Keep bot online via Discord gateway
This commit is contained in:
parent
bd282b4dc3
commit
e6fbd670ac
6 changed files with 84 additions and 0 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
|
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
|
||||||
DISCORD_CHANNEL_ID=1504278732070981683
|
DISCORD_CHANNEL_ID=1504278732070981683
|
||||||
|
DISCORD_GATEWAY_ENABLED=true
|
||||||
ARCHIVE_STATUS_CONFIG=services.json
|
ARCHIVE_STATUS_CONFIG=services.json
|
||||||
ARCHIVE_STATUS_STATE=state/status-message.json
|
ARCHIVE_STATUS_STATE=state/status-message.json
|
||||||
CHECK_INTERVAL_SECONDS=60
|
CHECK_INTERVAL_SECONDS=60
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
|
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
|
||||||
DISCORD_CHANNEL_ID=1504278732070981683
|
DISCORD_CHANNEL_ID=1504278732070981683
|
||||||
|
DISCORD_GATEWAY_ENABLED=true
|
||||||
ARCHIVE_STATUS_CONFIG=services.json
|
ARCHIVE_STATUS_CONFIG=services.json
|
||||||
ARCHIVE_STATUS_STATE=state/status-message.json
|
ARCHIVE_STATUS_STATE=state/status-message.json
|
||||||
CHECK_INTERVAL_SECONDS=60
|
CHECK_INTERVAL_SECONDS=60
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ FROM python:3.12-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt /app/requirements.txt
|
||||||
COPY --chown=1001:1001 status_bot.py /app/status_bot.py
|
COPY --chown=1001:1001 status_bot.py /app/status_bot.py
|
||||||
COPY --chown=1001:1001 dashboard.html /app/dashboard.html
|
COPY --chown=1001:1001 dashboard.html /app/dashboard.html
|
||||||
COPY --chown=1001:1001 services.example.json /app/services.json
|
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
|
RUN adduser -D -u 1001 -g "" -h /app archive-status
|
||||||
|
|
||||||
CMD ["python", "/app/status_bot.py"]
|
CMD ["python", "/app/status_bot.py"]
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ cp services.example.json services.json
|
||||||
Edit `.env` and set:
|
Edit `.env` and set:
|
||||||
|
|
||||||
- `DISCORD_BOT_TOKEN`
|
- `DISCORD_BOT_TOKEN`
|
||||||
|
- `DISCORD_GATEWAY_ENABLED=true`
|
||||||
- `DASHBOARD_USERNAME`
|
- `DASHBOARD_USERNAME`
|
||||||
- `DASHBOARD_PASSWORD_HASH`
|
- `DASHBOARD_PASSWORD_HASH`
|
||||||
|
|
||||||
|
|
@ -59,6 +60,8 @@ Dashboard auth includes:
|
||||||
- CSRF token required for write actions
|
- CSRF token required for write actions
|
||||||
- basic failed-login throttling
|
- 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.
|
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:
|
Preview the Discord embed payload:
|
||||||
|
|
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
discord.py>=2.4,<3
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
|
@ -160,6 +161,75 @@ class BotRuntime:
|
||||||
self.last_checked_at: datetime | None = None
|
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:
|
def env(name: str, default: str | None = None) -> str:
|
||||||
value = os.getenv(name, default)
|
value = os.getenv(name, default)
|
||||||
if value is None or not value.strip():
|
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"))
|
state_path = Path(env("ARCHIVE_STATUS_STATE", "state/status-message.json"))
|
||||||
interval = int(env("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS)))
|
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))
|
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)
|
dashboard = maybe_start_dashboard(runtime)
|
||||||
|
|
||||||
if runtime.dry_run:
|
if runtime.dry_run:
|
||||||
|
|
@ -933,6 +1007,8 @@ def main() -> int:
|
||||||
|
|
||||||
if dashboard is not None:
|
if dashboard is not None:
|
||||||
dashboard.shutdown()
|
dashboard.shutdown()
|
||||||
|
if gateway is not None:
|
||||||
|
gateway.stop()
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue