178 lines
5.9 KiB
Python
178 lines
5.9 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import secrets
|
|
import sys
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any
|
|
|
|
from .core import DISCORD_API
|
|
|
|
|
|
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 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_multipart_request(
|
|
method: str,
|
|
token: str,
|
|
path: str,
|
|
payload: dict[str, Any],
|
|
files: list[tuple[str, str, str, bytes]],
|
|
) -> dict[str, Any]:
|
|
boundary = f"----ArchiveBot{secrets.token_hex(16)}"
|
|
body = bytearray()
|
|
|
|
def add_part(name: str, content: bytes, content_type: str, filename: str | None = None) -> None:
|
|
body.extend(f"--{boundary}\r\n".encode("ascii"))
|
|
disposition = f'Content-Disposition: form-data; name="{name}"'
|
|
if filename is not None:
|
|
disposition += f'; filename="{filename}"'
|
|
body.extend(f"{disposition}\r\n".encode("utf-8"))
|
|
body.extend(f"Content-Type: {content_type}\r\n\r\n".encode("ascii"))
|
|
body.extend(content)
|
|
body.extend(b"\r\n")
|
|
|
|
add_part("payload_json", json.dumps(payload).encode("utf-8"), "application/json")
|
|
for field_name, filename, content_type, content in files:
|
|
add_part(field_name, content, content_type, filename)
|
|
body.extend(f"--{boundary}--\r\n".encode("ascii"))
|
|
|
|
request = urllib.request.Request(
|
|
f"{DISCORD_API}{path}",
|
|
data=bytes(body),
|
|
headers={
|
|
"Authorization": f"Bot {token}",
|
|
"User-Agent": "ArchiveStatusBot/1.0",
|
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
},
|
|
method=method,
|
|
)
|
|
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) 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_delete_message(token: str, channel_id: str, message_id: str) -> None:
|
|
try:
|
|
discord_request("DELETE", token, f"/channels/{channel_id}/messages/{message_id}")
|
|
except RuntimeError as exc:
|
|
print(f"Could not delete old media catalog message {message_id}: {exc}", file=sys.stderr)
|
|
|
|
|
|
def discord_bot_identity(token: str) -> dict[str, Any]:
|
|
return discord_request("GET", token, "/users/@me")
|
|
|