Build reusable bot framework
Some checks failed
CI / test (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled

This commit is contained in:
Chelsea Lee
2026-07-19 21:53:24 -05:00
parent 7ecc1107b2
commit fbdf33e894
66 changed files with 8428 additions and 0 deletions

1
bot/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Messaging-platform adapters."""

99
bot/api_client.py Normal file
View File

@@ -0,0 +1,99 @@
"""Async API client used by the Discord adapter."""
import os
import httpx
class ApiClient:
def __init__(self, discord_id, display_name):
self.discord_id = str(discord_id)
self.display_name = display_name
self.api_url = os.getenv("API_URL", "http://127.0.0.1:5000").rstrip("/")
self.service_key = os.getenv("BOT_API_KEY", "")
self.token = None
self.user_uuid = None
self.timezone = os.getenv("DEFAULT_TIMEZONE", "UTC")
self.http = httpx.AsyncClient(timeout=10.0)
async def authenticate(self):
headers = {"Authorization": f"Bearer {self.service_key}"}
try:
response = await self.http.post(
f"{self.api_url}/api/auth/discord/session",
headers=headers,
json={
"discord_id": self.discord_id,
"display_name": self.display_name,
},
)
except httpx.HTTPError:
return {"error": "API unavailable"}, 503
data = _response_json(response)
if response.status_code == 200:
self.token = data.get("token")
self.user_uuid = data.get("user_uuid")
self.timezone = data.get("timezone") or self.timezone
return data, response.status_code
async def request(self, method, endpoint, data=None, params=None):
if not self.token:
_, status = await self.authenticate()
if status != 200:
return {"error": "authentication failed"}, status
try:
response = await self._request(
method,
endpoint,
self.token,
data=data,
params=params,
)
except httpx.HTTPError:
return {"error": "API unavailable"}, 503
if response.status_code == 401:
authResult, status = await self.authenticate()
if status != 200:
return authResult, status
try:
response = await self._request(
method,
endpoint,
self.token,
data=data,
params=params,
)
except httpx.HTTPError:
return {"error": "API unavailable"}, 503
return _response_json(response), response.status_code
async def service_request(self, method, endpoint, data=None):
try:
response = await self._request(
method, endpoint, self.service_key, data=data
)
except httpx.HTTPError:
return {"error": "API unavailable"}, 503
return _response_json(response), response.status_code
async def _request(self, method, endpoint, token, data=None, params=None):
requestOptions = {
"headers": {"Authorization": f"Bearer {token}"},
"params": params,
}
if data is not None:
requestOptions["json"] = data
return await self.http.request(
method.upper(), f"{self.api_url}{endpoint}", **requestOptions
)
async def close(self):
await self.http.aclose()
def _response_json(response):
try:
return response.json()
except ValueError:
return {}

200
bot/bot.py Normal file
View File

@@ -0,0 +1,200 @@
"""
bot.py - Discord DM adapter for the reusable framework
Discord authenticates to the API with a service key and exchanges each
stable Discord identity for a short-lived user session. Feature modules see
only CommandContext rather than discord.py internals.
"""
import asyncio
import logging
import os
from pathlib import Path
import socket
import uuid
import discord
from discord.ext import tasks
from dotenv import load_dotenv
import ai.parser as ai_parser
from bot.api_client import ApiClient
from bot.context import CommandContext
from core.registry import discover_modules
load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False)
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
logger = logging.getLogger(__name__)
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
OUTBOX_POLL_INTERVAL = float(os.getenv("OUTBOX_POLL_INTERVAL", 5))
OUTBOX_BATCH_SIZE = int(os.getenv("OUTBOX_BATCH_SIZE", 20))
module_registry = discover_modules()
api_clients = {}
message_history = {}
user_locks = {}
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
service_client = ApiClient("service", "Discord bot")
OUTBOX_WORKER_ID = (
f"discord:{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex}"
)
def getApiClient(message):
discord_id = message.author.id
if discord_id not in api_clients:
display_name = getattr(message.author, "display_name", None) or str(
message.author
)
api_clients[discord_id] = ApiClient(discord_id, display_name)
return api_clients[discord_id]
async def sendHelpMessage(message):
lines = module_registry.help_lines()
content = "\n".join(lines) if lines else "- No modules registered"
await message.channel.send(
f"**Available commands:**\n{content}\n\nJust talk naturally and I'll help."
)
async def authenticateMessage(message):
api_client = getApiClient(message)
if api_client.token:
return api_client
result, status = await api_client.authenticate()
if status == 200:
return api_client
if status == 403:
await message.channel.send("This bot is not enabled for your Discord account.")
elif status == 503:
await message.channel.send("The bot is still starting. Please try again shortly.")
else:
detail = result.get("error", "authentication failed")
logger.warning("Discord authentication failed: %s", detail)
await message.channel.send("I couldn't start your session. Please try again.")
return None
async def routeCommand(message, api_client):
normalized = message.content.strip().lower()
if normalized in {"help", "?", "what can i say"}:
await sendHelpMessage(message)
return
discord_id = message.author.id
async with message.channel.typing():
history = message_history.get(discord_id, [])
parsed = await ai_parser.parse_command_async(
message.content,
module_registry,
history=history,
timezone_name=api_client.timezone,
)
message_history.setdefault(discord_id, []).append((message.content, parsed))
message_history[discord_id] = message_history[discord_id][-5:]
if parsed.get("needs_clarification"):
await message.channel.send(parsed["needs_clarification"])
return
if parsed.get("error"):
await message.channel.send(f"I had trouble understanding that: {parsed['error']}")
return
interaction_type = parsed.get("interaction_type")
command = module_registry.get_command(interaction_type)
if not command:
await message.channel.send(f"Unknown command type: {interaction_type}")
return
context = CommandContext(message, api_client)
try:
await command["handler"](context, parsed)
except Exception:
logger.exception("Command handler failed: %s", interaction_type)
await message.channel.send("That command failed unexpectedly. Please try again.")
@client.event
async def on_ready():
logger.info("Bot logged in as %s", client.user)
if not outboxLoop.is_running():
outboxLoop.start()
@client.event
async def on_message(message):
if message.author == client.user:
return
if not isinstance(message.channel, discord.DMChannel):
return
user_lock = user_locks.setdefault(message.author.id, asyncio.Lock())
async with user_lock:
api_client = await authenticateMessage(message)
if api_client:
await routeCommand(message, api_client)
@tasks.loop(seconds=OUTBOX_POLL_INTERVAL)
async def outboxLoop():
result, status = await service_client.service_request(
"post",
"/api/internal/outbox/claim",
{
"channel": "discord_dm",
"worker_id": OUTBOX_WORKER_ID,
"limit": OUTBOX_BATCH_SIZE,
},
)
if status != 200:
logger.warning("Outbox claim failed with status %s", status)
return
for outbound in result.get("messages", []):
await deliverOutboundMessage(outbound)
async def deliverOutboundMessage(outbound):
message_id = outbound["id"]
try:
user = await client.fetch_user(int(outbound["provider_user_id"]))
sent = await user.send(outbound["content"])
payload = {
"status": "sent",
"external_message_id": str(sent.id),
"worker_id": outbound["worker_id"],
}
except Exception as error:
logger.warning("Discord delivery failed for %s: %s", message_id, error)
payload = {
"status": "retry",
"error": str(error)[:500],
"worker_id": outbound["worker_id"],
}
_, status = await service_client.service_request(
"post", f"/api/internal/outbox/{message_id}/result", payload
)
if status != 200:
logger.warning("Outbox result failed for %s with status %s", message_id, status)
@outboxLoop.before_loop
async def beforeOutboxLoop():
await client.wait_until_ready()
if __name__ == "__main__":
if not DISCORD_BOT_TOKEN:
raise RuntimeError("DISCORD_BOT_TOKEN is required")
if not os.getenv("BOT_API_KEY"):
raise RuntimeError("BOT_API_KEY is required")
client.run(DISCORD_BOT_TOKEN)

13
bot/context.py Normal file
View File

@@ -0,0 +1,13 @@
"""Platform-neutral command context exposed to feature handlers."""
class CommandContext:
def __init__(self, message, api_client):
self._message = message
self.api = api_client
self.user_uuid = api_client.user_uuid
self.discord_user_id = str(message.author.id)
self.timezone = api_client.timezone
async def reply(self, content):
return await self._message.channel.send(content)