From c415e82500c0a22275adbc6921123f5ba29dab86 Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Wed, 13 May 2026 22:06:44 -0400 Subject: [PATCH] Initial Archive status bot --- .dockerignore | 9 + .env.deploy.example | 14 + .env.example | 14 + .gitignore | 7 + Dockerfile | 14 + README.md | 178 ++++++++ compose.local.yaml | 4 + compose.yaml | 18 + dashboard.html | 648 +++++++++++++++++++++++++++++ preview.html | 390 ++++++++++++++++++ services.example.json | 40 ++ status_bot.py | 928 ++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 2264 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.deploy.example create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.local.yaml create mode 100644 compose.yaml create mode 100644 dashboard.html create mode 100644 preview.html create mode 100644 services.example.json create mode 100644 status_bot.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9c2b2fd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.env +.git +.gitignore +__pycache__/ +*.py[cod] +state/ +services.json +compose.local.yaml +preview.html diff --git a/.env.deploy.example b/.env.deploy.example new file mode 100644 index 0000000..6d140a6 --- /dev/null +++ b/.env.deploy.example @@ -0,0 +1,14 @@ +DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token +DISCORD_CHANNEL_ID=1504278732070981683 +ARCHIVE_STATUS_CONFIG=services.json +ARCHIVE_STATUS_STATE=state/status-message.json +CHECK_INTERVAL_SECONDS=60 +HTTP_USER_AGENT=ArchiveStatusBot/1.0 +DISCORD_DRY_RUN=false +DASHBOARD_ENABLED=true +DASHBOARD_HOST=0.0.0.0 +DASHBOARD_PORT=8787 +DASHBOARD_USERNAME=admin +DASHBOARD_PASSWORD_HASH=replace-with-generated-pbkdf2-hash +DASHBOARD_SESSION_TTL_SECONDS=28800 +DASHBOARD_COOKIE_SECURE=true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c351831 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token +DISCORD_CHANNEL_ID=1504278732070981683 +ARCHIVE_STATUS_CONFIG=services.json +ARCHIVE_STATUS_STATE=state/status-message.json +CHECK_INTERVAL_SECONDS=60 +HTTP_USER_AGENT=ArchiveStatusBot/1.0 +DISCORD_DRY_RUN=false +DASHBOARD_ENABLED=true +DASHBOARD_HOST=0.0.0.0 +DASHBOARD_PORT=8787 +DASHBOARD_USERNAME=admin +DASHBOARD_PASSWORD_HASH=replace-with-generated-pbkdf2-hash +DASHBOARD_SESSION_TTL_SECONDS=28800 +DASHBOARD_COOKIE_SECURE=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f773ace --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +services.json +state/ +__pycache__/ +*.py[cod] +.agents/ +.codex/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..75c6b18 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-alpine + +WORKDIR /app + +COPY status_bot.py /app/status_bot.py +COPY dashboard.html /app/dashboard.html +COPY services.example.json /app/services.json + +RUN adduser -D -u 1000 -h /app archive-status +RUN mkdir -p /app/state && chown -R archive-status:archive-status /app + +USER archive-status + +CMD ["python", "/app/status_bot.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f36f4c6 --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# Archive Bot + +An AIO Discord bot foundation for The Mithral Archive. The first module is status monitoring: it checks configured URLs and keeps one live status message updated in the Discord `status` channel. + +The message uses a summary embed plus one small embed per service, so each service gets its own green or red Discord color bar. + +It does not need Discord gateway intents or slash commands for the status module. It only needs a bot token, the `status` channel ID, and permission to send/edit its own messages. + +The bot also includes a small web dashboard for editing monitored services and forcing immediate Discord refreshes. + +## Discord Bot Setup + +Create a Discord application and bot in the Discord Developer Portal, then invite it with: + +```text +https://discord.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=84992&scope=bot +``` + +Required channel permissions: + +- View Channel +- Send Messages +- Embed Links +- Read Message History + +The current `status` channel ID is: + +```text +1504278732070981683 +``` + +## Local Setup + +```sh +cp .env.example .env +cp services.example.json services.json +``` + +Edit `.env` and set: + +- `DISCORD_BOT_TOKEN` +- `DASHBOARD_USERNAME` +- `DASHBOARD_PASSWORD_HASH` + +Generate the dashboard password hash: + +```sh +python3 status_bot.py --hash-password +``` + +Paste the output into `DASHBOARD_PASSWORD_HASH`. The dashboard does not store a reusable browser token; it uses an HttpOnly session cookie and CSRF token after login. + +Dashboard auth includes: + +- PBKDF2-SHA256 password hashing +- HttpOnly `SameSite=Strict` session cookie +- CSRF token required for write actions +- basic failed-login throttling + +Edit `services.json` with the URLs you want displayed. Keep private/internal URLs out of Git if they should not be shared. + +Preview the Discord embed payload: + +```sh +ARCHIVE_STATUS_CONFIG=services.json python3 status_bot.py --preview +``` + +Open `preview.html` in a browser to see an approximate Discord-style render. Paste the JSON from `--preview` into the textarea and render it to test changes before sending anything to Discord. + +Run it: + +```sh +python3 status_bot.py +``` + +Local runs automatically read `.env` from the current directory. + +For dashboard testing without touching Discord, set: + +```env +DISCORD_DRY_RUN=true +ARCHIVE_STATUS_STATE=state/status-message.json +``` + +Open the dashboard: + +```text +http://127.0.0.1:8787 +``` + +Sign in with `DASHBOARD_USERNAME` and the password you used when generating `DASHBOARD_PASSWORD_HASH`. + +## Docker Setup + +```sh +cp .env.deploy.example .env +cp services.example.json services.json +docker compose up -d --build +``` + +The bot stores the Discord message ID in `state/status-message.json`. Keep that file mounted so the bot edits the same message after restarts. + +The deploy compose joins your existing reverse-proxy network: + +```yaml +networks: + mediaserver_default: + external: true +``` + +If that network does not already exist on the deploy host, create it once: + +```sh +docker network create mediaserver_default +``` + +The dashboard is exposed inside Docker on port `8787` for your reverse proxy. It is not published directly to the host by default. + +Use this target from your proxy: + +```text +http://archive-status-bot:8787 +``` + +For HTTPS behind a reverse proxy, set: + +```env +DASHBOARD_COOKIE_SECURE=true +``` + +Leave it `false` only for direct localhost HTTP testing. + +For direct local Docker testing without a proxy: + +```sh +docker compose -f compose.yaml -f compose.local.yaml up -d --build +``` + +Then open: + +```text +http://127.0.0.1:8787 +``` + +## Dashboard + +The dashboard currently supports: + +- viewing monitored services +- adding/removing service rows +- editing check URL, display URL, expected statuses, timeout, and keyword +- saving `services.json` +- forcing an immediate check and Discord message update + +The sidebar leaves room for future modules like polls, webhooks, automations, and service integrations without changing the bot shape later. + +## Service Config + +Each service supports: + +- `name`: label shown in Discord +- `url`: URL checked by the bot +- `displayUrl`: URL linked in the embed +- `method`: optional, defaults to `GET` +- `timeoutSeconds`: optional, defaults to `10` +- `expectedStatuses`: optional list such as `["200-399"]` or `[200, 204]` +- `keyword`: optional text that must appear in the response body + +Example: + +```json +{ + "name": "Jellyfin", + "url": "https://jellyfin.mithraic.cloud/health", + "displayUrl": "https://jellyfin.mithraic.cloud", + "expectedStatuses": ["200-399"] +} +``` diff --git a/compose.local.yaml b/compose.local.yaml new file mode 100644 index 0000000..37dfa3c --- /dev/null +++ b/compose.local.yaml @@ -0,0 +1,4 @@ +services: + archive-status-bot: + ports: + - 127.0.0.1:8787:8787 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..a87e171 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,18 @@ +services: + archive-status-bot: + build: . + container_name: archive-status-bot + restart: unless-stopped + env_file: + - .env + expose: + - "8787" + volumes: + - ./services.json:/app/services.json + - ./state:/app/state + networks: + - mediaserver_default + +networks: + mediaserver_default: + external: true diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..0bd3c77 --- /dev/null +++ b/dashboard.html @@ -0,0 +1,648 @@ + + + + + + Archive Bot Dashboard + + + + + + + + + + diff --git a/preview.html b/preview.html new file mode 100644 index 0000000..7f72ee4 --- /dev/null +++ b/preview.html @@ -0,0 +1,390 @@ + + + + + + Archive Status Preview + + + +
+

Archive Status Preview

+ +
+
# status
+
+
A
+
+
+ Archive Status + BOT + Today at 9:03 PM +
+
+
+ + + +
+
+
+
+
+
+ + + + diff --git a/services.example.json b/services.example.json new file mode 100644 index 0000000..a7be5b0 --- /dev/null +++ b/services.example.json @@ -0,0 +1,40 @@ +{ + "services": [ + { + "name": "Portal", + "url": "https://mithraic.cloud", + "displayUrl": "https://mithraic.cloud", + "expectedStatuses": ["200-399"] + }, + { + "name": "Jellyfin", + "url": "https://jellyfin.mithraic.cloud/health", + "displayUrl": "https://jellyfin.mithraic.cloud", + "expectedStatuses": ["200-399"] + }, + { + "name": "Navidrome", + "url": "https://listen.mithraic.cloud", + "displayUrl": "https://listen.mithraic.cloud", + "expectedStatuses": ["200-399"] + }, + { + "name": "Voting Hub", + "url": "https://vote.mithraic.cloud", + "displayUrl": "https://vote.mithraic.cloud", + "expectedStatuses": ["200-399"] + }, + { + "name": "Forgejo", + "url": "https://git.mithraic.cloud", + "displayUrl": "https://git.mithraic.cloud", + "expectedStatuses": ["200-399"] + }, + { + "name": "Support", + "url": "https://support.mithraic.cloud", + "displayUrl": "https://support.mithraic.cloud", + "expectedStatuses": ["200-399"] + } + ] +} diff --git a/status_bot.py b/status_bot.py new file mode 100644 index 0000000..e287639 --- /dev/null +++ b/status_bot.py @@ -0,0 +1,928 @@ +#!/usr/bin/env python3 +"""Live Discord status message for The Mithral Archive.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import secrets +import signal +import socket +import ssl +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone +from http.cookies import SimpleCookie +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +DISCORD_API = "https://discord.com/api/v10" +DEFAULT_INTERVAL_SECONDS = 60 +DEFAULT_TIMEOUT_SECONDS = 10 +MAX_DISCORD_EMBEDS = 10 +MAX_REQUEST_BYTES = 1_000_000 +SESSION_COOKIE = "archive_bot_session" +PBKDF2_ITERATIONS = 390_000 + + +@dataclass(frozen=True) +class Service: + name: str + url: str + display_url: str + method: str + timeout: float + expected_statuses: set[int] + expected_min: int + expected_max: int + keyword: str | None + + +@dataclass(frozen=True) +class CheckResult: + service: Service + ok: bool + status: int | None + latency_ms: int | None + error: str | None + + +@dataclass(frozen=True) +class DashboardAuthConfig: + username: str + password_hash: str + session_ttl_seconds: int + cookie_secure: bool + + +@dataclass +class DashboardSession: + username: str + csrf_token: str + expires_at: float + + +class DashboardAuth: + def __init__(self, config: DashboardAuthConfig) -> None: + self.config = config + self.lock = threading.Lock() + self.sessions: dict[str, DashboardSession] = {} + self.failed_logins: dict[str, list[float]] = {} + + def login_allowed(self, key: str) -> bool: + now = time.time() + window_start = now - 900 + with self.lock: + attempts = [attempt for attempt in self.failed_logins.get(key, []) if attempt >= window_start] + self.failed_logins[key] = attempts + return len(attempts) < 10 + + def record_failed_login(self, key: str) -> None: + now = time.time() + with self.lock: + self.failed_logins.setdefault(key, []).append(now) + + def clear_failed_login(self, key: str) -> None: + with self.lock: + self.failed_logins.pop(key, None) + + def login(self, username: str, password: str) -> tuple[str, DashboardSession] | None: + if not hmac.compare_digest(username, self.config.username): + return None + if not verify_password_hash(self.config.password_hash, password): + return None + + session_id = secrets.token_urlsafe(32) + session = DashboardSession( + username=username, + csrf_token=secrets.token_urlsafe(32), + expires_at=time.time() + self.config.session_ttl_seconds, + ) + with self.lock: + self.sessions[session_id] = session + return session_id, session + + def session_from_cookie(self, cookie_header: str | None) -> tuple[str, DashboardSession] | None: + if not cookie_header: + return None + cookie = SimpleCookie() + cookie.load(cookie_header) + morsel = cookie.get(SESSION_COOKIE) + if morsel is None: + return None + + session_id = morsel.value + now = time.time() + with self.lock: + session = self.sessions.get(session_id) + if session is None: + return None + if session.expires_at <= now: + self.sessions.pop(session_id, None) + return None + session.expires_at = now + self.config.session_ttl_seconds + return session_id, session + + def logout(self, session_id: str) -> None: + with self.lock: + self.sessions.pop(session_id, None) + + +class BotRuntime: + def __init__( + self, + token: str, + channel_id: str, + config_path: Path, + state_path: Path, + dry_run: bool = False, + ) -> None: + self.token = token + self.channel_id = channel_id + self.config_path = config_path + self.state_path = state_path + self.dry_run = dry_run + self.lock = threading.Lock() + self.last_results: list[CheckResult] = [] + self.last_error: str | None = None + self.last_message_id: str | None = None + self.last_checked_at: datetime | None = None + + +def env(name: str, default: str | None = None) -> str: + value = os.getenv(name, default) + if value is None or not value.strip(): + raise SystemExit(f"Missing required environment variable: {name}") + return value.strip() + + +def load_dotenv(path: Path = Path(".env")) -> None: + if not path.exists(): + return + + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + + key, value = stripped.split("=", 1) + key = key.strip() + value = value.strip().strip("\"'") + if key and key not in os.environ: + os.environ[key] = value + + +def load_json(path: Path) -> dict[str, Any]: + try: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except FileNotFoundError as exc: + raise ValueError(f"Config file not found: {path}") from exc + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON in {path}: {exc}") from exc + + if not isinstance(data, dict): + raise ValueError(f"Config must be a JSON object: {path}") + return data + + +def parse_expected_statuses(raw: Any) -> tuple[set[int], int, int]: + if raw is None: + return set(), 200, 399 + + if isinstance(raw, str): + raw = [part.strip() for part in raw.split(",") if part.strip()] + + if not isinstance(raw, list): + raise ValueError("expectedStatuses must be a list or comma-separated string") + + exact: set[int] = set() + min_status = 999 + max_status = 0 + + for item in raw: + if isinstance(item, int): + exact.add(item) + continue + if not isinstance(item, str): + raise ValueError("expectedStatuses entries must be integers or ranges") + if "-" in item: + left, right = item.split("-", 1) + try: + min_status = min(min_status, int(left)) + max_status = max(max_status, int(right)) + except ValueError as exc: + raise ValueError(f"Invalid expected status range: {item}") from exc + continue + try: + exact.add(int(item)) + except ValueError as exc: + raise ValueError(f"Invalid expected status value: {item}") from exc + + if min_status == 999 and max_status == 0: + min_status, max_status = 0, -1 + return exact, min_status, max_status + + +def services_from_data(data: dict[str, Any]) -> list[Service]: + raw_services = data.get("services") + if not isinstance(raw_services, list) or not raw_services: + raise ValueError("Config must include a non-empty services array") + + services: list[Service] = [] + for index, item in enumerate(raw_services, start=1): + if not isinstance(item, dict): + raise ValueError(f"Service #{index} must be an object") + + name = str(item.get("name", "")).strip() + url = str(item.get("url", "")).strip() + if not name or not url: + raise ValueError(f"Service #{index} must include name and url") + + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError(f"Service {name} has an invalid http(s) URL") + + exact, minimum, maximum = parse_expected_statuses(item.get("expectedStatuses")) + services.append( + Service( + name=name, + url=url, + display_url=str(item.get("displayUrl", url)).strip() or url, + method=str(item.get("method", "GET")).strip().upper(), + timeout=float(item.get("timeoutSeconds", DEFAULT_TIMEOUT_SECONDS)), + expected_statuses=exact, + expected_min=minimum, + expected_max=maximum, + keyword=(str(item["keyword"]).strip() if item.get("keyword") else None), + ) + ) + + return services + + +def load_services(path: Path) -> list[Service]: + return services_from_data(load_json(path)) + + +def save_services_config(path: Path, data: dict[str, Any]) -> None: + services_from_data(data) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(f"{path.suffix}.tmp") + with temporary.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + try: + temporary.replace(path) + except OSError: + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + temporary.unlink(missing_ok=True) + + +def services_to_jsonable(services: list[Service]) -> list[dict[str, Any]]: + output: list[dict[str, Any]] = [] + for service in services: + expected: list[int | str] = sorted(service.expected_statuses) + if service.expected_min <= service.expected_max: + expected.append(f"{service.expected_min}-{service.expected_max}") + item: dict[str, Any] = { + "name": service.name, + "url": service.url, + "displayUrl": service.display_url, + "method": service.method, + "timeoutSeconds": service.timeout, + "expectedStatuses": expected or ["200-399"], + } + if service.keyword: + item["keyword"] = service.keyword + output.append(item) + return output + + +def status_expected(service: Service, status: int) -> bool: + if status in service.expected_statuses: + return True + return service.expected_min <= status <= service.expected_max + + +def check_service(service: Service) -> CheckResult: + started = time.monotonic() + headers = {"User-Agent": env("HTTP_USER_AGENT", "ArchiveStatusBot/1.0")} + request = urllib.request.Request(service.url, headers=headers, method=service.method) + + try: + context = ssl.create_default_context() + with urllib.request.urlopen(request, timeout=service.timeout, context=context) as response: + body = response.read(1_000_000) if service.keyword else b"" + status = int(response.status) + except urllib.error.HTTPError as exc: + status = int(exc.code) + latency_ms = int((time.monotonic() - started) * 1000) + ok = status_expected(service, status) + return CheckResult(service, ok, status, latency_ms, None if ok else f"HTTP {status}") + except (urllib.error.URLError, TimeoutError, socket.timeout, ssl.SSLError) as exc: + latency_ms = int((time.monotonic() - started) * 1000) + return CheckResult(service, False, None, latency_ms, clean_error(exc)) + + latency_ms = int((time.monotonic() - started) * 1000) + ok = status_expected(service, status) + + if ok and service.keyword: + try: + text = body.decode("utf-8", errors="ignore") + except UnicodeDecodeError: + text = "" + if service.keyword not in text: + ok = False + return CheckResult(service, False, status, latency_ms, "keyword missing") + + return CheckResult(service, ok, status, latency_ms, None if ok else f"HTTP {status}") + + +def clean_error(exc: BaseException) -> str: + reason = getattr(exc, "reason", None) + if reason: + return str(reason)[:120] + return str(exc)[:120] or exc.__class__.__name__ + + +def discord_request( + method: str, + token: str, + path: str, + payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + body = None + headers = { + "Authorization": f"Bot {token}", + "User-Agent": "ArchiveStatusBot/1.0", + } + + if payload is not None: + body = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + request = urllib.request.Request( + f"{DISCORD_API}{path}", + data=body, + headers=headers, + method=method, + ) + + try: + with urllib.request.urlopen(request, timeout=20) as response: + data = response.read() + if not data: + return {} + return json.loads(data.decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="ignore") + raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc + + +def discord_bot_identity(token: str) -> dict[str, Any]: + return discord_request("GET", token, "/users/@me") + + +def load_state(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def save_state(path: Path, state: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(f"{path.suffix}.tmp") + with temporary.open("w", encoding="utf-8") as handle: + json.dump(state, handle, indent=2, sort_keys=True) + handle.write("\n") + temporary.replace(path) + + +def render_embeds(results: list[CheckResult]) -> list[dict[str, Any]]: + checked_at = datetime.now(timezone.utc) + online = sum(1 for result in results if result.ok) + total = len(results) + degraded = 0 < online < total + + if online == total: + color = 0x10B981 + summary = f"🟢 Operational Ā· {online}/{total} online" + elif degraded: + color = 0xF59E0B + offline = total - online + attention = "1 service needs attention" if offline == 1 else f"{offline} services need attention" + summary = f"🟔 Degraded Ā· {online}/{total} online Ā· {attention}" + else: + color = 0xEF4444 + summary = f"šŸ”“ Outage Ā· {online}/{total} online" + + service_lines = [] + state_lines = [] + for result in results: + icon = "🟢" if result.ok else "šŸ”“" + label = "Online" if result.ok else "Issue" + + service_lines.append(f"**{result.service.name}**") + state_lines.append(f"{icon} {label}") + + interval = os.getenv("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS)).strip() + return [ + { + "title": "The Mithral Archive", + "description": summary, + "color": color, + "fields": [ + { + "name": "Service", + "value": "\n".join(service_lines)[:1024] or "No services configured.", + "inline": True, + }, + { + "name": "Status", + "value": "\n".join(state_lines)[:1024] or "n/a", + "inline": True, + } + ], + "footer": {"text": f"Refreshes every {interval}s • Last checked {checked_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"}, + "timestamp": checked_at.isoformat(), + } + ] + + +def upsert_status_message( + token: str, + channel_id: str, + state_path: Path, + results: list[CheckResult], +) -> str: + state = load_state(state_path) + message_id = str(state.get("message_id", "")).strip() + payload = {"content": "", "embeds": render_embeds(results)} + + if message_id: + try: + discord_request("PATCH", token, f"/channels/{channel_id}/messages/{message_id}", payload) + return message_id + except RuntimeError as exc: + print(f"Could not edit existing status message, creating a new one: {exc}", file=sys.stderr) + + message = discord_request("POST", token, f"/channels/{channel_id}/messages", payload) + new_id = str(message.get("id", "")).strip() + if not new_id: + raise RuntimeError("Discord did not return a message id") + + save_state(state_path, {"message_id": new_id}) + return new_id + + +def fake_preview_results(services: list[Service]) -> list[CheckResult]: + results: list[CheckResult] = [] + for index, service in enumerate(services): + results.append( + CheckResult( + service=service, + ok=index != len(services) - 1, + status=200 if index != len(services) - 1 else 502, + latency_ms=42 + (index * 31), + error=None if index != len(services) - 1 else "HTTP 502", + ) + ) + return results + + +def print_preview(services: list[Service]) -> None: + payload = {"content": "", "embeds": render_embeds(fake_preview_results(services))} + print(json.dumps(payload, indent=2)) + + +def result_to_jsonable(result: CheckResult) -> dict[str, Any]: + return { + "name": result.service.name, + "url": result.service.url, + "displayUrl": result.service.display_url, + "ok": result.ok, + "status": result.status, + "latencyMs": result.latency_ms, + "error": result.error, + } + + +def run_check_cycle(runtime: BotRuntime) -> tuple[str, list[CheckResult]]: + services = load_services(runtime.config_path) + results = [check_service(service) for service in services] + if runtime.dry_run: + message_id = "dry-run" + else: + message_id = upsert_status_message(runtime.token, runtime.channel_id, runtime.state_path, results) + + with runtime.lock: + runtime.last_results = results + runtime.last_message_id = message_id + runtime.last_checked_at = datetime.now(timezone.utc) + runtime.last_error = None + + return message_id, results + + +def runtime_status(runtime: BotRuntime) -> dict[str, Any]: + services = load_services(runtime.config_path) + with runtime.lock: + results = list(runtime.last_results) + return { + "services": services_to_jsonable(services), + "results": [result_to_jsonable(result) for result in results], + "lastError": runtime.last_error, + "lastMessageId": runtime.last_message_id, + "lastCheckedAt": runtime.last_checked_at.isoformat() if runtime.last_checked_at else None, + "channelId": runtime.channel_id, + } + + +def bool_env(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def password_hash(password: str) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS) + salt_text = base64.urlsafe_b64encode(salt).decode("ascii").rstrip("=") + digest_text = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt_text}${digest_text}" + + +def decode_urlsafe_base64(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(value + padding) + + +def verify_password_hash(encoded: str, password: str) -> bool: + try: + algorithm, iterations, salt_text, digest_text = encoded.split("$", 3) + if algorithm != "pbkdf2_sha256": + return False + salt = decode_urlsafe_base64(salt_text) + expected = decode_urlsafe_base64(digest_text) + actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations)) + except (ValueError, TypeError): + return False + return hmac.compare_digest(actual, expected) + + +def dashboard_auth_from_env() -> DashboardAuth | None: + if bool_env("DASHBOARD_AUTH_DISABLED", False): + return None + + username = env("DASHBOARD_USERNAME") + encoded_hash = env("DASHBOARD_PASSWORD_HASH") + ttl = int(os.getenv("DASHBOARD_SESSION_TTL_SECONDS", "28800")) + secure = bool_env("DASHBOARD_COOKIE_SECURE", False) + return DashboardAuth( + DashboardAuthConfig( + username=username, + password_hash=encoded_hash, + session_ttl_seconds=ttl, + cookie_secure=secure, + ) + ) + + +def print_password_hash() -> None: + import getpass + + first = getpass.getpass("Dashboard password: ") + second = getpass.getpass("Confirm password: ") + if first != second: + raise SystemExit("Passwords did not match") + if len(first) < 12: + raise SystemExit("Use at least 12 characters") + print(password_hash(first)) + + +def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> type[BaseHTTPRequestHandler]: + dashboard_path = Path(__file__).with_name("dashboard.html") + + class DashboardHandler(BaseHTTPRequestHandler): + server_version = "ArchiveStatusDashboard/1.0" + + def log_message(self, format: str, *args: Any) -> None: + print(f"[dashboard] {self.address_string()} - {format % args}", flush=True) + + def do_GET(self) -> None: + if self.path in {"/", "/dashboard"}: + self.send_dashboard() + return + if self.path == "/favicon.ico": + self.send_response(HTTPStatus.NO_CONTENT) + self.end_headers() + return + if self.path == "/api/session": + session = self.require_auth() + if session is None: + return + _session_id, data = session + self.send_json( + HTTPStatus.OK, + { + "username": data.username, + "csrfToken": data.csrf_token, + }, + ) + return + if self.path == "/api/status": + if self.require_auth() is None: + return + self.send_json(HTTPStatus.OK, runtime_status(runtime)) + return + self.send_error(HTTPStatus.NOT_FOUND) + + def do_POST(self) -> None: + if self.path == "/api/login": + self.handle_login() + return + session = self.require_auth(require_csrf=True) + if session is None: + return + if self.path == "/api/logout": + self.handle_logout(session[0]) + return + if self.path == "/api/check": + self.handle_check() + return + if self.path == "/api/services": + self.handle_services() + return + self.send_error(HTTPStatus.NOT_FOUND) + + def require_auth(self, require_csrf: bool = False) -> tuple[str, DashboardSession] | None: + if auth is None: + return "disabled", DashboardSession("local", "disabled", time.time() + 3600) + + session = auth.session_from_cookie(self.headers.get("Cookie")) + if session is None: + self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Login required"}) + return None + + if require_csrf: + csrf = self.headers.get("X-CSRF-Token", "") + if not hmac.compare_digest(csrf, session[1].csrf_token): + self.send_json(HTTPStatus.FORBIDDEN, {"error": "CSRF token mismatch"}) + return None + + return session + + def cookie_attributes(self, max_age: int) -> str: + attrs = [ + "Path=/", + "HttpOnly", + "SameSite=Strict", + f"Max-Age={max_age}", + ] + if auth is not None and auth.config.cookie_secure: + attrs.append("Secure") + return "; ".join(attrs) + + def set_session_cookie(self, session_id: str) -> None: + ttl = auth.config.session_ttl_seconds if auth is not None else 3600 + self.send_header( + "Set-Cookie", + f"{SESSION_COOKIE}={session_id}; {self.cookie_attributes(ttl)}", + ) + + def clear_session_cookie(self) -> None: + self.send_header( + "Set-Cookie", + f"{SESSION_COOKIE}=; {self.cookie_attributes(0)}", + ) + + def send_dashboard(self) -> None: + try: + body = dashboard_path.read_bytes() + except FileNotFoundError: + self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, "dashboard.html missing") + return + + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def read_json(self) -> dict[str, Any]: + raw_length = self.headers.get("Content-Length", "0") + try: + length = int(raw_length) + except ValueError as exc: + raise ValueError("Invalid Content-Length") from exc + if length > MAX_REQUEST_BYTES: + raise ValueError("Request body is too large") + + body = self.rfile.read(length) + try: + data = json.loads(body.decode("utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON: {exc}") from exc + if not isinstance(data, dict): + raise ValueError("JSON body must be an object") + return data + + def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None: + body = json.dumps(payload, indent=2).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def handle_login(self) -> None: + if auth is None: + self.send_json(HTTPStatus.OK, {"username": "local", "csrfToken": "disabled"}) + return + + try: + data = self.read_json() + except ValueError as exc: + self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) + return + + username = str(data.get("username", "")) + password = str(data.get("password", "")) + throttle_key = f"{self.client_address[0]}:{username}" + if not auth.login_allowed(throttle_key): + self.send_json(HTTPStatus.TOO_MANY_REQUESTS, {"error": "Too many login attempts. Try again later."}) + return + + login = auth.login(username, password) + if login is None: + auth.record_failed_login(throttle_key) + self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Invalid username or password"}) + return + + auth.clear_failed_login(throttle_key) + session_id, session = login + payload = { + "username": session.username, + "csrfToken": session.csrf_token, + } + body = json.dumps(payload, indent=2).encode("utf-8") + self.send_response(HTTPStatus.OK) + self.set_session_cookie(session_id) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def handle_logout(self, session_id: str) -> None: + if auth is not None: + auth.logout(session_id) + body = b'{\n "ok": true\n}' + self.send_response(HTTPStatus.OK) + self.clear_session_cookie() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def handle_check(self) -> None: + try: + message_id, results = run_check_cycle(runtime) + except Exception as exc: + with runtime.lock: + runtime.last_error = str(exc) + self.send_json(HTTPStatus.BAD_GATEWAY, {"error": str(exc)}) + return + + self.send_json( + HTTPStatus.OK, + { + "messageId": message_id, + "results": [result_to_jsonable(result) for result in results], + }, + ) + + def handle_services(self) -> None: + try: + data = self.read_json() + services_from_data(data) + save_services_config(runtime.config_path, data) + message_id, results = run_check_cycle(runtime) + except Exception as exc: + with runtime.lock: + runtime.last_error = str(exc) + self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) + return + + self.send_json( + HTTPStatus.OK, + { + "messageId": message_id, + "services": data.get("services", []), + "results": [result_to_jsonable(result) for result in results], + }, + ) + + return DashboardHandler + + +def maybe_start_dashboard(runtime: BotRuntime) -> ThreadingHTTPServer | None: + if not bool_env("DASHBOARD_ENABLED", False): + return None + + host = os.getenv("DASHBOARD_HOST", "127.0.0.1").strip() or "127.0.0.1" + port = int(os.getenv("DASHBOARD_PORT", "8787")) + auth = dashboard_auth_from_env() + server = ThreadingHTTPServer((host, port), make_dashboard_handler(runtime, auth)) + thread = threading.Thread(target=server.serve_forever, name="dashboard", daemon=True) + thread.start() + auth_note = "without auth" if auth is None else "with password sessions" + print(f"Dashboard running at http://{host}:{port} ({auth_note})", flush=True) + return server + + +def main() -> int: + load_dotenv() + + if "--hash-password" in sys.argv: + print_password_hash() + return 0 + + if "--preview" in sys.argv: + config_path = Path(os.getenv("ARCHIVE_STATUS_CONFIG", "services.json")) + print_preview(load_services(config_path)) + return 0 + + token = env("DISCORD_BOT_TOKEN") + channel_id = env("DISCORD_CHANNEL_ID") + config_path = Path(env("ARCHIVE_STATUS_CONFIG", "services.json")) + state_path = Path(env("ARCHIVE_STATUS_STATE", "state/status-message.json")) + interval = int(env("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS))) + runtime = BotRuntime(token, channel_id, config_path, state_path, dry_run=bool_env("DISCORD_DRY_RUN", False)) + dashboard = maybe_start_dashboard(runtime) + + if runtime.dry_run: + print("Discord dry run is enabled; no Discord messages will be sent or edited.", flush=True) + else: + try: + identity = discord_bot_identity(token) + username = identity.get("username", "unknown") + bot_id = identity.get("id", "unknown") + print(f"Discord token authenticated as {username} ({bot_id})", flush=True) + except Exception as exc: + print(f"Could not verify Discord bot identity: {exc}", file=sys.stderr, flush=True) + + stopped = False + + def stop(_signum: int, _frame: Any) -> None: + nonlocal stopped + stopped = True + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + + while not stopped: + try: + message_id, results = run_check_cycle(runtime) + online = sum(1 for result in results if result.ok) + print(f"Updated Discord status message {message_id}: {online}/{len(results)} online", flush=True) + except Exception as exc: + with runtime.lock: + runtime.last_error = str(exc) + print(f"Status update failed: {exc}", file=sys.stderr, flush=True) + + for _ in range(interval): + if stopped: + break + time.sleep(1) + + if dashboard is not None: + dashboard.shutdown() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())