chore: initial import

This commit is contained in:
chelsea
2025-11-11 23:11:59 -06:00
parent 7598942bc5
commit c15fe83651
28 changed files with 3755 additions and 4 deletions

155
DiscordGateway.py Normal file
View File

@@ -0,0 +1,155 @@
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}")