commit 25d05e0e86c664ed6a9e680dd78e5140b4a28b04 Author: chelsea Date: Thu Feb 12 22:11:52 2026 -0600 first commit diff --git a/.env b/.env new file mode 100644 index 0000000..99139f8 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +DB_PASS=y8Khu7pJQZq6ywFDIJiqpx4zYmclHGHw diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..ded32cc --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,2342 @@ +====== LLM Bot Framework - Complete Technical Documentation ====== + +===== Introduction ===== + +The LLM Bot Framework is a template for building Discord bots powered by Large Language Models (LLMs) with natural language command parsing. It provides a complete architecture for creating domain-specific bots that can understand and execute user commands expressed in natural language. + +The framework demonstrates a **modular, layered architecture** that separates concerns cleanly: + + * **AI Layer** - Natural language parsing via LLM + * **Bot Layer** - Discord client with session management + * **API Layer** - RESTful endpoints with JWT authentication + * **Core Layer** - Database, authentication, notifications + * **Scheduler Layer** - Background task processing + +===== Architecture Overview ===== + +==== The Big Picture ==== + +At its core, the framework implements a **request-response flow** that transforms natural language into structured actions: + + +flowchart TD + A["User Message (Discord DM)"] --> B["bot/bot.py"] + B --> C["ai/parser.py (LLM)"] + C --> D["Structured JSON"] + D --> E["command_registry"] + E --> F["Domain Handler"] + F --> G["API Request (HTTP)"] + G --> H["api/main.py (Flask)"] + H --> I["core/ modules"] + I --> J[("PostgreSQL")] + J -.-> I + I -.-> H + H -.-> G + G -.-> F + F -.-> B + B -.-> A + + +==== Global Architecture ==== + + +flowchart TB + subgraph external ["External Services"] + DISCORD["Discord API"] + OPENROUTER["OpenRouter API"] + end + + subgraph docker ["Docker Compose"] + subgraph bot_svc ["bot service"] + BOT["bot/bot.py"] + REG["command_registry.py"] + CMDS["commands/example.py"] + end + + subgraph app_svc ["app service (port 8080)"] + FLASK["api/main.py (Flask)"] + ROUTES["api/routes/example.py"] + end + + subgraph core_svc ["core/"] + AUTH["auth.py"] + USERS["users.py"] + NOTIF["notifications.py"] + PG["postgres.py"] + end + + subgraph ai_svc ["ai/"] + PARSER["parser.py"] + CONFIG["ai_config.json"] + end + + subgraph sched_svc ["scheduler service"] + DAEMON["daemon.py"] + end + + DB[("PostgreSQL")] + end + + DISCORD <--> BOT + BOT --> PARSER + PARSER --> OPENROUTER + PARSER --> CONFIG + BOT --> REG + REG --> CMDS + CMDS --> PARSER + BOT -- "HTTP" --> FLASK + FLASK --> ROUTES + FLASK --> AUTH + FLASK --> USERS + ROUTES --> AUTH + ROUTES --> PG + AUTH --> USERS + AUTH --> PG + USERS --> PG + NOTIF --> PG + PG --> DB + DAEMON --> PG + + +==== Docker Service Orchestration ==== + + +flowchart LR + DB[("db\nPostgreSQL:16\nport 5432")] -- "healthcheck:\npg_isready" --> DB + DB -- "service_healthy" --> APP["app\nFlask API\nport 8080:5000"] + DB -- "service_healthy" --> SCHED["scheduler\ndaemon.py"] + APP -- "service_started" --> BOT["bot\nbot.bot"] + ENV[".env\n(DB_PASS)"] -.-> DB + CENV["config/.env\n(all vars)"] -.-> APP + CENV -.-> BOT + CENV -.-> SCHED + + +===== Core Layer ===== + +==== core/postgres.py - Generic PostgreSQL CRUD ==== + +This module provides a **database abstraction layer** that eliminates the need to write raw SQL for common operations. It uses parameterized queries throughout to prevent SQL injection. + +=== Configuration === + +Connection settings are pulled from environment variables: + +^ Variable ^ Default ^ Description ^ +| DB_HOST | localhost | PostgreSQL server hostname | +| DB_PORT | 5432 | PostgreSQL server port | +| DB_NAME | app | Database name | +| DB_USER | app | Database user | +| DB_PASS | (empty) | Database password | + +=== Internal Functions === + +**_get_config()** - Returns a dictionary of connection parameters + + +def _get_config(): + return { + "host": os.environ.get("DB_HOST", "localhost"), + "port": int(os.environ.get("DB_PORT", 5432)), + "dbname": os.environ.get("DB_NAME", "app"), + "user": os.environ.get("DB_USER", "app"), + "password": os.environ.get("DB_PASS", ""), + } + + +This function centralizes database configuration, making it easy to test with different databases or override settings. + +**_safe_id(name)** - Validates and escapes SQL identifiers + + +def _safe_id(name): + if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name): + raise ValueError(f"Invalid SQL identifier: {name}") + return f'"{name}"' + + +Critical for security. This function: + * Validates that the identifier contains only alphanumeric characters and underscores + * Must start with a letter or underscore + * Wraps the identifier in double quotes for PostgreSQL + +This prevents SQL injection through table or column names. Without this, a malicious input like ''users; DROP TABLE users;--'' could execute destructive commands. + +**_build_where(where, prefix="")** - Constructs WHERE clauses from dictionaries + + +def _build_where(where, prefix=""): + clauses = [] + params = {} + for i, (col, val) in enumerate(where.items()): + param_name = f"{prefix}{col}_{i}" + safe_col = _safe_id(col) + # ... handles various conditions + + +This function transforms Python dictionaries into SQL WHERE clauses: + +**Simple equality:** + +{"username": "john"} +# → WHERE "username" = %(username_0)s + + +**Comparison operators:** + +{"age": (">", 18)} +# → WHERE "age" > %(age_0)s + + +**Supported operators:** =, !=, <, >, <=, >=, LIKE, ILIKE, IN, IS, IS NOT + +**IN clause:** + +{"status": ("IN", ["active", "pending"])} +# → WHERE "status" IN (%(status_0_0)s, %(status_0_1)s) + + +**NULL checks:** + +{"deleted_at": None} +# → WHERE "deleted_at" IS NULL + +{"deleted_at": ("IS NOT", None)} +# → WHERE "deleted_at" IS NOT NULL + + +The ''prefix'' parameter prevents parameter name collisions when the same column appears in multiple parts of a query (e.g., in both SET and WHERE clauses). + +=== Connection Management === + + +flowchart TD + CRUD["CRUD function\n(insert/select/update/delete)"] --> GC["get_cursor(dict_cursor)"] + GC --> GCONN["get_connection()"] + GCONN --> CONNECT["psycopg2.connect(**_get_config())"] + CONNECT --> YIELD_CONN["yield conn"] + YIELD_CONN --> CURSOR["conn.cursor(RealDictCursor)"] + CURSOR --> YIELD_CUR["yield cursor"] + YIELD_CUR --> EXEC["cur.execute(query, params)"] + EXEC --> SUCCESS{"Success?"} + SUCCESS -- "Yes" --> COMMIT["conn.commit()"] + SUCCESS -- "Exception" --> ROLLBACK["conn.rollback()"] + COMMIT --> CLOSE["conn.close()"] + ROLLBACK --> CLOSE + + +**get_connection()** - Context manager for database connections + + +@contextmanager +def get_connection(): + conn = psycopg2.connect(**_get_config()) + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +This context manager ensures: + * **Automatic connection opening** when entering the context + * **Automatic commit** on successful completion + * **Automatic rollback** on any exception + * **Guaranteed cleanup** - connection always closes + +**get_cursor(dict_cursor=True)** - Context manager for cursors + + +@contextmanager +def get_cursor(dict_cursor=True): + with get_connection() as conn: + factory = psycopg2.extras.RealDictCursor if dict_cursor else None + cur = conn.cursor(cursor_factory=factory) + try: + yield cur + finally: + cur.close() + + +By default, returns a ''RealDictCursor'' which returns rows as dictionaries instead of tuples, making results easier to work with: + + +# With dict cursor: +{"id": "abc123", "username": "john"} + +# Without dict cursor: +("abc123", "john") + + +=== CRUD Operations === + +**insert(table, data)** - Insert a single row + + +def insert(table, data): + columns = list(data.keys()) + placeholders = [f"%({col})s" for col in columns] + safe_cols = [_safe_id(c) for c in columns] + + query = f""" + INSERT INTO {_safe_id(table)} + ({", ".join(safe_cols)}) + VALUES ({", ".join(placeholders)}) + RETURNING * + """ + with get_cursor() as cur: + cur.execute(query, data) + return dict(cur.fetchone()) if cur.rowcount else None + + +Example usage: + +user = postgres.insert("users", { + "id": str(uuid.uuid4()), + "username": "john", + "password_hashed": hashed_pw +}) +# Returns the inserted row with all fields + + +The ''RETURNING *'' clause returns the complete inserted row, including any auto-generated fields like ''created_at''. + +**select(table, where=None, order_by=None, limit=None, offset=None)** - Query rows + + +def select(table, where=None, order_by=None, limit=None, offset=None): + query = f"SELECT * FROM {_safe_id(table)}" + params = {} + + if where: + clauses, params = _build_where(where) + query += f" WHERE {clauses}" + if order_by: + if isinstance(order_by, list): + order_by = ", ".join(order_by) + query += f" ORDER BY {order_by}" + if limit is not None: + query += f" LIMIT {int(limit)}" + if offset is not None: + query += f" OFFSET {int(offset)}" + + with get_cursor() as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + +Examples: + +# Get all users +all_users = postgres.select("users") + +# Get users with filtering +active_users = postgres.select("users", + where={"status": "active"}, + order_by="created_at DESC", + limit=10 +) + +# Complex filtering +adults = postgres.select("users", + where={"age": (">=", 18), "status": "active"} +) + + +**select_one(table, where)** - Query a single row + + +def select_one(table, where): + results = select(table, where=where, limit=1) + return results[0] if results else None + + +Convenience method that returns ''None'' if no row found, instead of an empty list: + +user = postgres.select_one("users", {"username": "john"}) +if user: + print(user["id"]) + + +**update(table, data, where)** - Update rows + + +def update(table, data, where): + set_columns = list(data.keys()) + set_clause = ", ".join(f"{_safe_id(col)} = %(set_{col})s" for col in set_columns) + params = {f"set_{col}": val for col, val in data.items()} + + where_clause, where_params = _build_where(where, prefix="where_") + params.update(where_params) + + query = f""" + UPDATE {_safe_id(table)} + SET {set_clause} + WHERE {where_clause} + RETURNING * + """ + with get_cursor() as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + +The ''prefix'' parameter prevents parameter name collisions. Example: + +updated = postgres.update( + "users", + {"status": "inactive"}, + {"id": user_uuid} +) + + +Returns all updated rows (useful when updating multiple rows). + +**delete(table, where)** - Delete rows + + +def delete(table, where): + where_clause, params = _build_where(where) + query = f""" + DELETE FROM {_safe_id(table)} + WHERE {where_clause} + RETURNING * + """ + with get_cursor() as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + +Returns deleted rows for confirmation/auditing: + +deleted = postgres.delete("users", {"id": user_uuid}) +print(f"Deleted {len(deleted)} user(s)") + + +=== Utility Functions === + +**count(table, where=None)** - Count rows + + +def count(table, where=None): + query = f"SELECT COUNT(*) as count FROM {_safe_id(table)}" + params = {} + if where: + clauses, params = _build_where(where) + query += f" WHERE {clauses}" + with get_cursor() as cur: + cur.execute(query, params) + return cur.fetchone()["count"] + + +**exists(table, where)** - Check if rows exist + + +def exists(table, where): + return count(table, where) > 0 + + +**upsert(table, data, conflict_columns)** - Insert or update + + +def upsert(table, data, conflict_columns): + columns = list(data.keys()) + placeholders = [f"%({col})s" for col in columns] + safe_cols = [_safe_id(c) for c in columns] + conflict_cols = [_safe_id(c) for c in conflict_columns] + + update_cols = [c for c in columns if c not in conflict_columns] + update_clause = ", ".join( + f"{_safe_id(c)} = EXCLUDED.{_safe_id(c)}" for c in update_cols + ) + + query = f""" + INSERT INTO {_safe_id(table)} + ({", ".join(safe_cols)}) + VALUES ({", ".join(placeholders)}) + ON CONFLICT ({", ".join(conflict_cols)}) + DO UPDATE SET {update_clause} + RETURNING * + """ + + +Example: Create or update user settings + +settings = postgres.upsert( + "notifications", + { + "user_uuid": user_uuid, + "discord_enabled": True, + "discord_webhook": "https://..." + }, + conflict_columns=["user_uuid"] +) + + +**insert_many(table, rows)** - Bulk insert + + +def insert_many(table, rows): + if not rows: + return 0 + columns = list(rows[0].keys()) + safe_cols = [_safe_id(c) for c in columns] + query = f""" + INSERT INTO {_safe_id(table)} + ({", ".join(safe_cols)}) + VALUES %s + """ + template = f"({', '.join(f'%({col})s' for col in columns)})" + with get_cursor() as cur: + psycopg2.extras.execute_values( + cur, query, rows, template=template, page_size=100 + ) + return cur.rowcount + + +Uses ''execute_values'' for efficient bulk inserts (up to 100 rows per batch): + +rows = [ + {"id": str(uuid.uuid4()), "name": "Task 1"}, + {"id": str(uuid.uuid4()), "name": "Task 2"}, + {"id": str(uuid.uuid4()), "name": "Task 3"}, +] +count = postgres.insert_many("tasks", rows) + + +**execute(query, params=None)** - Execute raw SQL + + +def execute(query, params=None): + with get_cursor() as cur: + cur.execute(query, params or {}) + if cur.description: + return [dict(row) for row in cur.fetchall()] + return cur.rowcount + + +For complex queries that don't fit the CRUD pattern: + +results = postgres.execute(""" + SELECT u.username, COUNT(t.id) as task_count + FROM users u + LEFT JOIN tasks t ON t.user_uuid = u.id + GROUP BY u.username + HAVING COUNT(t.id) > :min_count +""", {"min_count": 5}) + + +**table_exists(table)** - Check if table exists + +**get_columns(table)** - Get table schema information + +==== core/auth.py - JWT Authentication ==== + +This module handles **user authentication** using JWT (JSON Web Tokens) with bcrypt password hashing. + +=== Token Management === + + +flowchart LR + subgraph getLoginToken + A["username, password"] --> B["users.getUserUUID()"] + B --> C["getUserpasswordHash()"] + C --> D["bcrypt.checkpw()"] + D -- "match" --> E["jwt.encode(payload)"] + D -- "no match" --> F["return False"] + E --> G["return JWT token"] + end + + subgraph verifyLoginToken + H["token, username/userUUID"] --> I{"username\nprovided?"} + I -- "Yes" --> J["users.getUserUUID()"] + J --> K["jwt.decode()"] + I -- "No" --> K + K -- "valid" --> L{"sub == userUUID?"} + L -- "Yes" --> M["return True"] + L -- "No" --> N["return False"] + K -- "expired/invalid" --> N + end + + +**getLoginToken(username, password)** - Generate JWT on successful login + + +def getLoginToken(username, password): + userUUID = users.getUserUUID(username) + if userUUID: + formatted_pass = password.encode("utf-8") + users_hashed_pw = getUserpasswordHash(userUUID) + if bcrypt.checkpw(formatted_pass, users_hashed_pw): + payload = { + "sub": userUUID, + "name": users.getUserFirstName(userUUID), + "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1), + } + return jwt.encode(payload, os.getenv("JWT_SECRET"), algorithm="HS256") + return False + + +The JWT payload contains: + * **sub** (subject) - User's UUID, used for identification + * **name** - User's display name + * **exp** (expiration) - Token expires in 1 hour + +**verifyLoginToken(login_token, username=False, userUUID=False)** - Validate JWT + + +def verifyLoginToken(login_token, username=False, userUUID=False): + if username: + userUUID = users.getUserUUID(username) + + if userUUID: + try: + decoded_token = jwt.decode( + login_token, os.getenv("JWT_SECRET"), algorithms=["HS256"] + ) + if decoded_token.get("sub") == str(userUUID): + return True + return False + except (ExpiredSignatureError, InvalidTokenError): + return False + return False + + +Validates that: + 1. The token is properly signed + 2. The token hasn't expired + 3. The token belongs to the claimed user + +=== Password Management === + +**getUserpasswordHash(userUUID)** - Retrieve stored password hash + + +def getUserpasswordHash(userUUID): + user = postgres.select_one("users", {"id": userUUID}) + if user: + pw_hash = user.get("password_hashed") + if isinstance(pw_hash, memoryview): + return bytes(pw_hash) + return pw_hash + return None + + +Handles the case where PostgreSQL returns ''BYTEA'' as a ''memoryview'' object. + + +flowchart LR + A["unregisterUser(userUUID, password)"] --> B["getUserpasswordHash()"] + B --> C["postgres.select_one('users')"] + C --> D{"hash found?"} + D -- "No" --> E["return False"] + D -- "Yes" --> F["bcrypt.checkpw()"] + F -- "match" --> G["users.deleteUser()"] + F -- "no match" --> E + G --> H["postgres.delete('users')"] + + +**unregisterUser(userUUID, password)** - Delete user account with password confirmation + + +def unregisterUser(userUUID, password): + pw_hash = getUserpasswordHash(userUUID) + if not pw_hash: + return False + if bcrypt.checkpw(password.encode("utf-8"), pw_hash): + return users.deleteUser(userUUID) + return False + + +Requires password re-entry to prevent unauthorized account deletion. + +==== core/users.py - User Management ==== + +This module provides **CRUD operations for users** with validation and security considerations. + +=== Query Functions === + +**getUserUUID(username)** - Get UUID from username + +def getUserUUID(username): + userRecord = postgres.select_one("users", {"username": username}) + if userRecord: + return userRecord["id"] + return False + + +**getUserFirstName(userUUID)** - Get user's display name + +def getUserFirstName(userUUID): + userRecord = postgres.select_one("users", {"id": userUUID}) + if userRecord: + return userRecord.get("username") + return None + + +**isUsernameAvailable(username)** - Check username uniqueness + +def isUsernameAvailable(username): + return not postgres.exists("users", {"username": username}) + + +**doesUserUUIDExist(userUUID)** - Verify UUID exists + +def doesUserUUIDExist(userUUID): + return postgres.exists("users", {"id": userUUID}) + + +=== Mutation Functions === + + +flowchart TD + REG["registerUser(username, password)"] --> AVAIL["isUsernameAvailable()"] + AVAIL --> EXISTS["postgres.exists('users')"] + EXISTS -- "taken" --> RET_F["return False"] + EXISTS -- "available" --> HASH["bcrypt.hashpw(password, gensalt())"] + HASH --> CREATE["createUser(user_data)"] + CREATE --> VALIDATE["validateUser()"] + VALIDATE -- "invalid" --> RAISE["raise ValueError"] + VALIDATE -- "valid" --> INSERT["postgres.insert('users')"] + INSERT --> RET_T["return True"] + + UPD["updateUser(userUUID, data)"] --> LOOKUP["postgres.select_one('users')"] + LOOKUP -- "not found" --> RET_F2["return False"] + LOOKUP -- "found" --> FILTER["filter blocked fields\n(id, password_hashed, created_at)"] + FILTER --> UPD_DB["postgres.update('users')"] + + DEL["deleteUser(userUUID)"] --> LOOKUP2["postgres.select_one('users')"] + LOOKUP2 -- "not found" --> RET_F3["return False"] + LOOKUP2 -- "found" --> DEL_DB["postgres.delete('users')"] + + +**registerUser(username, password, data=None)** - Create new user + +def registerUser(username, password, data=None): + if isUsernameAvailable(username): + hashed_pass = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + user_data = { + "id": str(uuid.uuid4()), + "username": username, + "password_hashed": hashed_pass, + } + if data: + user_data.update(data) + createUser(user_data) + return True + return False + + +Uses ''bcrypt.gensalt()'' to generate a unique salt for each password. + +**updateUser(userUUID, data_dict)** - Update user fields + +def updateUser(userUUID, data_dict): + user = postgres.select_one("users", {"id": userUUID}) + if not user: + return False + blocked = {"id", "password_hashed", "created_at"} + allowed = set(user.keys()) - blocked + updates = {k: v for k, v in data_dict.items() if k in allowed} + if not updates: + return False + postgres.update("users", updates, {"id": userUUID}) + return True + + +**Blocked fields** prevent modification of: + * ''id'' - Primary key should never change + * ''password_hashed'' - Use ''changePassword()'' instead + * ''created_at'' - Audit field should be immutable + +**changePassword(userUUID, new_password)** - Securely update password + +def changePassword(userUUID, new_password): + user = postgres.select_one("users", {"id": userUUID}) + if not user: + return False + hashed = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()) + postgres.update("users", {"password_hashed": hashed}, {"id": userUUID}) + return True + + +**deleteUser(userUUID)** - Remove user record + +def deleteUser(userUUID): + user = postgres.select_one("users", {"id": userUUID}) + if not user: + return False + postgres.delete("users", {"id": userUUID}) + return True + + +=== Internal Functions === + +**createUser(data_dict)** - Internal user creation with validation + +def createUser(data_dict): + user_schema = { + "id": None, + "username": None, + "password_hashed": None, + "created_at": None, + } + for key in user_schema: + if key in data_dict: + user_schema[key] = data_dict[key] + + is_valid, errors = validateUser(user_schema) + if not is_valid: + raise ValueError(f"Invalid user data: {', '.join(errors)}") + + postgres.insert("users", user_schema) + + +**validateUser(user)** - Ensure required fields present + +def validateUser(user): + required = ["id", "username", "password_hashed"] + missing = [f for f in required if f not in user or user[f] is None] + if missing: + return False, missing + return True, [] + + +==== core/notifications.py - Multi-Channel Notifications ==== + +This module provides **notification routing** to multiple channels (Discord webhooks, ntfy). + + +flowchart TD + SEND["_sendToEnabledChannels(settings, message)"] + SEND --> CHK_D{"discord_enabled\nand webhook set?"} + CHK_D -- "Yes" --> DISC["discord.send(webhook_url, message)"] + CHK_D -- "No" --> CHK_N + DISC --> CHK_N{"ntfy_enabled\nand topic set?"} + CHK_N -- "Yes" --> NTFY["ntfy.send(topic, message)"] + CHK_N -- "No" --> RESULT + NTFY --> RESULT["return True if any succeeded"] + + DISC -- "POST webhook_url\n{content: message}" --> DISCORD_API["Discord Webhook\n(expects 204)"] + NTFY -- "POST ntfy.sh/topic\nmessage body" --> NTFY_API["ntfy.sh\n(expects 200)"] + + GET["getNotificationSettings(userUUID)"] --> DB_SEL["postgres.select_one('notifications')"] + SET["setNotificationSettings(userUUID, data)"] --> DB_CHK{"existing\nrecord?"} + DB_CHK -- "Yes" --> DB_UPD["postgres.update('notifications')"] + DB_CHK -- "No" --> DB_INS["postgres.insert('notifications')"] + + +=== Notification Channels === + +**discord.send(webhook_url, message)** - Send via Discord webhook + +class discord: + @staticmethod + def send(webhook_url, message): + try: + response = requests.post(webhook_url, json={"content": message}) + return response.status_code == 204 + except: + return False + + +Discord webhooks return ''204 No Content'' on success. + +**ntfy.send(topic, message)** - Send via ntfy.sh + +class ntfy: + @staticmethod + def send(topic, message): + try: + response = requests.post( + f"https://ntfy.sh/{topic}", data=message.encode("utf-8") + ) + return response.status_code == 200 + except: + return False + + +ntfy.sh is a free push notification service. Users subscribe to topics. + +=== Settings Management === + +**getNotificationSettings(userUUID)** - Retrieve user notification config + +def getNotificationSettings(userUUID): + settings = postgres.select_one("notifications", {"user_uuid": userUUID}) + if not settings: + return False + return settings + + +**setNotificationSettings(userUUID, data_dict)** - Update notification config + +def setNotificationSettings(userUUID, data_dict): + existing = postgres.select_one("notifications", {"user_uuid": userUUID}) + allowed = [ + "discord_webhook", + "discord_enabled", + "ntfy_topic", + "ntfy_enabled", + ] + updates = {k: v for k, v in data_dict.items() if k in allowed} + if not updates: + return False + if existing: + postgres.update("notifications", updates, {"user_uuid": userUUID}) + else: + updates["id"] = str(uuid.uuid4()) + updates["user_uuid"] = userUUID + postgres.insert("notifications", updates) + return True + + +Implements an **upsert pattern** - updates if exists, inserts if not. + +**_sendToEnabledChannels(notif_settings, message)** - Route to all enabled channels + +def _sendToEnabledChannels(notif_settings, message): + sent = False + + if notif_settings.get("discord_enabled") and notif_settings.get("discord_webhook"): + if discord.send(notif_settings["discord_webhook"], message): + sent = True + + if notif_settings.get("ntfy_enabled") and notif_settings.get("ntfy_topic"): + if ntfy.send(notif_settings["ntfy_topic"], message): + sent = True + + return sent + + +Returns ''True'' if **any** channel succeeded, allowing partial failures. + +===== AI Layer ===== + +==== ai/parser.py - LLM-Powered JSON Parser ==== + +This is the **heart of the natural language interface**. It transforms user messages into structured JSON using an LLM, with automatic retry and validation. + +=== Configuration Loading === + + +CONFIG_PATH = os.environ.get( + "AI_CONFIG_PATH", os.path.join(os.path.dirname(__file__), "ai_config.json") +) + +with open(CONFIG_PATH, "r") as f: + AI_CONFIG = json.load(f) + + +Configuration is loaded once at module import time. + +=== OpenAI Client Initialization === + + +client = OpenAI( + api_key=os.getenv("OPENROUTER_API_KEY"), + base_url=os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), +) + + +Uses OpenRouter for **model flexibility** - can use any OpenAI-compatible model including: + * OpenAI models (GPT-4, GPT-3.5) + * Anthropic models (Claude) + * Open-source models (Llama, Qwen, Mistral) + +=== Internal Functions === + +**_extract_json_from_text(text)** - Extract JSON from reasoning model output + + +def _extract_json_from_text(text): + match = re.search(r"```json\s*(\{.*?\})\s*```", text, re.DOTALL) + if match: + return match.group(1) + match = re.search(r"(\{[^{}]*\})", text, re.DOTALL) + if match: + return match.group(1) + return None + + +Some reasoning models (like Qwen with thinking) output reasoning before the JSON. This extracts: + 1. JSON inside markdown code blocks: ''%%```json {...} ```%%'' + 2. First JSON object in text: ''{...}'' + + +flowchart LR + CALL["_call_llm(system, user)"] --> API["OpenAI client\nchat.completions.create()"] + API --> MSG["response.choices[0].message"] + MSG --> CHK_C{"msg.content\nnon-empty?"} + CHK_C -- "Yes" --> RET_TEXT["return content"] + CHK_C -- "No" --> CHK_R{"msg.reasoning\nexists?"} + CHK_R -- "Yes" --> EXTRACT["_extract_json_from_text()"] + CHK_R -- "No" --> RET_NONE["return None"] + EXTRACT --> RET_JSON["return extracted JSON"] + API -- "Exception" --> LOG["print error"] --> RET_NONE + + +**_call_llm(system_prompt, user_prompt)** - Execute LLM request + + +def _call_llm(system_prompt, user_prompt): + try: + response = client.chat.completions.create( + model=AI_CONFIG["model"], + max_tokens=AI_CONFIG.get("max_tokens", 8192), + timeout=AI_CONFIG["validation"]["timeout_seconds"], + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + ) + msg = response.choices[0].message + text = msg.content.strip() if msg.content else "" + if text: + return text + reasoning = getattr(msg, "reasoning", None) + if reasoning: + extracted = _extract_json_from_text(reasoning) + if extracted: + return extracted + return None + except Exception as e: + print(f"LLM error: {type(e).__name__}: {e}", flush=True) + return None + + +Handles both: + * **Standard responses** - JSON in ''message.content'' + * **Reasoning responses** - JSON extracted from ''message.reasoning'' + +=== Main Parsing Function === + + +flowchart TD + START["parse(user_input, interaction_type)"] --> RETRY_CHK{"retry_count\n>= max_retries?"} + RETRY_CHK -- "Yes" --> ERR_MAX["return {error: 'Failed after N retries'}"] + RETRY_CHK -- "No" --> PROMPT["Build prompt from\nai_config.json template"] + PROMPT --> HISTORY["Inject last 3 conversation turns"] + HISTORY --> LLM["_call_llm(system, user_prompt)"] + LLM --> LLM_CHK{"Response\nreceived?"} + LLM_CHK -- "No" --> ERR_UNAVAIL["return {error: 'AI unavailable'}"] + LLM_CHK -- "Yes" --> JSON_PARSE["json.loads(response)"] + JSON_PARSE -- "JSONDecodeError" --> RETRY_JSON["parse(..., retry+1,\nerrors=['not valid JSON'])"] + JSON_PARSE -- "Success" --> VALIDATE{"Custom validator\nregistered?"} + VALIDATE -- "No" --> RETURN["return parsed JSON"] + VALIDATE -- "Yes" --> RUN_VAL["validator(parsed)"] + RUN_VAL --> VAL_CHK{"Validation\nerrors?"} + VAL_CHK -- "No" --> RETURN + VAL_CHK -- "Yes" --> RETRY_VAL["parse(..., retry+1,\nerrors=validation_errors)"] + + +**parse(user_input, interaction_type, retry_count=0, errors=None, history=None)** + + +def parse(user_input, interaction_type, retry_count=0, errors=None, history=None): + if retry_count >= AI_CONFIG["validation"]["max_retries"]: + return { + "error": f"Failed to parse after {retry_count} retries", + "user_input": user_input, + } + + +**Parameters:** + * ''user_input'' - Raw user message + * ''interaction_type'' - Key in config prompts (e.g., ''command_parser'') + * ''retry_count'' - Internal counter for automatic retry + * ''errors'' - Previous validation errors (for retry context) + * ''history'' - List of (message, parsed_result) tuples + +**Retry Logic:** + +try: + parsed = json.loads(response_text) +except json.JSONDecodeError: + return parse( + user_input, + interaction_type, + retry_count + 1, + ["Response was not valid JSON"], + history=history, + ) + + +When JSON parsing fails, the function **recursively calls itself** with incremented retry count, passing the error to give the LLM context to fix its output. + +**Validation Integration:** + +validator = AI_CONFIG["validation"].get("validators", {}).get(interaction_type) +if validator: + validation_errors = validator(parsed) + if validation_errors: + return parse( + user_input, + interaction_type, + retry_count + 1, + validation_errors, + history=history, + ) + + +Custom validators are called after successful JSON parsing. If validation fails, the parser retries with the validation errors as context. + +**Conversation History:** + +history_context = "No previous context" +if history and len(history) > 0: + history_lines = [] + for i, (msg, result) in enumerate(history[-3:]): + history_lines.append(f"{i + 1}. User: {msg}") + if isinstance(result, dict) and not result.get("error"): + history_lines.append(f" Parsed: {json.dumps(result)}") + else: + history_lines.append(f" Parsed: {result}") + history_context = "\n".join(history_lines) + + +The last 3 conversation turns are included for context, enabling: + * Pronoun resolution ("Add **it** to my list") + * Follow-up commands ("Change **the second one**") + * Clarification handling + +=== Validator Registration === + +**register_validator(interaction_type, validator_fn)** + +def register_validator(interaction_type, validator_fn): + if "validators" not in AI_CONFIG["validation"]: + AI_CONFIG["validation"]["validators"] = {} + AI_CONFIG["validation"]["validators"][interaction_type] = validator_fn + + +Domain modules register their validators: + +def validate_example_json(data): + errors = [] + if "action" not in data: + errors.append("Missing required field: action") + return errors + +ai_parser.register_validator("example", validate_example_json) + + +==== ai/ai_config.json - AI Configuration ==== + + +{ + "model": "qwen/qwen3-next-80b-a3b-thinking:nitro", + "max_tokens": 8192, + "prompts": { + "command_parser": { + "system": "...", + "user_template": "..." + } + }, + "validation": { + "max_retries": 3, + "timeout_seconds": 15, + "validators": {} + } +} + + +**Configuration Fields:** + +^ Field ^ Purpose ^ +| model | OpenRouter model identifier | +| max_tokens | Maximum response length | +| prompts | Prompt templates by interaction type | +| validation.max_retries | Retry attempts on failure | +| validation.timeout_seconds | LLM request timeout | +| validation.validators | Runtime-registered validators | + +**Prompt Structure:** + +Each prompt has: + * ''system'' - System prompt defining the AI's role + * ''user_template'' - Template with ''{user_input}'' and ''{history_context}'' placeholders + +===== Bot Layer ===== + +==== bot/bot.py - Discord Client ==== + +This is the **Discord bot client** that manages user sessions and routes commands. + +=== Global State === + + +user_sessions = {} # discord_id → {token, user_uuid, username} +login_state = {} # discord_id → {step, username} +message_history = {} # discord_id → [(msg, parsed), ...] +user_cache = {} # discord_id → {hashed_password, user_uuid, username} +CACHE_FILE = "/app/user_cache.pkl" + + +**user_sessions** - Active authenticated sessions + +**login_state** - Tracks multi-step login flow + +**message_history** - Last 5 messages per user for context + +**user_cache** - Persisted credentials for auto-login + + +flowchart TD + MSG["on_message(message)"] --> SELF{"Own\nmessage?"} + SELF -- "Yes" --> IGNORE["ignore"] + SELF -- "No" --> DM{"Is DM\nchannel?"} + DM -- "No" --> IGNORE + DM -- "Yes" --> LOGIN_CHK{"In\nlogin_state?"} + LOGIN_CHK -- "Yes" --> HANDLE_LOGIN["handleLoginStep()"] + LOGIN_CHK -- "No" --> SESSION_CHK{"Has\nsession?"} + SESSION_CHK -- "No" --> START_LOGIN["Start login flow\n(ask for username)"] + SESSION_CHK -- "Yes" --> ROUTE["routeCommand()"] + + ROUTE --> HELP_CHK{"'help' in\nmessage?"} + HELP_CHK -- "Yes" --> HELP["sendHelpMessage()"] + HELP_CHK -- "No" --> PARSE["ai_parser.parse()"] + PARSE --> CLARIFY{"needs_\nclarification?"} + CLARIFY -- "Yes" --> ASK["Ask user to clarify"] + CLARIFY -- "No" --> ERROR_CHK{"error in\nparsed?"} + ERROR_CHK -- "Yes" --> SHOW_ERR["Show error"] + ERROR_CHK -- "No" --> HANDLER["get_handler(interaction_type)"] + HANDLER -- "found" --> EXEC["handler(message, session, parsed)"] + HANDLER -- "not found" --> UNKNOWN["'Unknown command type'"] + + +=== Discord Client Setup === + + +intents = discord.Intents.default() +intents.message_content = True + +client = discord.Client(intents=intents) + + +''message_content'' intent is required to read message text. + +=== Utility Functions === + +**decodeJwtPayload(token)** - Decode JWT without verification + +def decodeJwtPayload(token): + payload = token.split(".")[1] + payload += "=" * (4 - len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload)) + + +Used to extract ''user_uuid'' from tokens. Note: This **does not verify** the signature; verification happens server-side. + +**apiRequest(method, endpoint, token=None, data=None)** - Make HTTP requests to API + +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 + + +Returns a tuple of (response_data, status_code) for easy handling. + +=== Cache Management === + +**loadCache()** - Load persisted user credentials + +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}") + + +**saveCache()** - Persist user credentials + +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}") + + +**Why cache credentials?** + * Users don't need to re-login every session + * Passwords are hashed locally for verification + * New tokens are fetched automatically + +**hashPassword() / verifyPassword()** - Local password handling + +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")) + + +=== Authentication Flow === + + +flowchart TD + START["negotiateToken(discord_id, username, password)"] --> CACHE{"Cached user\nfound?"} + CACHE -- "Yes" --> VERIFY["verifyPassword(password, cached.hashed_password)"] + VERIFY -- "Match" --> API_CACHED["POST /api/login"] + VERIFY -- "Mismatch" --> API_FRESH["POST /api/login"] + CACHE -- "No" --> API_FRESH + + API_CACHED -- "200 + token" --> DECODE_C["decodeJwtPayload(token)"] + DECODE_C --> UPDATE_C["Update cache\n(keep existing hash)"] + UPDATE_C --> RET_OK["return (token, user_uuid)"] + + API_FRESH -- "200 + token" --> DECODE_F["decodeJwtPayload(token)"] + DECODE_F --> UPDATE_F["Cache new credentials\n(hashPassword(password))"] + UPDATE_F --> RET_OK + + API_CACHED -- "failure" --> RET_FAIL["return (None, None)"] + API_FRESH -- "failure" --> RET_FAIL + + +**negotiateToken(discord_id, username, password)** - Get token with caching + +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 + + +**Flow:** + 1. Check if user has cached credentials + 2. If cached password matches, fetch new token + 3. If no cache or mismatch, authenticate normally + 4. Cache credentials for future sessions + +**handleAuthFailure(message)** - Handle expired sessions + +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." + ) + + +=== Login Flow === + +**handleLoginStep(message)** - Multi-step login process + +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." + ) + + +**State Machine:** + + +stateDiagram-v2 + [*] --> AskUsername: First message (no session) + AskUsername --> AskPassword: User sends username + AskPassword --> Authenticated: negotiateToken() succeeds + AskPassword --> [*]: Invalid credentials\n(send any message to retry) + Authenticated --> routeCommand: Subsequent messages + + +=== Command Routing === + +**routeCommand(message)** - Parse and route commands + +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 + + async with message.channel.typing(): + history = message_history.get(discord_id, []) + parsed = ai_parser.parse(message.content, "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}" + ) + + +**Flow:** + 1. Check for help request + 2. Show typing indicator while LLM processes + 3. Parse with AI, including conversation history + 4. Update message history (keep last 5) + 5. Handle clarification requests + 6. Handle parsing errors + 7. Route to appropriate handler + 8. Handle unknown interaction types + +=== Discord Event Handlers === + +**on_ready()** - Bot startup + +@client.event +async def on_ready(): + print(f"Bot logged in as {client.user}") + loadCache() + backgroundLoop.start() + + +**on_message(message)** - Message handler + +@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) + + +**Filters:** + * Ignore own messages + * Only respond to DMs (not server channels) + +=== Background Tasks === + +**backgroundLoop()** - Scheduled task execution + +@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() + + +Runs every 60 seconds. Override for domain-specific polling. + +==== bot/command_registry.py - Module Registration ==== + + +flowchart TD + subgraph "Module Registration (at import time)" + EX["commands/example.py"] -- "register_module('example', handle_example)" --> REG["COMMAND_MODULES dict"] + EX -- "register_validator('example', validate_fn)" --> VAL["AI_CONFIG validators dict"] + HABIT["commands/habits.py"] -- "register_module('habit', handle_habit)" --> REG + HABIT -- "register_validator('habit', validate_fn)" --> VAL + end + + subgraph "Runtime Dispatch" + PARSED["parsed JSON\n{interaction_type: 'example'}"] --> GET["get_handler('example')"] + GET --> REG + REG --> HANDLER["handle_example(message, session, parsed)"] + end + + +This module provides a **simple registry pattern** for command handlers. + + +COMMAND_MODULES = {} + +def register_module(interaction_type, handler): + COMMAND_MODULES[interaction_type] = handler + +def get_handler(interaction_type): + return COMMAND_MODULES.get(interaction_type) + +def list_registered(): + return list(COMMAND_MODULES.keys()) + + +**Why a registry?** + * Decouples command handling from bot logic + * Domain modules self-register + * Easy to add/remove modules without touching core code + +==== bot/commands/example.py - Example Command Module ==== + +This demonstrates the **pattern for creating domain modules**. + + +from bot.command_registry import register_module +import ai.parser as ai_parser + + +async def handle_example(message, session, parsed): + action = parsed.get("action", "unknown") + token = session["token"] + user_uuid = session["user_uuid"] + + if action == "check": + await message.channel.send( + f"Checking example items for {session['username']}..." + ) + elif action == "add": + item_name = parsed.get("item_name", "unnamed") + await message.channel.send(f"Adding example item: **{item_name}**") + else: + await message.channel.send(f"Unknown example action: {action}") + + +def validate_example_json(data): + errors = [] + + if not isinstance(data, dict): + return ["Response must be a JSON object"] + + if "error" in data: + return [] + + if "action" not in data: + errors.append("Missing required field: action") + + action = data.get("action") + + if action == "add" and "item_name" not in data: + errors.append("Missing required field for add: item_name") + + return errors + + +register_module("example", handle_example) +ai_parser.register_validator("example", validate_example_json) + + +**Pattern:** + 1. Define async handler function + 2. Define validation function + 3. Register both at module load time + +===== API Layer ===== + +==== api/main.py - Flask Application ==== + +This is the **REST API server** providing endpoints for authentication, user management, and domain operations. + +=== Route Registration === + + +ROUTE_MODULES = [] + +def register_routes(module): + """Register a routes module. Module should have a register(app) function.""" + ROUTE_MODULES.append(module) + + +Modules are registered before app startup: + +if __name__ == "__main__": + for module in ROUTE_MODULES: + if hasattr(module, "register"): + module.register(app) + app.run(host="0.0.0.0", port=5000) + + + +flowchart LR + subgraph "API Auth Pattern (all protected routes)" + REQ["Incoming Request"] --> HDR{"Authorization\nheader?"} + HDR -- "Missing/invalid" --> R401A["401 missing token"] + HDR -- "Bearer token" --> DECODE["_get_user_uuid(token)\nor extract from URL"] + DECODE --> VERIFY["auth.verifyLoginToken\n(token, userUUID)"] + VERIFY -- "True" --> HANDLER["Route handler logic"] + VERIFY -- "False" --> R401B["401 unauthorized"] + end + + +=== Authentication Endpoints === + +**POST /api/register** - Create new user + +@app.route("/api/register", methods=["POST"]) +def api_register(): + data = flask.request.get_json() + username = data.get("username") + password = data.get("password") + if not username or not password: + return flask.jsonify({"error": "username and password required"}), 400 + result = users.registerUser(username, password, data) + if result: + return flask.jsonify({"success": True}), 201 + else: + return flask.jsonify({"error": "username taken"}), 409 + + +**Response Codes:** + * ''201'' - User created successfully + * ''400'' - Missing required fields + * ''409'' - Username already exists + +**POST /api/login** - Authenticate user + +@app.route("/api/login", methods=["POST"]) +def api_login(): + data = flask.request.get_json() + username = data.get("username") + password = data.get("password") + if not username or not password: + return flask.jsonify({"error": "username and password required"}), 400 + token = auth.getLoginToken(username, password) + if token: + return flask.jsonify({"token": token}), 200 + else: + return flask.jsonify({"error": "invalid credentials"}), 401 + + +**Response Codes:** + * ''200'' - Returns JWT token + * ''400'' - Missing required fields + * ''401'' - Invalid credentials + +=== User Endpoints === + +**GET /api/getUserUUID/** - Get user's UUID + +@app.route("/api/getUserUUID/", methods=["GET"]) +def api_getUserUUID(username): + header = flask.request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return flask.jsonify({"error": "missing token"}), 401 + token = header[7:] + if auth.verifyLoginToken(token, username): + return flask.jsonify(users.getUserUUID(username)), 200 + else: + return flask.jsonify({"error": "unauthorized"}), 401 + + +**GET /api/user/** - Get user details + +@app.route("/api/user/", methods=["GET"]) +def api_getUser(userUUID): + # ... auth check ... + user = postgres.select_one("users", {"id": userUUID}) + if user: + user.pop("password_hashed", None) # Never return password hash + return flask.jsonify(user), 200 + else: + return flask.jsonify({"error": "user not found"}), 404 + + +**Security note:** ''password_hashed'' is explicitly removed before returning. + +**PUT /api/user/** - Update user + +@app.route("/api/user/", methods=["PUT"]) +def api_updateUser(userUUID): + # ... auth check ... + data = flask.request.get_json() + result = users.updateUser(userUUID, data) + if result: + return flask.jsonify({"success": True}), 200 + else: + return flask.jsonify({"error": "no valid fields to update"}), 400 + + +**DELETE /api/user/** - Delete user + +@app.route("/api/user/", methods=["DELETE"]) +def api_deleteUser(userUUID): + # ... auth check ... + data = flask.request.get_json() + password = data.get("password") + if not password: + return flask.jsonify( + {"error": "password required for account deletion"} + ), 400 + result = auth.unregisterUser(userUUID, password) + if result: + return flask.jsonify({"success": True}), 200 + else: + return flask.jsonify({"error": "invalid password"}), 401 + + +**Security:** Requires password confirmation for deletion. + +=== Health Check === + +**GET /health** - Service health + +@app.route("/health", methods=["GET"]) +def health_check(): + return flask.jsonify({"status": "ok"}), 200 + + +Used by Docker health checks and load balancers. + +==== api/routes/example.py - Example Route Module ==== + + +def _get_user_uuid(token): + """Decode JWT to extract user UUID. Returns None on failure.""" + try: + payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"]) + return payload.get("sub") + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return None + + +def register(app): + @app.route("/api/example", methods=["GET"]) + def api_listExamples(): + header = flask.request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return flask.jsonify({"error": "missing token"}), 401 + token = header[7:] + + user_uuid = _get_user_uuid(token) + if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid): + return flask.jsonify({"error": "unauthorized"}), 401 + + items = postgres.select("examples") + return flask.jsonify(items), 200 + + @app.route("/api/example", methods=["POST"]) + def api_addExample(): + # ... similar pattern ... + data = flask.request.get_json() + item = postgres.insert("examples", data) + return flask.jsonify(item), 201 + + +**Authentication Pattern:** + 1. Extract Bearer token from Authorization header + 2. Decode token to extract user UUID + 3. Verify token belongs to that user via ''verifyLoginToken'' + 4. Return ''401 Unauthorized'' if invalid + 5. Proceed with request if valid + +===== Scheduler Layer ===== + +==== scheduler/daemon.py - Background Task Daemon ==== + + +flowchart TD + START["daemon_loop()"] --> POLL["poll_callback()"] + POLL --> SLEEP["time.sleep(POLL_INTERVAL)"] + SLEEP --> POLL + POLL -- "Exception" --> LOG["logger.error()"] + LOG --> SLEEP + + +This module provides a **simple polling loop** for background tasks. + + +POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60)) + +def poll_callback(): + """ + Override this function with your domain logic. + Called every POLL_INTERVAL seconds. + """ + pass + +def daemon_loop(): + logger.info("Scheduler daemon starting") + while True: + try: + poll_callback() + except Exception as e: + logger.error(f"Poll callback error: {e}") + time.sleep(POLL_INTERVAL) + + +**Usage:** +Override ''poll_callback()'' in your implementation: + +# scheduler/daemon.py (customized) +def poll_callback(): + # Check for due tasks + tasks = postgres.select("tasks", where={"due_at": ("<=", datetime.now())}) + for task in tasks: + settings = notifications.getNotificationSettings(task["user_uuid"]) + notifications._sendToEnabledChannels( + settings, + f"Task due: {task['name']}" + ) + + +**Error Handling:** +Exceptions are caught and logged, allowing the daemon to continue running. + +===== Configuration ===== + +==== config/schema.sql - Database Schema ==== + + +erDiagram + users { + UUID id PK + VARCHAR username UK "NOT NULL" + BYTEA password_hashed "NOT NULL" + TIMESTAMP created_at "DEFAULT NOW()" + } + + notifications { + UUID id PK + UUID user_uuid FK,UK "REFERENCES users(id)" + VARCHAR discord_webhook + BOOLEAN discord_enabled "DEFAULT FALSE" + VARCHAR ntfy_topic + BOOLEAN ntfy_enabled "DEFAULT FALSE" + TIMESTAMP last_message_sent + VARCHAR current_notification_status "DEFAULT 'inactive'" + TIMESTAMP created_at "DEFAULT NOW()" + TIMESTAMP updated_at "DEFAULT NOW()" + } + + users ||--o| notifications : "ON DELETE CASCADE" + + + +-- Users table (minimal) +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + password_hashed BYTEA NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Notifications table +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY, + user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE, + discord_webhook VARCHAR(500), + discord_enabled BOOLEAN DEFAULT FALSE, + ntfy_topic VARCHAR(255), + ntfy_enabled BOOLEAN DEFAULT FALSE, + last_message_sent TIMESTAMP, + current_notification_status VARCHAR(50) DEFAULT 'inactive', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + + +**Design Notes:** + * ''UUID'' primary keys for security (not auto-increment) + * ''ON DELETE CASCADE'' automatically removes related records + * ''BYTEA'' for password hashes (binary data) + +==== config/.env.example - Environment Variables ==== + + +# Discord Bot +DISCORD_BOT_TOKEN=your_discord_bot_token_here + +# API +API_URL=http://app:5000 + +# Database +DB_HOST=db +DB_PORT=5432 +DB_NAME=app +DB_USER=app +DB_PASS=your_db_password_here + +# JWT +JWT_SECRET=your_jwt_secret_here + +# AI / OpenRouter +OPENROUTER_API_KEY=your_openrouter_api_key_here +OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +AI_CONFIG_PATH=/app/ai/ai_config.json + + +===== Docker Deployment ===== + +==== Dockerfile ==== + + +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "-m", "api.main"] + + +**Base Image:** ''python:3.11-slim'' for minimal footprint + +**Default Command:** Runs the Flask API via ''python -m api.main'' (ensures ''/app'' is on sys.path for correct module resolution) + +==== docker-compose.yml ==== + + +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: app + POSTGRES_USER: app + POSTGRES_PASSWORD: ${DB_PASS} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./config/schema.sql:/docker-entrypoint-initdb.d/schema.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: . + ports: + - "8080:5000" + env_file: config/.env + depends_on: + db: + condition: service_healthy + + scheduler: + build: . + command: ["python", "-m", "scheduler.daemon"] + env_file: config/.env + depends_on: + db: + condition: service_healthy + + bot: + build: . + command: ["python", "-m", "bot.bot"] + env_file: config/.env + depends_on: + app: + condition: service_started + +volumes: + pgdata: + + +**Services:** + +^ Service ^ Purpose ^ Dependencies ^ +| db | PostgreSQL database | None | +| app | Flask API (port 8080) | db (healthy) | +| scheduler | Background tasks | db (healthy) | +| bot | Discord client | app | + +**Health Checks:** + * ''db'' uses PostgreSQL's built-in ''pg_isready'' + * Other services wait for their dependencies + +**Environment Files:** + * ''config/.env'' - Loaded by app, bot, and scheduler services + * Root ''.env'' - Provides ''DB_PASS'' for docker-compose variable substitution (''${DB_PASS}'' in the db service) + +**Volume Mounts:** + * ''pgdata'' - Persistent database storage + * ''schema.sql'' - Auto-runs on first startup + +===== Data Flow Examples ===== + +==== Example 1: User Login ==== + + +sequenceDiagram + actor User + participant Bot as bot.py + participant API as api/main.py + participant Auth as core/auth.py + participant DB as PostgreSQL + + User->>Bot: DM "username123" + Bot->>Bot: login_state[id] = {step: "username"} + Bot->>User: "Password?" + User->>Bot: DM "mypassword" + Bot->>API: POST /api/login {username, password} + API->>Auth: getLoginToken("username123", "mypassword") + Auth->>DB: SELECT * FROM users WHERE username='username123' + DB-->>Auth: user record (with hashed pw) + Auth->>Auth: bcrypt.checkpw(password, hash) + Auth-->>API: JWT token + API-->>Bot: {token: "eyJ..."} 200 + Bot->>Bot: Store session {token, user_uuid, username} + Bot->>User: "Welcome back username123!" + + +==== Example 2: Natural Language Command ==== + + +sequenceDiagram + actor User + participant Bot as bot.py + participant Parser as ai/parser.py + participant LLM as OpenRouter + participant Registry as command_registry + participant Handler as Domain Handler + participant API as api/main.py + participant DB as PostgreSQL + + User->>Bot: DM "add a task to buy groceries" + Bot->>Bot: Show typing indicator + Bot->>Parser: parse("add a task...", "command_parser", history) + Parser->>LLM: chat.completions.create(system + user prompt) + LLM-->>Parser: {"interaction_type":"task","action":"add","task_name":"buy groceries"} + Parser->>Parser: json.loads() + validate + Parser-->>Bot: parsed JSON + Bot->>Bot: Update message_history (keep last 5) + Bot->>Registry: get_handler("task") + Registry-->>Bot: handle_task function + Bot->>Handler: handle_task(message, session, parsed) + Handler->>API: POST /api/tasks {name: "buy groceries"} + API->>DB: INSERT INTO tasks... + DB-->>API: inserted row + API-->>Handler: {id, name, ...} 201 + Handler->>User: "Added task: **buy groceries**" + + +==== Example 3: API Request with Authentication ==== + + +sequenceDiagram + actor Client as External Client + participant Flask as api/main.py + participant Route as routes/example.py + participant Auth as core/auth.py + participant PG as core/postgres.py + participant DB as PostgreSQL + + Client->>Flask: POST /api/tasks
Authorization: Bearer + Flask->>Route: Route handler + Route->>Route: _get_user_uuid(token)
jwt.decode() → sub claim + Route->>Auth: verifyLoginToken(token, userUUID) + Auth->>Auth: jwt.decode() + check sub == userUUID + Auth-->>Route: True + Route->>PG: insert("tasks", data) + PG->>DB: INSERT INTO tasks... RETURNING * + DB-->>PG: inserted row + PG-->>Route: {id, name, ...} + Route-->>Client: 201 {id, name, ...} +
+ +===== Security Considerations ===== + +==== SQL Injection Prevention ==== + +All database queries use parameterized queries: + +# Safe - parameterized +cur.execute("SELECT * FROM users WHERE id = %(id)s", {"id": user_id}) + +# Unsafe - string interpolation (NOT used) +cur.execute(f"SELECT * FROM users WHERE id = '{user_id}'") + + +The ''_safe_id()'' function validates SQL identifiers. + +==== Password Storage ==== + +Passwords are: + * Hashed with bcrypt (adaptive hash function) + * Salted automatically by bcrypt + * Never stored in plain text + * Never returned in API responses + +==== JWT Security ==== + + * Tokens expire after 1 hour + * Signed with secret key (''JWT_SECRET'') + * Contain minimal data (UUID, name) + * Verified on every request + +==== Rate Limiting ==== + +Not implemented in this template. Consider adding: + * Rate limiting on API endpoints + * Login attempt throttling + * Command cooldown per user + +===== Extending the Framework ===== + +==== Adding a New Domain Module ==== + +**Step 1: Create database table** + +Edit ''config/schema.sql'': + +CREATE TABLE IF NOT EXISTS habits ( + id UUID PRIMARY KEY, + user_uuid UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + streak INT DEFAULT 0, + last_completed DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + + +**Step 2: Create API routes** + +Create ''api/routes/habits.py'': + +import os +import flask +import jwt +import core.auth as auth +import core.postgres as postgres +import uuid + + +def _get_user_uuid(token): + """Decode JWT to extract user UUID. Returns None on failure.""" + try: + payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"]) + return payload.get("sub") + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return None + + +def register(app): + @app.route("/api/habits", methods=["GET"]) + def api_listHabits(): + header = flask.request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return flask.jsonify({"error": "missing token"}), 401 + token = header[7:] + + user_uuid = _get_user_uuid(token) + if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid): + return flask.jsonify({"error": "unauthorized"}), 401 + + items = postgres.select("habits", where={"user_uuid": user_uuid}) + return flask.jsonify(items), 200 + + @app.route("/api/habits", methods=["POST"]) + def api_addHabit(): + # ... similar pattern ... + pass + + +Register in ''api/main.py'': + +import api.routes.habits as habits_routes +register_routes(habits_routes) + + +**Step 3: Create bot commands** + +Create ''bot/commands/habits.py'': + +from bot.command_registry import register_module +import ai.parser as ai_parser +from bot.bot import apiRequest + +async def handle_habit(message, session, parsed): + action = parsed.get("action") + token = session["token"] + + if action == "list": + result, status = apiRequest("get", "/api/habits", token) + if status == 200: + lines = [f"- {h['name']} (streak: {h['streak']})" for h in result] + await message.channel.send("Your habits:\n" + "\n".join(lines)) + + elif action == "add": + name = parsed.get("habit_name") + result, status = apiRequest("post", "/api/habits", token, {"name": name}) + if status == 201: + await message.channel.send(f"Created habit: **{name}**") + +def validate_habit_json(data): + errors = [] + if "action" not in data: + errors.append("Missing required field: action") + return errors + +register_module("habit", handle_habit) +ai_parser.register_validator("habit", validate_habit_json) + + +**Step 4: Add AI prompts** + +Edit ''ai/ai_config.json'': + +{ + "prompts": { + "command_parser": { ... }, + "habit_parser": { + "system": "You parse habit tracking commands...", + "user_template": "Parse: \"{user_input}\". Return JSON with action (list/add/complete) and habit_name." + } + } +} + + +===== Troubleshooting ===== + +==== Common Issues ==== + +**Bot not responding to DMs** + * Verify ''message_content'' intent is enabled in Discord Developer Portal + * Check bot has permission to read messages + +**API returning 401 Unauthorized** + * Verify token is valid and not expired + * Check Authorization header format: ''Bearer '' + +**AI parser failing** + * Verify OpenRouter API key is valid + * Check model is available + * Review prompts in ''ai_config.json'' + +**Database connection errors** + * Verify PostgreSQL is running + * Check environment variables match Docker config + +==== Debugging Tips ==== + +**Enable verbose logging:** + +import logging +logging.basicConfig(level=logging.DEBUG) + + +**Test database connection:** + +from core import postgres +print(postgres.select("users")) + + +**Test AI parser:** + +import ai.parser as ai_parser +result = ai_parser.parse("add task buy milk", "command_parser") +print(result) + + +===== Conclusion ===== + +The LLM Bot Framework provides a solid foundation for building AI-powered Discord bots. Its modular architecture allows developers to: + + * Add new domains without modifying core code + * Use any OpenAI-compatible LLM + * Deploy easily with Docker + * Scale with PostgreSQL + +Key design decisions: + * **Separation of concerns** - Bot, API, and core logic are independent + * **Configuration-driven** - AI behavior is customizable via JSON + * **Security-first** - Parameterized queries, hashed passwords, JWT auth + * **Developer experience** - Clear patterns for extending the framework + +The framework demonstrates how LLMs can bridge the gap between natural language and structured database operations, enabling conversational interfaces that feel intuitive to users. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..444d8b2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "-m", "api.main"] diff --git a/HOOKS.md b/HOOKS.md new file mode 100644 index 0000000..5b656f0 --- /dev/null +++ b/HOOKS.md @@ -0,0 +1,44 @@ +# Where the hooks are + +## API route registration — `api/main.py` + +Lines 10-11: imported `api.routes.routines` and `api.routes.medications` +Line 16: added both to `ROUTE_MODULES` list so they auto-register on startup + +## Bot command registration — `bot/bot.py` + +Lines 23-24: imported `bot.commands.routines` and `bot.commands.medications` +These imports trigger `register_module()` and `register_validator()` at load time, +which makes the bot's AI parser route "routine" and "medication" interaction types +to the right handlers. + +## Bot command handlers — `bot/commands/routines.py`, `bot/commands/medications.py` + +Each file: +- Defines an async handler (`handle_routine`, `handle_medication`) +- Defines a JSON validator for the AI parser +- Calls `register_module()` to hook into the command registry +- Calls `ai_parser.register_validator()` to hook into parse validation + +## Scheduler — `scheduler/daemon.py` + +`poll_callback()` now calls three check functions on every tick: +- `check_medication_reminders()` — sends notifications for doses due now +- `check_routine_reminders()` — sends notifications for scheduled routines +- `check_refills()` — warns when medication supply is running low + +All three use `core.notifications._sendToEnabledChannels()` to deliver. + +## AI config — `ai/ai_config.json` + +Updated the `command_parser` system prompt to list the two interaction types +(`routine`, `medication`) and the fields to extract for each. This is what +tells the LLM how to parse natural language into the right action structure. + +## What's NOT hooked yet (needs implementation) + +- `config/schema.sql` — needs tables for routines, routine_steps, + routine_sessions, routine_schedules, medications, med_logs +- The actual body of every API route (all prototyped as `pass`) +- The actual body of both bot command handlers +- The three scheduler check functions diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5656db --- /dev/null +++ b/README.md @@ -0,0 +1,251 @@ +# LLM Bot Framework + +A template for building Discord bots powered by LLMs with natural language command parsing. + +## Features + +- **AI-Powered Parsing**: Uses LLMs to parse natural language into structured JSON with automatic retry and validation +- **Module Registry**: Easily register domain-specific command handlers +- **Flask API**: REST API with JWT authentication +- **PostgreSQL**: Generic CRUD layer for any table +- **Discord Bot**: Session management, login flow, background tasks +- **Notifications**: Discord webhook + ntfy support out of the box +- **Docker Ready**: Full docker-compose setup + +## Quick Start + +```bash +# Copy environment config +cp config/.env.example config/.env + +# Edit with your values +nano config/.env + +# Start everything +docker-compose up +``` + +## Project Structure + +``` +llm-bot-framework/ +├── api/ +│ ├── main.py # Flask app with auth routes +│ └── routes/ +│ └── example.py # Example route module +├── bot/ +│ ├── bot.py # Discord client +│ ├── command_registry.py # Module registration +│ └── commands/ +│ └── example.py # Example command module +├── core/ +│ ├── postgres.py # Generic PostgreSQL CRUD +│ ├── auth.py # JWT + bcrypt +│ ├── users.py # User management +│ └── notifications.py # Multi-channel notifications +├── ai/ +│ ├── parser.py # LLM JSON parser +│ └── ai_config.json # Model + prompts config +├── scheduler/ +│ └── daemon.py # Background polling +├── config/ +│ ├── schema.sql # Database schema +│ └── .env.example # Environment template +├── docker-compose.yml +├── Dockerfile +└── requirements.txt +``` + +## Creating a Domain Module + +### 1. Add Database Schema + +Edit `config/schema.sql`: + +```sql +CREATE TABLE IF NOT EXISTS tasks ( + id UUID PRIMARY KEY, + user_uuid UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + completed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 2. Create API Routes + +Create `api/routes/tasks.py`: + +```python +import flask +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import core.auth as auth +import core.postgres as postgres +import uuid + +def register(app): + @app.route('/api/tasks', methods=['GET']) + def api_listTasks(): + header = flask.request.headers.get('Authorization', '') + if not header.startswith('Bearer '): + return flask.jsonify({'error': 'missing token'}), 401 + token = header[7:] + + # Get user UUID from token + from core.auth import decodeJwtPayload + import json, base64 + payload = token.split('.')[1] + payload += '=' * (4 - len(payload) % 4) + decoded = json.loads(base64.urlsafe_b64decode(payload)) + user_uuid = decoded['sub'] + + tasks = postgres.select("tasks", {"user_uuid": user_uuid}) + return flask.jsonify(tasks), 200 + + @app.route('/api/tasks', methods=['POST']) + def api_addTask(): + header = flask.request.headers.get('Authorization', '') + if not header.startswith('Bearer '): + return flask.jsonify({'error': 'missing token'}), 401 + token = header[7:] + + data = flask.request.get_json() + task = postgres.insert("tasks", { + 'id': str(uuid.uuid4()), + 'user_uuid': data['user_uuid'], + 'name': data['name'], + }) + return flask.jsonify(task), 201 +``` + +Register it in `api/main.py`: + +```python +import api.routes.tasks as tasks_routes +register_routes(tasks_routes) +``` + +### 3. Create Bot Commands + +Create `bot/commands/tasks.py`: + +```python +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from bot.command_registry import register_module +import ai.parser as ai_parser + +async def handle_task(message, session, parsed): + action = parsed.get('action') + token = session['token'] + user_uuid = session['user_uuid'] + + # Make API calls using the bot's apiRequest helper + from bot.bot import apiRequest + + if action == 'list': + result, status = apiRequest('get', f'/api/tasks', token) + if status == 200: + lines = [f"- {t['name']}" for t in result] + await message.channel.send("Your tasks:\n" + "\n".join(lines)) + else: + await message.channel.send("Failed to fetch tasks.") + + elif action == 'add': + task_name = parsed.get('task_name') + result, status = apiRequest('post', '/api/tasks', token, { + 'user_uuid': user_uuid, + 'name': task_name + }) + if status == 201: + await message.channel.send(f"Added task: **{task_name}**") + else: + await message.channel.send("Failed to add task.") + +def validate_task_json(data): + errors = [] + if 'action' not in data: + errors.append('Missing required field: action') + if data.get('action') == 'add' and 'task_name' not in data: + errors.append('Missing required field for add: task_name') + return errors + +register_module('task', handle_task) +ai_parser.register_validator('task', validate_task_json) +``` + +### 4. Add AI Prompts + +Edit `ai/ai_config.json`: + +```json +{ + "prompts": { + "command_parser": { + "system": "You are a helpful assistant...", + "user_template": "..." + }, + "task_parser": { + "system": "You parse task commands...", + "user_template": "Parse: \"{user_input}\"\n\nReturn JSON with action (list/add/complete) and task_name." + } + } +} +``` + +## AI Parser Usage + +```python +import ai.parser as ai_parser + +# Basic usage +parsed = ai_parser.parse(user_input, 'command_parser') + +# With conversation history +history = [("previous message", {"action": "add", "item": "test"})] +parsed = ai_parser.parse(user_input, 'command_parser', history=history) + +# Register custom validator +ai_parser.register_validator('task', validate_task_json) +``` + +## Notification Channels + +```python +import core.notifications as notif + +# Set user notification settings +notif.setNotificationSettings(user_uuid, { + 'discord_webhook': 'https://discord.com/api/webhooks/...', + 'discord_enabled': True, + 'ntfy_topic': 'my-alerts', + 'ntfy_enabled': True +}) + +# Send notification +settings = notif.getNotificationSettings(user_uuid) +notif._sendToEnabledChannels(settings, "Task due: Buy groceries") +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `DISCORD_BOT_TOKEN` | Discord bot token | +| `API_URL` | API URL (default: `http://app:5000`) | +| `DB_HOST` | PostgreSQL host | +| `DB_PORT` | PostgreSQL port | +| `DB_NAME` | Database name | +| `DB_USER` | Database user | +| `DB_PASS` | Database password | +| `JWT_SECRET` | JWT signing secret | +| `OPENROUTER_API_KEY` | OpenRouter API key | +| `OPENROUTER_BASE_URL` | OpenRouter base URL | +| `AI_CONFIG_PATH` | Path to ai_config.json | + +## License + +MIT diff --git a/ai/__pycache__/parser.cpython-312.pyc b/ai/__pycache__/parser.cpython-312.pyc new file mode 100644 index 0000000..98b6305 Binary files /dev/null and b/ai/__pycache__/parser.cpython-312.pyc differ diff --git a/ai/ai_config.json b/ai/ai_config.json new file mode 100644 index 0000000..19e50e2 --- /dev/null +++ b/ai/ai_config.json @@ -0,0 +1,15 @@ +{ + "model": "qwen/qwen3-next-80b-a3b-thinking:nitro", + "max_tokens": 8192, + "prompts": { + "command_parser": { + "system": "You are a helpful AI assistant that parses user commands into structured JSON. Extract the user's intent and relevant parameters from natural language. Return ONLY valid JSON, no explanations.\n\nBe flexible with language - handle typos, slang, and casual phrasing. Consider conversation context when available.\n\nIf unclear, ask for clarification in the 'needs_clarification' field with confidence < 0.8.\n\nAvailable interaction types:\n- \"routine\": managing daily routines (create, start, complete steps, view history)\n- \"medication\": managing medications (add, take, skip, snooze, check schedule, refills)\n\nFor routine commands, extract: action (create|list|start|complete_step|skip_step|cancel|history|schedule), routine_name?, step_name?, duration_minutes?, days?, time?\nFor medication commands, extract: action (add|list|take|skip|snooze|today|adherence|refill), med_name?, dosage?, unit?, frequency?, times?, reason?, minutes?", + "user_template": "Parse this command into structured JSON.\n\nCurrent conversation context (if any):\n{history_context}\n\nUser message: \"{user_input}\"\n\nReturn JSON with:\n{\n \"interaction_type\": \"routine\" or \"medication\",\n \"action\": \"string\",\n \"confidence\": number (0-1),\n \"needs_clarification\": \"string\" (if confidence < 0.8),\n ... other extracted fields ...\n}\n\nIf unclear, ask for clarification in the needs_clarification field." + } + }, + "validation": { + "max_retries": 3, + "timeout_seconds": 15, + "validators": {} + } +} diff --git a/ai/parser.py b/ai/parser.py new file mode 100644 index 0000000..4cbf25b --- /dev/null +++ b/ai/parser.py @@ -0,0 +1,151 @@ +""" +parser.py - LLM-powered JSON parser with retry and validation + +Config-driven via ai_config.json. Supports: +- Any OpenAI-compatible API (OpenRouter, local, etc.) +- Reasoning models that output in reasoning field +- Schema validation with automatic retry +- Conversation context for multi-turn interactions +""" + +import json +import os +import re +from openai import OpenAI + +CONFIG_PATH = os.environ.get( + "AI_CONFIG_PATH", os.path.join(os.path.dirname(__file__), "ai_config.json") +) + +with open(CONFIG_PATH, "r") as f: + AI_CONFIG = json.load(f) + +client = OpenAI( + api_key=os.getenv("OPENROUTER_API_KEY"), + base_url=os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), +) + + +def _extract_json_from_text(text): + """Pull the first JSON object out of a block of text (for reasoning models).""" + match = re.search(r"```json\s*(\{.*?\})\s*```", text, re.DOTALL) + if match: + return match.group(1) + match = re.search(r"(\{[^{}]*\})", text, re.DOTALL) + if match: + return match.group(1) + return None + + +def _call_llm(system_prompt, user_prompt): + """Call OpenAI-compatible API and return the response text.""" + try: + response = client.chat.completions.create( + model=AI_CONFIG["model"], + max_tokens=AI_CONFIG.get("max_tokens", 8192), + timeout=AI_CONFIG["validation"]["timeout_seconds"], + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + ) + msg = response.choices[0].message + text = msg.content.strip() if msg.content else "" + if text: + return text + reasoning = getattr(msg, "reasoning", None) + if reasoning: + extracted = _extract_json_from_text(reasoning) + if extracted: + return extracted + return None + except Exception as e: + print(f"LLM error: {type(e).__name__}: {e}", flush=True) + return None + + +def parse(user_input, interaction_type, retry_count=0, errors=None, history=None): + """ + Parse user input into structured JSON using LLM. + + Args: + user_input: The raw user message + interaction_type: Key in ai_config.json prompts (e.g., 'command_parser') + retry_count: Internal retry counter + errors: Previous validation errors for retry + history: List of (user_msg, parsed_result) tuples for context + + Returns: + dict: Parsed JSON or error dict + """ + if retry_count >= AI_CONFIG["validation"]["max_retries"]: + return { + "error": f"Failed to parse after {retry_count} retries", + "user_input": user_input, + } + + prompt_config = AI_CONFIG["prompts"].get(interaction_type) + if not prompt_config: + return { + "error": f"Unknown interaction type: {interaction_type}", + "user_input": user_input, + } + + history_context = "No previous context" + if history and len(history) > 0: + history_lines = [] + for i, (msg, result) in enumerate(history[-3:]): + history_lines.append(f"{i + 1}. User: {msg}") + if isinstance(result, dict) and not result.get("error"): + history_lines.append(f" Parsed: {json.dumps(result)}") + else: + history_lines.append(f" Parsed: {result}") + history_context = "\n".join(history_lines) + + user_prompt = prompt_config["user_template"].format( + user_input=user_input, history_context=history_context + ) + + if errors: + user_prompt += ( + f"\n\nPrevious attempt had errors: {errors}\nPlease fix and try again." + ) + + response_text = _call_llm(prompt_config["system"], user_prompt) + if not response_text: + return {"error": "AI service unavailable", "user_input": user_input} + + try: + parsed = json.loads(response_text) + except json.JSONDecodeError: + return parse( + user_input, + interaction_type, + retry_count + 1, + ["Response was not valid JSON"], + history=history, + ) + + if "error" in parsed: + return parsed + + validator = AI_CONFIG["validation"].get("validators", {}).get(interaction_type) + if validator: + validation_errors = validator(parsed) + if validation_errors: + return parse( + user_input, + interaction_type, + retry_count + 1, + validation_errors, + history=history, + ) + + return parsed + + +def register_validator(interaction_type, validator_fn): + """Register a custom validation function for an interaction type.""" + if "validators" not in AI_CONFIG["validation"]: + AI_CONFIG["validation"]["validators"] = {} + AI_CONFIG["validation"]["validators"][interaction_type] = validator_fn diff --git a/api/__pycache__/main.cpython-312.pyc b/api/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..36f63dd Binary files /dev/null and b/api/__pycache__/main.cpython-312.pyc differ diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..01efb4e --- /dev/null +++ b/api/main.py @@ -0,0 +1,139 @@ +""" +main.py - Flask API with auth routes and module registry + +Domain routes are registered via the routes registry. +""" + +import os +import flask +import core.auth as auth +import core.users as users +import core.postgres as postgres +import api.routes.routines as routines_routes +import api.routes.medications as medications_routes + +app = flask.Flask(__name__) + +ROUTE_MODULES = [routines_routes, medications_routes] + + +def register_routes(module): + """Register a routes module. Module should have a register(app) function.""" + ROUTE_MODULES.append(module) + + +# ── Auth Routes ──────────────────────────────────────────────────── + + +@app.route("/api/register", methods=["POST"]) +def api_register(): + data = flask.request.get_json() + username = data.get("username") + password = data.get("password") + if not username or not password: + return flask.jsonify({"error": "username and password required"}), 400 + result = users.registerUser(username, password, data) + if result: + return flask.jsonify({"success": True}), 201 + else: + return flask.jsonify({"error": "username taken"}), 409 + + +@app.route("/api/login", methods=["POST"]) +def api_login(): + data = flask.request.get_json() + username = data.get("username") + password = data.get("password") + if not username or not password: + return flask.jsonify({"error": "username and password required"}), 400 + token = auth.getLoginToken(username, password) + if token: + return flask.jsonify({"token": token}), 200 + else: + return flask.jsonify({"error": "invalid credentials"}), 401 + + +# ── User Routes ──────────────────────────────────────────────────── + + +@app.route("/api/getUserUUID/", methods=["GET"]) +def api_getUserUUID(username): + header = flask.request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return flask.jsonify({"error": "missing token"}), 401 + token = header[7:] + if auth.verifyLoginToken(token, username): + return flask.jsonify(users.getUserUUID(username)), 200 + else: + return flask.jsonify({"error": "unauthorized"}), 401 + + +@app.route("/api/user/", methods=["GET"]) +def api_getUser(userUUID): + header = flask.request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return flask.jsonify({"error": "missing token"}), 401 + token = header[7:] + if auth.verifyLoginToken(token, userUUID=userUUID): + user = postgres.select_one("users", {"id": userUUID}) + if user: + user.pop("password_hashed", None) + return flask.jsonify(user), 200 + else: + return flask.jsonify({"error": "user not found"}), 404 + else: + return flask.jsonify({"error": "unauthorized"}), 401 + + +@app.route("/api/user/", methods=["PUT"]) +def api_updateUser(userUUID): + header = flask.request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return flask.jsonify({"error": "missing token"}), 401 + token = header[7:] + if auth.verifyLoginToken(token, userUUID=userUUID): + data = flask.request.get_json() + result = users.updateUser(userUUID, data) + if result: + return flask.jsonify({"success": True}), 200 + else: + return flask.jsonify({"error": "no valid fields to update"}), 400 + else: + return flask.jsonify({"error": "unauthorized"}), 401 + + +@app.route("/api/user/", methods=["DELETE"]) +def api_deleteUser(userUUID): + header = flask.request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return flask.jsonify({"error": "missing token"}), 401 + token = header[7:] + if auth.verifyLoginToken(token, userUUID=userUUID): + data = flask.request.get_json() + password = data.get("password") + if not password: + return flask.jsonify( + {"error": "password required for account deletion"} + ), 400 + result = auth.unregisterUser(userUUID, password) + if result: + return flask.jsonify({"success": True}), 200 + else: + return flask.jsonify({"error": "invalid password"}), 401 + else: + return flask.jsonify({"error": "unauthorized"}), 401 + + +# ── Health Check ─────────────────────────────────────────────────── + + +@app.route("/health", methods=["GET"]) +def health_check(): + return flask.jsonify({"status": "ok"}), 200 + + +if __name__ == "__main__": + for module in ROUTE_MODULES: + if hasattr(module, "register"): + module.register(app) + app.run(host="0.0.0.0", port=5000) diff --git a/api/routes/__pycache__/example.cpython-312.pyc b/api/routes/__pycache__/example.cpython-312.pyc new file mode 100644 index 0000000..fbc5597 Binary files /dev/null and b/api/routes/__pycache__/example.cpython-312.pyc differ diff --git a/api/routes/example.py b/api/routes/example.py new file mode 100644 index 0000000..1f1ff2b --- /dev/null +++ b/api/routes/example.py @@ -0,0 +1,56 @@ +""" +Example route module - Copy this pattern for your domain. + +This module demonstrates: +1. Registering routes with Flask app +2. Using auth validation +3. Making database calls via postgres module +""" + +import os +import flask +import jwt +import core.auth as auth +import core.postgres as postgres + + +def _get_user_uuid(token): + """Decode JWT to extract user UUID. Returns None on failure.""" + try: + payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"]) + return payload.get("sub") + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return None + + +def register(app): + """Register routes with the Flask app.""" + + @app.route("/api/example", methods=["GET"]) + def api_listExamples(): + header = flask.request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return flask.jsonify({"error": "missing token"}), 401 + token = header[7:] + + user_uuid = _get_user_uuid(token) + if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid): + return flask.jsonify({"error": "unauthorized"}), 401 + + items = postgres.select("examples") + return flask.jsonify(items), 200 + + @app.route("/api/example", methods=["POST"]) + def api_addExample(): + header = flask.request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return flask.jsonify({"error": "missing token"}), 401 + token = header[7:] + + user_uuid = _get_user_uuid(token) + if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid): + return flask.jsonify({"error": "unauthorized"}), 401 + + data = flask.request.get_json() + item = postgres.insert("examples", data) + return flask.jsonify(item), 201 diff --git a/api/routes/medications.py b/api/routes/medications.py new file mode 100644 index 0000000..5e98858 --- /dev/null +++ b/api/routes/medications.py @@ -0,0 +1,160 @@ +""" +Medications API - medication scheduling, logging, and adherence tracking +""" + +import os +import flask +import jwt +import core.auth as auth +import core.postgres as postgres + + +def _get_user_uuid(token): + try: + payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"]) + return payload.get("sub") + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return None + + +def _auth(request): + """Extract and verify token. Returns user_uuid or None.""" + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return None + token = header[7:] + user_uuid = _get_user_uuid(token) + if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid): + return None + return user_uuid + + +def register(app): + + # ── Medications CRUD ────────────────────────────────────────── + + @app.route("/api/medications", methods=["GET"]) + def api_listMedications(): + """List all medications for the logged-in user.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + @app.route("/api/medications", methods=["POST"]) + def api_addMedication(): + """Add a medication. Body: {name, dosage, unit, frequency, times: ["08:00","20:00"], notes?}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + @app.route("/api/medications/", methods=["GET"]) + def api_getMedication(med_id): + """Get a single medication with its schedule.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + @app.route("/api/medications/", methods=["PUT"]) + def api_updateMedication(med_id): + """Update medication details. Body: {name?, dosage?, unit?, frequency?, times?, notes?, active?}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + @app.route("/api/medications/", methods=["DELETE"]) + def api_deleteMedication(med_id): + """Delete a medication and its logs.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + # ── Medication Logging (take / skip / snooze) ───────────────── + + @app.route("/api/medications//take", methods=["POST"]) + def api_takeMedication(med_id): + """Log that a dose was taken. Body: {scheduled_time?, notes?}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() or {} + pass + + @app.route("/api/medications//skip", methods=["POST"]) + def api_skipMedication(med_id): + """Log a skipped dose. Body: {scheduled_time?, reason?}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() or {} + pass + + @app.route("/api/medications//snooze", methods=["POST"]) + def api_snoozeMedication(med_id): + """Snooze a reminder. Body: {minutes: 15}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + # ── Medication Log / History ────────────────────────────────── + + @app.route("/api/medications//log", methods=["GET"]) + def api_getMedLog(med_id): + """Get dose log for a medication. Query: ?days=30""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + @app.route("/api/medications/today", methods=["GET"]) + def api_todaysMeds(): + """Get today's medication schedule with taken/pending status.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + # ── Adherence Stats ─────────────────────────────────────────── + + @app.route("/api/medications/adherence", methods=["GET"]) + def api_adherenceStats(): + """Get adherence stats across all meds. Query: ?days=30""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + @app.route("/api/medications//adherence", methods=["GET"]) + def api_medAdherence(med_id): + """Get adherence stats for a single medication. Query: ?days=30""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + # ── Refills ─────────────────────────────────────────────────── + + @app.route("/api/medications//refill", methods=["PUT"]) + def api_setRefill(med_id): + """Set refill info. Body: {quantity_remaining, refill_date?, pharmacy_notes?}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + @app.route("/api/medications/refills-due", methods=["GET"]) + def api_refillsDue(): + """Get medications that need refills soon. Query: ?days_ahead=7""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass diff --git a/api/routes/routines.py b/api/routes/routines.py new file mode 100644 index 0000000..6a955d6 --- /dev/null +++ b/api/routes/routines.py @@ -0,0 +1,204 @@ +""" +Routines API - Brilli-style routine management + +Routines have ordered steps. Users start sessions to walk through them. +""" + +import os +import flask +import jwt +import core.auth as auth +import core.postgres as postgres + + +def _get_user_uuid(token): + try: + payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"]) + return payload.get("sub") + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return None + + +def _auth(request): + """Extract and verify token. Returns user_uuid or None.""" + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return None + token = header[7:] + user_uuid = _get_user_uuid(token) + if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid): + return None + return user_uuid + + +def register(app): + + # ── Routines CRUD ───────────────────────────────────────────── + + @app.route("/api/routines", methods=["GET"]) + def api_listRoutines(): + """List all routines for the logged-in user.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + @app.route("/api/routines", methods=["POST"]) + def api_createRoutine(): + """Create a new routine. Body: {name, description?, icon?}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + @app.route("/api/routines/", methods=["GET"]) + def api_getRoutine(routine_id): + """Get a routine with its steps.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + @app.route("/api/routines/", methods=["PUT"]) + def api_updateRoutine(routine_id): + """Update routine details. Body: {name?, description?, icon?}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + @app.route("/api/routines/", methods=["DELETE"]) + def api_deleteRoutine(routine_id): + """Delete a routine and all its steps/sessions.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + # ── Steps CRUD ──────────────────────────────────────────────── + + @app.route("/api/routines//steps", methods=["GET"]) + def api_listSteps(routine_id): + """List steps for a routine, ordered by position.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + @app.route("/api/routines//steps", methods=["POST"]) + def api_addStep(routine_id): + """Add a step to a routine. Body: {name, duration_minutes?, position?}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + @app.route("/api/routines//steps/", methods=["PUT"]) + def api_updateStep(routine_id, step_id): + """Update a step. Body: {name?, duration_minutes?, position?}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + @app.route("/api/routines//steps/", methods=["DELETE"]) + def api_deleteStep(routine_id, step_id): + """Delete a step from a routine.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + @app.route("/api/routines//steps/reorder", methods=["PUT"]) + def api_reorderSteps(routine_id): + """Reorder steps. Body: {step_ids: [ordered list of step UUIDs]}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + # ── Routine Sessions (active run-through) ───────────────────── + + @app.route("/api/routines//start", methods=["POST"]) + def api_startRoutine(routine_id): + """Start a routine session. Returns the session with first step.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + @app.route("/api/sessions/active", methods=["GET"]) + def api_getActiveSession(): + """Get the user's currently active routine session, if any.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + @app.route("/api/sessions//complete-step", methods=["POST"]) + def api_completeStep(session_id): + """Mark current step done, advance to next. Body: {step_id}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + @app.route("/api/sessions//skip-step", methods=["POST"]) + def api_skipStep(session_id): + """Skip current step, advance to next. Body: {step_id}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + @app.route("/api/sessions//cancel", methods=["POST"]) + def api_cancelSession(session_id): + """Cancel an active routine session.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + # ── Routine History / Stats ─────────────────────────────────── + + @app.route("/api/routines//history", methods=["GET"]) + def api_routineHistory(routine_id): + """Get past sessions for a routine. Query: ?days=7""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + # ── Routine Scheduling ──────────────────────────────────────── + + @app.route("/api/routines//schedule", methods=["PUT"]) + def api_setRoutineSchedule(routine_id): + """Set when this routine should run. Body: {days: ["mon","tue",...], time: "08:00", remind: true}""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + data = flask.request.get_json() + pass + + @app.route("/api/routines//schedule", methods=["GET"]) + def api_getRoutineSchedule(routine_id): + """Get the schedule for a routine.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass + + @app.route("/api/routines//schedule", methods=["DELETE"]) + def api_deleteRoutineSchedule(routine_id): + """Remove the schedule from a routine.""" + user_uuid = _auth(flask.request) + if not user_uuid: + return flask.jsonify({"error": "unauthorized"}), 401 + pass diff --git a/bot/__pycache__/bot.cpython-312.pyc b/bot/__pycache__/bot.cpython-312.pyc new file mode 100644 index 0000000..edb334f Binary files /dev/null and b/bot/__pycache__/bot.cpython-312.pyc differ diff --git a/bot/__pycache__/command_registry.cpython-312.pyc b/bot/__pycache__/command_registry.cpython-312.pyc new file mode 100644 index 0000000..38f703f Binary files /dev/null and b/bot/__pycache__/command_registry.cpython-312.pyc differ diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 0000000..a5990ec --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,272 @@ +""" +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 + +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): + registered = list_registered() + help_msg = f"**Available Modules:**\n{chr(10).join(f'- {m}' for m in registered) if registered else '- No modules registered'}\n\nJust talk naturally and I'll help you out!" + await message.channel.send(help_msg) + + +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 + + async with message.channel.typing(): + history = message_history.get(discord_id, []) + parsed = ai_parser.parse(message.content, "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) diff --git a/bot/command_registry.py b/bot/command_registry.py new file mode 100644 index 0000000..7002564 --- /dev/null +++ b/bot/command_registry.py @@ -0,0 +1,35 @@ +""" +command_registry.py - Module registration for bot commands + +Register domain-specific handlers for different interaction types. +""" + +COMMAND_MODULES = {} + + +def register_module(interaction_type, handler): + """ + Register a handler for an interaction type. + + Args: + interaction_type: String key (e.g., 'med', 'habit', 'task') + handler: Async function(message, session, parsed) -> None + + Example: + async def handle_med(message, session, parsed): + action = parsed['action'] + # ... handle medication logic ... + + register_module('med', handle_med) + """ + COMMAND_MODULES[interaction_type] = handler + + +def get_handler(interaction_type): + """Get the registered handler for an interaction type.""" + return COMMAND_MODULES.get(interaction_type) + + +def list_registered(): + """List all registered interaction types.""" + return list(COMMAND_MODULES.keys()) diff --git a/bot/commands/__pycache__/example.cpython-312.pyc b/bot/commands/__pycache__/example.cpython-312.pyc new file mode 100644 index 0000000..9d8f642 Binary files /dev/null and b/bot/commands/__pycache__/example.cpython-312.pyc differ diff --git a/bot/commands/example.py b/bot/commands/example.py new file mode 100644 index 0000000..b3cad7b --- /dev/null +++ b/bot/commands/example.py @@ -0,0 +1,63 @@ +""" +Example command module - Copy this pattern for your domain. + +This module demonstrates: +1. Registering a handler with the command registry +2. Using the AI parser with custom prompts +3. Making API calls +""" + +from bot.command_registry import register_module +import ai.parser as ai_parser + + +async def handle_example(message, session, parsed): + """ + Handler for 'example' interaction type. + + Args: + message: Discord message object + session: {token, user_uuid, username} + parsed: Parsed JSON from AI parser + """ + action = parsed.get("action", "unknown") + token = session["token"] + user_uuid = session["user_uuid"] + + if action == "check": + await message.channel.send( + f"Checking example items for {session['username']}..." + ) + elif action == "add": + item_name = parsed.get("item_name", "unnamed") + await message.channel.send(f"Adding example item: **{item_name}**") + else: + await message.channel.send(f"Unknown example action: {action}") + + +def validate_example_json(data): + """Validate parsed JSON for example commands. Return list of errors.""" + errors = [] + + if not isinstance(data, dict): + return ["Response must be a JSON object"] + + if "error" in data: + return [] + + if "action" not in data: + errors.append("Missing required field: action") + + action = data.get("action") + + if action == "add" and "item_name" not in data: + errors.append("Missing required field for add: item_name") + + return errors + + +# Register the module +register_module("example", handle_example) + +# Register the validator +ai_parser.register_validator("example", validate_example_json) diff --git a/bot/commands/medications.py b/bot/commands/medications.py new file mode 100644 index 0000000..98e45aa --- /dev/null +++ b/bot/commands/medications.py @@ -0,0 +1,30 @@ +""" +Medications command handler - bot-side hooks for medication management +""" + +from bot.command_registry import register_module +import ai.parser as ai_parser + + +async def handle_medication(message, session, parsed): + action = parsed.get("action", "unknown") + token = session["token"] + user_uuid = session["user_uuid"] + + # TODO: wire up API calls per action + pass + + +def validate_medication_json(data): + errors = [] + if not isinstance(data, dict): + return ["Response must be a JSON object"] + if "error" in data: + return [] + if "action" not in data: + errors.append("Missing required field: action") + return errors + + +register_module("medication", handle_medication) +ai_parser.register_validator("medication", validate_medication_json) diff --git a/bot/commands/routines.py b/bot/commands/routines.py new file mode 100644 index 0000000..4ef294f --- /dev/null +++ b/bot/commands/routines.py @@ -0,0 +1,30 @@ +""" +Routines command handler - bot-side hooks for routine management +""" + +from bot.command_registry import register_module +import ai.parser as ai_parser + + +async def handle_routine(message, session, parsed): + action = parsed.get("action", "unknown") + token = session["token"] + user_uuid = session["user_uuid"] + + # TODO: wire up API calls per action + pass + + +def validate_routine_json(data): + errors = [] + if not isinstance(data, dict): + return ["Response must be a JSON object"] + if "error" in data: + return [] + if "action" not in data: + errors.append("Missing required field: action") + return errors + + +register_module("routine", handle_routine) +ai_parser.register_validator("routine", validate_routine_json) diff --git a/core/__pycache__/auth.cpython-312.pyc b/core/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..8b9ce87 Binary files /dev/null and b/core/__pycache__/auth.cpython-312.pyc differ diff --git a/core/__pycache__/notifications.cpython-312.pyc b/core/__pycache__/notifications.cpython-312.pyc new file mode 100644 index 0000000..75ee534 Binary files /dev/null and b/core/__pycache__/notifications.cpython-312.pyc differ diff --git a/core/__pycache__/postgres.cpython-312.pyc b/core/__pycache__/postgres.cpython-312.pyc new file mode 100644 index 0000000..afa7498 Binary files /dev/null and b/core/__pycache__/postgres.cpython-312.pyc differ diff --git a/core/__pycache__/users.cpython-312.pyc b/core/__pycache__/users.cpython-312.pyc new file mode 100644 index 0000000..4b07d03 Binary files /dev/null and b/core/__pycache__/users.cpython-312.pyc differ diff --git a/core/auth.py b/core/auth.py new file mode 100644 index 0000000..3f40bae --- /dev/null +++ b/core/auth.py @@ -0,0 +1,58 @@ +import core.users as users +import core.postgres as postgres +import bcrypt +import jwt +from jwt.exceptions import ExpiredSignatureError, InvalidTokenError +import datetime +import os + + +def verifyLoginToken(login_token, username=False, userUUID=False): + if username: + userUUID = users.getUserUUID(username) + + if userUUID: + try: + decoded_token = jwt.decode( + login_token, os.getenv("JWT_SECRET"), algorithms=["HS256"] + ) + if decoded_token.get("sub") == str(userUUID): + return True + return False + except (ExpiredSignatureError, InvalidTokenError): + return False + return False + + +def getUserpasswordHash(userUUID): + user = postgres.select_one("users", {"id": userUUID}) + if user: + pw_hash = user.get("password_hashed") + if isinstance(pw_hash, memoryview): + return bytes(pw_hash) + return pw_hash + return None + + +def getLoginToken(username, password): + userUUID = users.getUserUUID(username) + if userUUID: + formatted_pass = password.encode("utf-8") + users_hashed_pw = getUserpasswordHash(userUUID) + if bcrypt.checkpw(formatted_pass, users_hashed_pw): + payload = { + "sub": userUUID, + "name": users.getUserFirstName(userUUID), + "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1), + } + return jwt.encode(payload, os.getenv("JWT_SECRET"), algorithm="HS256") + return False + + +def unregisterUser(userUUID, password): + pw_hash = getUserpasswordHash(userUUID) + if not pw_hash: + return False + if bcrypt.checkpw(password.encode("utf-8"), pw_hash): + return users.deleteUser(userUUID) + return False diff --git a/core/notifications.py b/core/notifications.py new file mode 100644 index 0000000..91d0ede --- /dev/null +++ b/core/notifications.py @@ -0,0 +1,74 @@ +""" +notifications.py - Multi-channel notification routing + +Supported channels: Discord webhook, ntfy +""" + +import core.postgres as postgres +import uuid +import requests +import time + + +def _sendToEnabledChannels(notif_settings, message): + """Send message to all enabled channels. Returns True if at least one succeeded.""" + sent = False + + if notif_settings.get("discord_enabled") and notif_settings.get("discord_webhook"): + if discord.send(notif_settings["discord_webhook"], message): + sent = True + + if notif_settings.get("ntfy_enabled") and notif_settings.get("ntfy_topic"): + if ntfy.send(notif_settings["ntfy_topic"], message): + sent = True + + return sent + + +def getNotificationSettings(userUUID): + settings = postgres.select_one("notifications", {"user_uuid": userUUID}) + if not settings: + return False + return settings + + +def setNotificationSettings(userUUID, data_dict): + existing = postgres.select_one("notifications", {"user_uuid": userUUID}) + allowed = [ + "discord_webhook", + "discord_enabled", + "ntfy_topic", + "ntfy_enabled", + ] + updates = {k: v for k, v in data_dict.items() if k in allowed} + if not updates: + return False + if existing: + postgres.update("notifications", updates, {"user_uuid": userUUID}) + else: + updates["id"] = str(uuid.uuid4()) + updates["user_uuid"] = userUUID + postgres.insert("notifications", updates) + return True + + +class discord: + @staticmethod + def send(webhook_url, message): + try: + response = requests.post(webhook_url, json={"content": message}) + return response.status_code == 204 + except: + return False + + +class ntfy: + @staticmethod + def send(topic, message): + try: + response = requests.post( + f"https://ntfy.sh/{topic}", data=message.encode("utf-8") + ) + return response.status_code == 200 + except: + return False diff --git a/core/postgres.py b/core/postgres.py new file mode 100644 index 0000000..0f8be50 --- /dev/null +++ b/core/postgres.py @@ -0,0 +1,264 @@ +""" +postgres.py - Generic PostgreSQL CRUD module + +Requires: pip install psycopg2-binary + +Connection config from environment: + DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS +""" + +import os +import re +import psycopg2 +import psycopg2.extras +from contextlib import contextmanager + + +def _get_config(): + return { + "host": os.environ.get("DB_HOST", "localhost"), + "port": int(os.environ.get("DB_PORT", 5432)), + "dbname": os.environ.get("DB_NAME", "app"), + "user": os.environ.get("DB_USER", "app"), + "password": os.environ.get("DB_PASS", ""), + } + + +def _safe_id(name): + if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name): + raise ValueError(f"Invalid SQL identifier: {name}") + return f'"{name}"' + + +def _build_where(where, prefix=""): + clauses = [] + params = {} + for i, (col, val) in enumerate(where.items()): + param_name = f"{prefix}{col}_{i}" + safe_col = _safe_id(col) + + if isinstance(val, tuple) and len(val) == 2: + op, operand = val + op = op.upper() + allowed = { + "=", + "!=", + "<", + ">", + "<=", + ">=", + "LIKE", + "ILIKE", + "IN", + "IS", + "IS NOT", + } + if op not in allowed: + raise ValueError(f"Unsupported operator: {op}") + if op == "IN": + ph = ", ".join(f"%({param_name}_{j})s" for j in range(len(operand))) + clauses.append(f"{safe_col} IN ({ph})") + for j, item in enumerate(operand): + params[f"{param_name}_{j}"] = item + elif op in ("IS", "IS NOT"): + clauses.append(f"{safe_col} {op} NULL") + else: + clauses.append(f"{safe_col} {op} %({param_name})s") + params[param_name] = operand + elif val is None: + clauses.append(f"{safe_col} IS NULL") + else: + clauses.append(f"{safe_col} = %({param_name})s") + params[param_name] = val + + return " AND ".join(clauses), params + + +@contextmanager +def get_connection(): + conn = psycopg2.connect(**_get_config()) + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +@contextmanager +def get_cursor(dict_cursor=True): + with get_connection() as conn: + factory = psycopg2.extras.RealDictCursor if dict_cursor else None + cur = conn.cursor(cursor_factory=factory) + try: + yield cur + finally: + cur.close() + + +def insert(table, data): + columns = list(data.keys()) + placeholders = [f"%({col})s" for col in columns] + safe_cols = [_safe_id(c) for c in columns] + + query = f""" + INSERT INTO {_safe_id(table)} + ({", ".join(safe_cols)}) + VALUES ({", ".join(placeholders)}) + RETURNING * + """ + with get_cursor() as cur: + cur.execute(query, data) + return dict(cur.fetchone()) if cur.rowcount else None + + +def select(table, where=None, order_by=None, limit=None, offset=None): + query = f"SELECT * FROM {_safe_id(table)}" + params = {} + + if where: + clauses, params = _build_where(where) + query += f" WHERE {clauses}" + if order_by: + if isinstance(order_by, list): + order_by = ", ".join(order_by) + query += f" ORDER BY {order_by}" + if limit is not None: + query += f" LIMIT {int(limit)}" + if offset is not None: + query += f" OFFSET {int(offset)}" + + with get_cursor() as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + +def select_one(table, where): + results = select(table, where=where, limit=1) + return results[0] if results else None + + +def update(table, data, where): + set_columns = list(data.keys()) + set_clause = ", ".join(f"{_safe_id(col)} = %(set_{col})s" for col in set_columns) + params = {f"set_{col}": val for col, val in data.items()} + + where_clause, where_params = _build_where(where, prefix="where_") + params.update(where_params) + + query = f""" + UPDATE {_safe_id(table)} + SET {set_clause} + WHERE {where_clause} + RETURNING * + """ + with get_cursor() as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + +def delete(table, where): + where_clause, params = _build_where(where) + query = f""" + DELETE FROM {_safe_id(table)} + WHERE {where_clause} + RETURNING * + """ + with get_cursor() as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + +def count(table, where=None): + query = f"SELECT COUNT(*) as count FROM {_safe_id(table)}" + params = {} + if where: + clauses, params = _build_where(where) + query += f" WHERE {clauses}" + with get_cursor() as cur: + cur.execute(query, params) + return cur.fetchone()["count"] + + +def exists(table, where): + return count(table, where) > 0 + + +def upsert(table, data, conflict_columns): + columns = list(data.keys()) + placeholders = [f"%({col})s" for col in columns] + safe_cols = [_safe_id(c) for c in columns] + conflict_cols = [_safe_id(c) for c in conflict_columns] + + update_cols = [c for c in columns if c not in conflict_columns] + update_clause = ", ".join( + f"{_safe_id(c)} = EXCLUDED.{_safe_id(c)}" for c in update_cols + ) + + query = f""" + INSERT INTO {_safe_id(table)} + ({", ".join(safe_cols)}) + VALUES ({", ".join(placeholders)}) + ON CONFLICT ({", ".join(conflict_cols)}) + DO UPDATE SET {update_clause} + RETURNING * + """ + with get_cursor() as cur: + cur.execute(query, data) + return dict(cur.fetchone()) if cur.rowcount else None + + +def insert_many(table, rows): + if not rows: + return 0 + columns = list(rows[0].keys()) + safe_cols = [_safe_id(c) for c in columns] + query = f""" + INSERT INTO {_safe_id(table)} + ({", ".join(safe_cols)}) + VALUES %s + """ + template = f"({', '.join(f'%({col})s' for col in columns)})" + with get_cursor() as cur: + psycopg2.extras.execute_values( + cur, query, rows, template=template, page_size=100 + ) + return cur.rowcount + + +def execute(query, params=None): + with get_cursor() as cur: + cur.execute(query, params or {}) + if cur.description: + return [dict(row) for row in cur.fetchall()] + return cur.rowcount + + +def table_exists(table): + with get_cursor() as cur: + cur.execute( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = %(table)s + ) + """, + {"table": table}, + ) + return cur.fetchone()["exists"] + + +def get_columns(table): + with get_cursor() as cur: + cur.execute( + """ + SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = %(table)s + ORDER BY ordinal_position + """, + {"table": table}, + ) + return [dict(row) for row in cur.fetchall()] diff --git a/core/users.py b/core/users.py new file mode 100644 index 0000000..e267fa2 --- /dev/null +++ b/core/users.py @@ -0,0 +1,96 @@ +import uuid +import core.postgres as postgres +import bcrypt + + +def getUserUUID(username): + userRecord = postgres.select_one("users", {"username": username}) + if userRecord: + return userRecord["id"] + return False + + +def getUserFirstName(userUUID): + userRecord = postgres.select_one("users", {"id": userUUID}) + if userRecord: + return userRecord.get("username") + return None + + +def isUsernameAvailable(username): + return not postgres.exists("users", {"username": username}) + + +def doesUserUUIDExist(userUUID): + return postgres.exists("users", {"id": userUUID}) + + +def registerUser(username, password, data=None): + if isUsernameAvailable(username): + hashed_pass = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + user_data = { + "id": str(uuid.uuid4()), + "username": username, + "password_hashed": hashed_pass, + } + if data: + user_data.update(data) + createUser(user_data) + return True + return False + + +def updateUser(userUUID, data_dict): + user = postgres.select_one("users", {"id": userUUID}) + if not user: + return False + blocked = {"id", "password_hashed", "created_at"} + allowed = set(user.keys()) - blocked + updates = {k: v for k, v in data_dict.items() if k in allowed} + if not updates: + return False + postgres.update("users", updates, {"id": userUUID}) + return True + + +def changePassword(userUUID, new_password): + user = postgres.select_one("users", {"id": userUUID}) + if not user: + return False + hashed = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()) + postgres.update("users", {"password_hashed": hashed}, {"id": userUUID}) + return True + + +def deleteUser(userUUID): + user = postgres.select_one("users", {"id": userUUID}) + if not user: + return False + postgres.delete("users", {"id": userUUID}) + return True + + +def createUser(data_dict): + user_schema = { + "id": None, + "username": None, + "password_hashed": None, + "created_at": None, + } + for key in user_schema: + if key in data_dict: + user_schema[key] = data_dict[key] + + is_valid, errors = validateUser(user_schema) + if not is_valid: + raise ValueError(f"Invalid user data: {', '.join(errors)}") + + postgres.insert("users", user_schema) + + +def validateUser(user): + required = ["id", "username", "password_hashed"] + missing = [f for f in required if f not in user or user[f] is None] + if missing: + return False, missing + return True, [] diff --git a/diagrams/README.md b/diagrams/README.md new file mode 100644 index 0000000..08e9544 --- /dev/null +++ b/diagrams/README.md @@ -0,0 +1,12 @@ +# LLM Bot Framework - Mermaid Diagrams + +## Diagrams + +| File | Description | +|------|-------------| +| `system.mmd` | Architecture: services, modules, and data flow | +| `flow.mmd` | Sequence: user command from input to response | + +## Render + +Paste into https://mermaid.live/ or use VS Code "Mermaid" extension. diff --git a/diagrams/flow.mmd b/diagrams/flow.mmd new file mode 100644 index 0000000..acbdd47 --- /dev/null +++ b/diagrams/flow.mmd @@ -0,0 +1,15 @@ +sequenceDiagram + participant U as User + participant B as Bot + participant L as LLM + participant A as API + participant D as DB + + U->>B: DM "add task buy groceries" + B->>L: parse message + L-->>B: {type: "task", action: "add", name: "buy groceries"} + B->>A: POST /api/tasks + A->>D: INSERT + D-->>A: {id, name, created_at} + A-->>B: 201 Created + B-->>U: "Added task: buy groceries" diff --git a/diagrams/system.mmd b/diagrams/system.mmd new file mode 100644 index 0000000..245124a --- /dev/null +++ b/diagrams/system.mmd @@ -0,0 +1,61 @@ +flowchart TB + subgraph External + USER([User]) + DISCORD([Discord API]) + LLM([OpenRouter]) + NTFY([ntfy.sh]) + end + + subgraph Bot["bot/"] + CLIENT[bot.py] + REGISTRY[command_registry.py] + COMMANDS[commands/] + end + + subgraph API["api/"] + FLASK[main.py] + ROUTES[routes/] + end + + subgraph Scheduler["scheduler/"] + DAEMON[daemon.py] + end + + subgraph Core["core/"] + AUTH[auth.py] + USERS[users.py] + PG[postgres.py] + NOTIF[notifications.py] + end + + subgraph AI["ai/"] + PARSER[parser.py] + CONFIG[ai_config.json] + end + + subgraph DB["db service"] + POSTGRES[(PostgreSQL)] + end + + USER <-->|"DM"| DISCORD + DISCORD <-->|"events"| CLIENT + CLIENT -->|"parse"| PARSER + PARSER -->|"completion"| LLM + LLM -->|"JSON"| PARSER + PARSER -->|"structured data"| CLIENT + CLIENT -->|"get_handler"| REGISTRY + REGISTRY -->|"handler"| COMMANDS + COMMANDS -->|"logic"| CLIENT + CLIENT -->|"HTTP"| FLASK + FLASK --> ROUTES + FLASK --> AUTH + FLASK --> USERS + FLASK --> PG + AUTH --> USERS + USERS --> PG + PG --> POSTGRES + DAEMON --> PG + DAEMON --> NOTIF + NOTIF -->|"webhook"| DISCORD + NOTIF -->|"push"| NTFY + PARSER --> CONFIG diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c2552c7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: app + POSTGRES_USER: app + POSTGRES_PASSWORD: ${DB_PASS} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./config/schema.sql:/docker-entrypoint-initdb.d/schema.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: . + ports: + - "8080:5000" + env_file: config/.env + depends_on: + db: + condition: service_healthy + + scheduler: + build: . + command: ["python", "-m", "scheduler.daemon"] + env_file: config/.env + depends_on: + db: + condition: service_healthy + + bot: + build: . + command: ["python", "-m", "bot.bot"] + env_file: config/.env + depends_on: + app: + condition: service_started + +volumes: + pgdata: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0761643 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +flask>=3.0.0 +psycopg2-binary>=2.9.0 +bcrypt>=4.1.0 +PyJWT>=2.8.0 +discord.py>=2.3.0 +openai>=1.0.0 +requests>=2.31.0 diff --git a/scheduler/__pycache__/daemon.cpython-312.pyc b/scheduler/__pycache__/daemon.cpython-312.pyc new file mode 100644 index 0000000..e44803e Binary files /dev/null and b/scheduler/__pycache__/daemon.cpython-312.pyc differ diff --git a/scheduler/daemon.py b/scheduler/daemon.py new file mode 100644 index 0000000..adf8332 --- /dev/null +++ b/scheduler/daemon.py @@ -0,0 +1,57 @@ +""" +daemon.py - Background polling loop for scheduled tasks + +Override poll_callback() with your domain-specific logic. +""" + +import os +import time +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60)) + + +def check_medication_reminders(): + """Check for medications due now and send notifications.""" + # TODO: query medications table for doses due within the poll window + # TODO: cross-ref med_logs to skip already-taken doses + # TODO: send via core.notifications._sendToEnabledChannels() + pass + + +def check_routine_reminders(): + """Check for scheduled routines due now and send notifications.""" + # TODO: query routine_schedules for routines due within the poll window + # TODO: send via core.notifications._sendToEnabledChannels() + pass + + +def check_refills(): + """Check for medications running low on refills.""" + # TODO: query medications where quantity_remaining is low + # TODO: send refill reminder via notifications + pass + + +def poll_callback(): + """Called every POLL_INTERVAL seconds.""" + check_medication_reminders() + check_routine_reminders() + check_refills() + + +def daemon_loop(): + logger.info("Scheduler daemon starting") + while True: + try: + poll_callback() + except Exception as e: + logger.error(f"Poll callback error: {e}") + time.sleep(POLL_INTERVAL) + + +if __name__ == "__main__": + daemon_loop()