Add Discord channel message puller
uv script that fetches messages from a channel for the last N hours via the Discord REST API and saves them as JSON for use with jq.
This commit is contained in:
1 parent
b45cd20452
commit
8f27a5fc1a
3 files changed
+197
No files matched your search
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
discord-bot.txt
|
||||
discord_*.json
|
||||
__pycache__/
|
||||
@@ -0,0 +1,36 @@
|
||||
# pull-messages-from-discord
|
||||
|
||||
Pull messages from a Discord channel for the last N hours and save them as JSON.
|
||||
|
||||
## Setup
|
||||
|
||||
Put the bot token in `.env` (gitignored):
|
||||
|
||||
```
|
||||
HM_DISCORD_BOT_TOKEN=...
|
||||
```
|
||||
|
||||
The bot needs View Channel + Read Message History on the channel, and the
|
||||
Message Content Intent enabled in the Developer Portal (otherwise `content`
|
||||
and `embeds` come back empty).
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
uv run script.py --channel homelab-alerts --time 24h
|
||||
uv run script.py -c homelab-alerts -t 24
|
||||
```
|
||||
|
||||
Options: `-c/--channel` (required), `-t/--time` hours, `24` or `24h` (default 24),
|
||||
`-g/--guild` name or ID if the channel name is ambiguous, `-o/--output` file path.
|
||||
|
||||
Output goes next to the script: `discord_<channel>_<UTC timestamp>.json`.
|
||||
|
||||
## jq examples
|
||||
|
||||
```
|
||||
jq '.count' discord_homelab-alerts_*.json
|
||||
jq -r '.messages[] | "\(.timestamp) \(.content)"' file.json
|
||||
jq '.messages[].embeds[].title' file.json
|
||||
jq '.messages[] | select(.content | test("error"; "i"))' file.json
|
||||
```
|
||||
@@ -0,0 +1,157 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["httpx"]
|
||||
# ///
|
||||
"""Pull messages from a Discord channel for the last N hours and save them as JSON."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
API = "https://discord.com/api/v10"
|
||||
DISCORD_EPOCH_MS = 1420070400000
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
# 0 text, 5 announcement
|
||||
TEXT_CHANNEL_TYPES = {0, 5}
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
token = os.environ.get("HM_DISCORD_BOT_TOKEN")
|
||||
if token:
|
||||
return token
|
||||
for name in (".env", "discord-bot.txt"):
|
||||
path = SCRIPT_DIR / name
|
||||
if not path.is_file():
|
||||
continue
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
if key.strip() == "HM_DISCORD_BOT_TOKEN":
|
||||
return value.strip().strip("'\"")
|
||||
sys.exit("error: HM_DISCORD_BOT_TOKEN not found in environment, .env, or discord-bot.txt")
|
||||
|
||||
|
||||
def parse_hours(value: str) -> int:
|
||||
m = re.fullmatch(r"(\d+)h?", value.strip().lower())
|
||||
if not m or int(m.group(1)) == 0:
|
||||
raise argparse.ArgumentTypeError(f"invalid time {value!r}; use hours, e.g. 24 or 24h")
|
||||
return int(m.group(1))
|
||||
|
||||
|
||||
def datetime_to_snowflake(dt: datetime) -> int:
|
||||
return (int(dt.timestamp() * 1000) - DISCORD_EPOCH_MS) << 22
|
||||
|
||||
|
||||
def api_get(client: httpx.Client, path: str, params: dict | None = None):
|
||||
while True:
|
||||
resp = client.get(f"{API}{path}", params=params)
|
||||
if resp.status_code == 429:
|
||||
time.sleep(float(resp.json().get("retry_after", 1)) + 0.1)
|
||||
continue
|
||||
if resp.status_code == 401:
|
||||
sys.exit("error: 401 unauthorized - bot token invalid")
|
||||
if resp.status_code == 403:
|
||||
sys.exit(f"error: 403 forbidden on {path} - bot needs View Channel + Read Message History")
|
||||
if resp.status_code >= 400:
|
||||
sys.exit(f"error: {resp.status_code} on {path}: {resp.text}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
def find_channel(client: httpx.Client, name: str, guild_filter: str | None) -> tuple[dict, dict]:
|
||||
matches = []
|
||||
for guild in api_get(client, "/users/@me/guilds"):
|
||||
if guild_filter and guild_filter not in (guild["id"], guild["name"]):
|
||||
continue
|
||||
for ch in api_get(client, f"/guilds/{guild['id']}/channels"):
|
||||
if ch["type"] in TEXT_CHANNEL_TYPES and name in (ch["name"], ch["id"]):
|
||||
matches.append((guild, ch))
|
||||
if not matches:
|
||||
sys.exit(f"error: no text channel named {name!r} visible to bot")
|
||||
if len(matches) > 1:
|
||||
options = "\n".join(
|
||||
f" {ch['id']} guild={g['name']!r} parent_id={ch.get('parent_id')}" for g, ch in matches
|
||||
)
|
||||
sys.exit(
|
||||
f"error: {len(matches)} channels match {name!r}:\n{options}\n"
|
||||
"Pass a channel ID to --channel (or --guild to narrow by server)."
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def fetch_messages(client: httpx.Client, channel_id: str, since: datetime) -> list[dict]:
|
||||
messages: list[dict] = []
|
||||
after = str(datetime_to_snowflake(since))
|
||||
while True:
|
||||
page = api_get(client, f"/channels/{channel_id}/messages", {"limit": 100, "after": after})
|
||||
if not page:
|
||||
break
|
||||
messages.extend(page)
|
||||
after = max(page, key=lambda m: int(m["id"]))["id"]
|
||||
if len(page) < 100:
|
||||
break
|
||||
messages.sort(key=lambda m: int(m["id"]))
|
||||
return messages
|
||||
|
||||
|
||||
def slim(msg: dict) -> dict:
|
||||
author = msg.get("author", {})
|
||||
return {
|
||||
"id": msg["id"],
|
||||
"timestamp": msg["timestamp"],
|
||||
"edited_timestamp": msg.get("edited_timestamp"),
|
||||
"author": {
|
||||
"id": author.get("id"),
|
||||
"username": author.get("username"),
|
||||
"bot": author.get("bot", False),
|
||||
},
|
||||
"content": msg.get("content", ""),
|
||||
"embeds": msg.get("embeds", []),
|
||||
"attachments": [
|
||||
{"filename": a.get("filename"), "url": a.get("url")} for a in msg.get("attachments", [])
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("-c", "--channel", required=True, help="channel name, e.g. homelab-alerts")
|
||||
parser.add_argument("-t", "--time", type=parse_hours, default=24, help="hours back, e.g. 24 or 24h (default 24)")
|
||||
parser.add_argument("-g", "--guild", help="guild name or ID (only if channel name is ambiguous)")
|
||||
parser.add_argument("-o", "--output", type=Path, help="output file (default: next to script)")
|
||||
args = parser.parse_args()
|
||||
|
||||
channel_name = args.channel.lstrip("#")
|
||||
now = datetime.now(timezone.utc)
|
||||
since = now - timedelta(hours=args.time)
|
||||
|
||||
headers = {"Authorization": f"Bot {load_token()}", "User-Agent": "DiscordBot (pull-messages, 1.0)"}
|
||||
with httpx.Client(headers=headers, timeout=30) as client:
|
||||
guild, channel = find_channel(client, channel_name, args.guild)
|
||||
raw = fetch_messages(client, channel["id"], since)
|
||||
|
||||
result = {
|
||||
"channel": channel_name,
|
||||
"channel_id": channel["id"],
|
||||
"guild": guild["name"],
|
||||
"since": since.isoformat(),
|
||||
"fetched_at": now.isoformat(),
|
||||
"hours": args.time,
|
||||
"count": len(raw),
|
||||
"messages": [slim(m) for m in raw],
|
||||
}
|
||||
out = args.output or SCRIPT_DIR / f"discord_{channel_name}_{now:%Y%m%dT%H%M%SZ}.json"
|
||||
out.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
|
||||
print(f"Saved {len(raw)} messages -> {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user