diff --git a/.env.deploy.example b/.env.deploy.example index d29e0a5..064bbbb 100644 --- a/.env.deploy.example +++ b/.env.deploy.example @@ -1,5 +1,6 @@ DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token DISCORD_CHANNEL_ID=1504278732070981683 +DISCORD_GATEWAY_ENABLED=true ARCHIVE_STATUS_CONFIG=services.json ARCHIVE_STATUS_STATE=state/status-message.json CHECK_INTERVAL_SECONDS=60 diff --git a/.env.example b/.env.example index c351831..025ab7d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token DISCORD_CHANNEL_ID=1504278732070981683 +DISCORD_GATEWAY_ENABLED=true ARCHIVE_STATUS_CONFIG=services.json ARCHIVE_STATUS_STATE=state/status-message.json CHECK_INTERVAL_SECONDS=60 diff --git a/Dockerfile b/Dockerfile index a53a2d4..8777d1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,12 @@ FROM python:3.12-alpine WORKDIR /app +COPY requirements.txt /app/requirements.txt COPY --chown=1001:1001 status_bot.py /app/status_bot.py COPY --chown=1001:1001 dashboard.html /app/dashboard.html COPY --chown=1001:1001 services.example.json /app/services.json +RUN pip install --no-cache-dir -r /app/requirements.txt RUN adduser -D -u 1001 -g "" -h /app archive-status CMD ["python", "/app/status_bot.py"] diff --git a/README.md b/README.md index 068a1c9..44a1d41 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ cp services.example.json services.json Edit `.env` and set: - `DISCORD_BOT_TOKEN` +- `DISCORD_GATEWAY_ENABLED=true` - `DASHBOARD_USERNAME` - `DASHBOARD_PASSWORD_HASH` @@ -59,6 +60,8 @@ Dashboard auth includes: - CSRF token required for write actions - basic failed-login throttling +The bot keeps a Gateway connection open so Discord shows it as online even when no status refresh is happening. + 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: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b3c356c --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +discord.py>=2.4,<3 diff --git a/status_bot.py b/status_bot.py index 638502d..331cbd8 100644 --- a/status_bot.py +++ b/status_bot.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 +import asyncio import hashlib import hmac import json @@ -160,6 +161,75 @@ class BotRuntime: self.last_checked_at: datetime | None = None +class DiscordGatewayManager: + def __init__(self, token: str) -> None: + self.token = token + self.thread: threading.Thread | None = None + self.loop: asyncio.AbstractEventLoop | None = None + self.client: Any = None + self.ready = threading.Event() + self._disconnecting = threading.Event() + + def start(self) -> None: + if self.thread is not None: + return + self.thread = threading.Thread(target=self._run, name="discord-gateway", daemon=True) + self.thread.start() + + def stop(self) -> None: + self._disconnecting.set() + if self.loop is not None and self.client is not None: + asyncio.run_coroutine_threadsafe(self.client.close(), self.loop) + if self.thread is not None: + self.thread.join(timeout=15) + + def _run(self) -> None: + try: + import discord + except ImportError: + print("discord.py is not installed. Install requirements.txt to keep the bot online.", file=sys.stderr, flush=True) + self.ready.set() + return + + class GatewayClient(discord.Client): + def __init__(self, manager: DiscordGatewayManager) -> None: + intents = discord.Intents.default() + intents.guilds = True + super().__init__(intents=intents) + self.manager = manager + + async def on_ready(self) -> None: + await self.change_presence(status=discord.Status.online) + user = self.user + name = user.name if user is not None else "unknown" + bot_id = user.id if user is not None else "unknown" + print(f"Discord gateway connected as {name} ({bot_id})", flush=True) + self.manager.ready.set() + + async def on_disconnect(self) -> None: + self.manager.ready.clear() + + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.client = GatewayClient(self) + + try: + self.loop.run_until_complete(self.client.start(self.token)) + except Exception as exc: + if not self._disconnecting.is_set(): + print(f"Discord gateway stopped: {exc}", file=sys.stderr, flush=True) + finally: + self.ready.set() + try: + pending = asyncio.all_tasks(self.loop) + for task in pending: + task.cancel() + if pending: + self.loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + finally: + self.loop.close() + + def env(name: str, default: str | None = None) -> str: value = os.getenv(name, default) if value is None or not value.strip(): @@ -894,6 +964,10 @@ def main() -> int: 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)) + gateway = None + if bool_env("DISCORD_GATEWAY_ENABLED", True) and not runtime.dry_run: + gateway = DiscordGatewayManager(token) + gateway.start() dashboard = maybe_start_dashboard(runtime) if runtime.dry_run: @@ -933,6 +1007,8 @@ def main() -> int: if dashboard is not None: dashboard.shutdown() + if gateway is not None: + gateway.stop() return 0