import json import os import urllib.error import urllib.parse import urllib.request from AppConfig import readDiscordLog, writeDiscordLog discordApiBase = "https://discord.com/api/v10" discordWebhookUrl = os.getenv("DISCORD_WEBHOOK_URL") class DiscordGatewayError(Exception): pass class DiscordGateway: """Handles Discord IO while staying easy to stub.""" dmChannelCache = {} @staticmethod def getMessages(userId, howMany, oldestFirst=False): if DiscordGateway.canUseApi() and userId: try: channelId = DiscordGateway.ensureDmChannel(userId) apiMessages = DiscordGateway.fetchChannelMessages(channelId, howMany) if oldestFirst: apiMessages = list(reversed(apiMessages)) return [DiscordGateway.describeApiMessage(message) for message in apiMessages] except DiscordGatewayError as error: print(f"[discord] API getMessages failed: {error}") # fallback to local log records = readDiscordLog() if userId: records = [record for record in records if record.get("userId") == userId] if not oldestFirst: records = list(reversed(records)) limited = records[:howMany] return [DiscordGateway.describeLogRecord(record) for record in limited] @staticmethod def sendMessage(userId, content): if discordWebhookUrl: DiscordGateway.postViaWebhook(content) DiscordGateway.persistLog(userId or "webhook", content) print("[discord] Sent message via webhook") return if DiscordGateway.canUseApi(): try: channelId = DiscordGateway.ensureDmChannel(userId) DiscordGateway.postChannelMessage(channelId, content) DiscordGateway.persistLog(userId, content) print(f"[discord] Sent DM via API to {userId}") return except DiscordGatewayError as error: print(f"[discord] API sendMessage failed: {error}") DiscordGateway.persistLog(userId, content) print(f"[discord] (log) DM to {userId}: {content}") @staticmethod def canUseApi(): return bool(os.getenv("DISCORD_BOT_TOKEN")) @staticmethod def ensureDmChannel(userId): if not userId: raise DiscordGatewayError("User ID is required for DM channel") cacheKey = str(userId) if cacheKey in DiscordGateway.dmChannelCache: return DiscordGateway.dmChannelCache[cacheKey] payload = json.dumps({"recipient_id": cacheKey}).encode("utf-8") data = DiscordGateway.apiRequest("POST", "/users/@me/channels", payload) channelId = data.get("id") if not channelId: raise DiscordGatewayError("Discord API did not return a channel id") DiscordGateway.dmChannelCache[cacheKey] = channelId return channelId @staticmethod def fetchChannelMessages(channelId, limit): params = urllib.parse.urlencode({"limit": limit}) path = f"/channels/{channelId}/messages?{params}" return DiscordGateway.apiRequest("GET", path) @staticmethod def postChannelMessage(channelId, content): payload = json.dumps({"content": content}).encode("utf-8") DiscordGateway.apiRequest("POST", f"/channels/{channelId}/messages", payload) @staticmethod def apiRequest(method, path, payload=None): token = os.getenv("DISCORD_BOT_TOKEN") if not token: raise DiscordGatewayError("DISCORD_BOT_TOKEN is not set") url = f"{discordApiBase}{path}" headers = { "Authorization": f"Bot {token}", } if payload is not None: headers["Content-Type"] = "application/json" request = urllib.request.Request(url, data=payload, method=method, headers=headers) try: with urllib.request.urlopen(request, timeout=20) as response: body = response.read().decode("utf-8") if not body: return {} return json.loads(body) except urllib.error.HTTPError as error: responseBody = error.read().decode("utf-8") if hasattr(error, "read") else "" raise DiscordGatewayError(f"HTTP {error.code}: {responseBody or str(error)}") except urllib.error.URLError as error: raise DiscordGatewayError(str(error)) @staticmethod def describeApiMessage(message): author = message.get("author") or {} authorName = author.get("username") or author.get("id") or "unknown" content = message.get("content") or "" return f"{authorName}: {content}" @staticmethod def describeLogRecord(record): userPart = record.get("userId") or "unknown" contentPart = record.get("content") or "" return f"{userPart}: {contentPart}" @staticmethod def persistLog(userId, content): entry = { "userId": userId, "content": content, } records = readDiscordLog() records.append(entry) writeDiscordLog(records) @staticmethod def postViaWebhook(content): payload = json.dumps({"content": content}).encode("utf-8") request = urllib.request.Request( discordWebhookUrl, data=payload, method="POST", headers={"Content-Type": "application/json"}, ) try: with urllib.request.urlopen(request, timeout=20): return except urllib.error.HTTPError as error: body = error.read().decode("utf-8") if hasattr(error, "read") else "" raise DiscordGatewayError(f"Webhook HTTP {error.code}: {body or str(error)}") except urllib.error.URLError as error: raise DiscordGatewayError(f"Webhook error: {error}")