""" 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)