""" bot.py - Discord bot client with session management and command routing Features: - Login flow with username/password - Session management with JWT tokens - AI-powered command parsing via registry - Background task loop for polling """ import discord from discord.ext import tasks import os import sys import json import base64 import requests import bcrypt import pickle from bot.command_registry import get_handler, list_registered import ai.parser as ai_parser import bot.commands.routines # noqa: F401 - registers handler import bot.commands.medications # noqa: F401 - registers handler import bot.commands.knowledge # noqa: F401 - registers handler DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") API_URL = os.getenv("API_URL", "http://app:5000") user_sessions = {} login_state = {} message_history = {} user_cache = {} CACHE_FILE = "/app/user_cache.pkl" intents = discord.Intents.default() intents.message_content = True client = discord.Client(intents=intents) def decodeJwtPayload(token): payload = token.split(".")[1] payload += "=" * (4 - len(payload) % 4) return json.loads(base64.urlsafe_b64decode(payload)) def apiRequest(method, endpoint, token=None, data=None): url = f"{API_URL}{endpoint}" headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" try: resp = getattr(requests, method)(url, headers=headers, json=data, timeout=10) try: return resp.json(), resp.status_code except ValueError: return {}, resp.status_code except requests.RequestException: return {"error": "API unavailable"}, 503 def loadCache(): try: if os.path.exists(CACHE_FILE): with open(CACHE_FILE, "rb") as f: global user_cache user_cache = pickle.load(f) print(f"Loaded cache for {len(user_cache)} users") except Exception as e: print(f"Error loading cache: {e}") def saveCache(): try: with open(CACHE_FILE, "wb") as f: pickle.dump(user_cache, f) except Exception as e: print(f"Error saving cache: {e}") def hashPassword(password): return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") def verifyPassword(password, hashed): return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8")) def getCachedUser(discord_id): return user_cache.get(discord_id) def setCachedUser(discord_id, user_data): user_cache[discord_id] = user_data saveCache() def negotiateToken(discord_id, username, password): cached = getCachedUser(discord_id) if ( cached and cached.get("username") == username and verifyPassword(password, cached.get("hashed_password")) ): result, status = apiRequest( "post", "/api/login", data={"username": username, "password": password} ) if status == 200 and "token" in result: token = result["token"] payload = decodeJwtPayload(token) user_uuid = payload["sub"] setCachedUser( discord_id, { "hashed_password": cached["hashed_password"], "user_uuid": user_uuid, "username": username, }, ) return token, user_uuid return None, None result, status = apiRequest( "post", "/api/login", data={"username": username, "password": password} ) if status == 200 and "token" in result: token = result["token"] payload = decodeJwtPayload(token) user_uuid = payload["sub"] setCachedUser( discord_id, { "hashed_password": hashPassword(password), "user_uuid": user_uuid, "username": username, }, ) return token, user_uuid return None, None async def handleAuthFailure(message): discord_id = message.author.id user_sessions.pop(discord_id, None) await message.channel.send( "Your session has expired. Send any message to log in again." ) async def handleLoginStep(message): discord_id = message.author.id state = login_state[discord_id] if state["step"] == "username": state["username"] = message.content.strip() state["step"] = "password" await message.channel.send("Password?") elif state["step"] == "password": username = state["username"] password = message.content.strip() del login_state[discord_id] token, user_uuid = negotiateToken(discord_id, username, password) if token and user_uuid: user_sessions[discord_id] = { "token": token, "user_uuid": user_uuid, "username": username, } registered = ", ".join(list_registered()) or "none" await message.channel.send( f"Welcome back **{username}**!\n\n" f"Registered modules: {registered}\n\n" f"Send 'help' for available commands." ) else: await message.channel.send( "Invalid credentials. Send any message to try again." ) async def sendHelpMessage(message): help_msg = """**🤖 Synculous Bot - Natural Language Commands** Just talk to me naturally! Here are some examples: **💊 Medications:** • "add lsd 50 mcg every tuesday at 4:20pm" • "take my wellbutrin" • "what meds do i have today?" • "show my refills" • "snooze my reminder for 30 minutes" • "check adherence" **📋 Routines:** • "create morning routine with brush teeth, shower, eat" • "start my morning routine" • "done" (complete current step) • "skip" (skip current step) • "pause/resume" (pause or continue) • "what steps are in my routine?" • "schedule workout for monday wednesday friday at 7am" • "show my stats" **💡 Tips:** • I understand natural language, typos, and slang • If I'm unsure, I'll ask for clarification • For important actions, I'll ask you to confirm with "yes" or "no" • When you're in a routine, shortcuts like "done", "skip", "pause" work automatically""" await message.channel.send(help_msg) async def checkActiveSession(session): """Check if user has an active routine session and return details.""" token = session.get("token") if not token: return None resp, status = apiRequest("get", "/api/sessions/active", token) if status == 200 and "session" in resp: return resp return None async def handleConfirmation(message, session): """Handle yes/no confirmation responses. Returns True if handled.""" discord_id = message.author.id user_input = message.content.lower().strip() if "pending_confirmations" not in session: return False # Check for any pending confirmations pending = session["pending_confirmations"] if not pending: return False # Get the most recent pending confirmation confirmation_id = list(pending.keys())[-1] confirmation_data = pending[confirmation_id] if user_input in ("yes", "y", "yeah", "sure", "ok", "confirm"): # Execute the confirmed action del pending[confirmation_id] interaction_type = confirmation_data.get("interaction_type") handler = get_handler(interaction_type) if handler: # Create a fake parsed object for the handler fake_parsed = confirmation_data.copy() fake_parsed["needs_confirmation"] = False await handler(message, session, fake_parsed) return True elif user_input in ("no", "n", "nah", "cancel", "abort"): del pending[confirmation_id] await message.channel.send("❌ Cancelled.") return True return False async def handleActiveSessionShortcuts(message, session, active_session): """Handle shortcuts like 'done', 'skip', 'next' when in active session.""" user_input = message.content.lower().strip() # Map common shortcuts to actions shortcuts = { "done": ("routine", "complete"), "finished": ("routine", "complete"), "complete": ("routine", "complete"), "next": ("routine", "complete"), "skip": ("routine", "skip"), "pass": ("routine", "skip"), "pause": ("routine", "pause"), "hold": ("routine", "pause"), "resume": ("routine", "resume"), "continue": ("routine", "resume"), "stop": ("routine", "cancel"), "quit": ("routine", "cancel"), "abort": ("routine", "abort"), } if user_input in shortcuts: interaction_type, action = shortcuts[user_input] handler = get_handler(interaction_type) if handler: fake_parsed = {"action": action} await handler(message, session, fake_parsed) return True return False async def routeCommand(message): discord_id = message.author.id session = user_sessions[discord_id] user_input = message.content.lower() if "help" in user_input or "what can i say" in user_input: await sendHelpMessage(message) return # Check for active session first active_session = await checkActiveSession(session) # Handle confirmation responses confirmation_handled = await handleConfirmation(message, session) if confirmation_handled: return # Handle shortcuts when in active session if active_session: shortcut_handled = await handleActiveSessionShortcuts( message, session, active_session ) if shortcut_handled: return async with message.channel.typing(): history = message_history.get(discord_id, []) # Add context about active session to help AI understand context = "" if active_session: session_data = active_session.get("session", {}) routine_name = session_data.get("routine_name", "a routine") current_step = session_data.get("current_step_index", 0) + 1 total_steps = active_session.get("total_steps", 0) context = f"\n[Context: User is currently in active session for '{routine_name}', on step {current_step} of {total_steps}. They can say 'done', 'skip', 'pause', 'resume', or 'stop'.]" parsed = ai_parser.parse( message.content + context, "command_parser", history=history ) if discord_id not in message_history: message_history[discord_id] = [] message_history[discord_id].append((message.content, parsed)) message_history[discord_id] = message_history[discord_id][-5:] if "needs_clarification" in parsed: await message.channel.send( f"I'm not quite sure what you mean. {parsed['needs_clarification']}" ) return if "error" in parsed: await message.channel.send( f"I had trouble understanding that: {parsed['error']}" ) return interaction_type = parsed.get("interaction_type") handler = get_handler(interaction_type) if handler: await handler(message, session, parsed) else: registered = ", ".join(list_registered()) or "none" await message.channel.send( f"Unknown command type '{interaction_type}'. Registered modules: {registered}" ) @client.event async def on_ready(): print(f"Bot logged in as {client.user}") loadCache() backgroundLoop.start() @client.event async def on_message(message): if message.author == client.user: return if not isinstance(message.channel, discord.DMChannel): return discord_id = message.author.id if discord_id in login_state: await handleLoginStep(message) return if discord_id not in user_sessions: login_state[discord_id] = {"step": "username"} await message.channel.send("Welcome! Send your username to log in.") return await routeCommand(message) @tasks.loop(seconds=60) async def backgroundLoop(): """Override this in your domain module or extend as needed.""" pass @backgroundLoop.before_loop async def beforeBackgroundLoop(): await client.wait_until_ready() if __name__ == "__main__": client.run(DISCORD_BOT_TOKEN)