chore: initial import
This commit is contained in:
155
DiscordGateway.py
Normal file
155
DiscordGateway.py
Normal 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}")
|
||||
Reference in New Issue
Block a user