From 11c4ff5cb7d94f1c32d980c3b8aa20b09fad9a8a Mon Sep 17 00:00:00 2001 From: chelsea Date: Thu, 12 Feb 2026 17:48:00 -0600 Subject: [PATCH] Initial commit --- Dockerfile | 10 ++ ai/ai_config.json | 15 +++ ai/parser.py | 151 ++++++++++++++++++++++ api/main.py | 137 ++++++++++++++++++++ api/routes/example.py | 50 ++++++++ bot/bot.py | 272 ++++++++++++++++++++++++++++++++++++++++ bot/command_registry.py | 35 ++++++ bot/commands/example.py | 68 ++++++++++ config/.env.example | 20 +++ config/schema.sql | 30 +++++ core/auth.py | 58 +++++++++ core/notifications.py | 74 +++++++++++ core/postgres.py | 264 ++++++++++++++++++++++++++++++++++++++ core/users.py | 96 ++++++++++++++ docker-compose.yml | 45 +++++++ requirements.txt | 7 ++ scheduler/daemon.py | 37 ++++++ 17 files changed, 1369 insertions(+) create mode 100644 Dockerfile create mode 100644 ai/ai_config.json create mode 100644 ai/parser.py create mode 100644 api/main.py create mode 100644 api/routes/example.py create mode 100644 bot/bot.py create mode 100644 bot/command_registry.py create mode 100644 bot/commands/example.py create mode 100644 config/.env.example create mode 100644 config/schema.sql create mode 100644 core/auth.py create mode 100644 core/notifications.py create mode 100644 core/postgres.py create mode 100644 core/users.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 scheduler/daemon.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b0dde83 --- /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", "api/main.py"] diff --git a/ai/ai_config.json b/ai/ai_config.json new file mode 100644 index 0000000..f172e6b --- /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.", + "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\": \"string\",\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/main.py b/api/main.py new file mode 100644 index 0000000..501e2f6 --- /dev/null +++ b/api/main.py @@ -0,0 +1,137 @@ +""" +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 + +app = flask.Flask(__name__) + +ROUTE_MODULES = [] + + +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/example.py b/api/routes/example.py new file mode 100644 index 0000000..dd896c2 --- /dev/null +++ b/api/routes/example.py @@ -0,0 +1,50 @@ +""" +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 flask +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import core.auth as auth +import core.postgres as postgres + + +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:] + + decoded = auth.verifyLoginToken(token, userUUID=True) + if decoded != True: + 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:] + + decoded = auth.verifyLoginToken(token, userUUID=True) + if decoded != True: + return flask.jsonify({"error": "unauthorized"}), 401 + + data = flask.request.get_json() + item = postgres.insert("examples", data) + return flask.jsonify(item), 201 diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 0000000..5ef6dab --- /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 + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from bot.command_registry import get_handler, list_registered +import ai.parser as ai_parser + +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/example.py b/bot/commands/example.py new file mode 100644 index 0000000..87e20c0 --- /dev/null +++ b/bot/commands/example.py @@ -0,0 +1,68 @@ +""" +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 +""" + +import sys +import os + +sys.path.insert(0, 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_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/config/.env.example b/config/.env.example new file mode 100644 index 0000000..7db5999 --- /dev/null +++ b/config/.env.example @@ -0,0 +1,20 @@ +# 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 diff --git a/config/schema.sql b/config/schema.sql new file mode 100644 index 0000000..f925e0e --- /dev/null +++ b/config/schema.sql @@ -0,0 +1,30 @@ +-- 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 +); + +-- Add your domain tables below +-- Example: +-- CREATE TABLE IF NOT EXISTS examples ( +-- id UUID PRIMARY KEY, +-- user_uuid UUID REFERENCES users(id) ON DELETE CASCADE, +-- name VARCHAR(255) NOT NULL, +-- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +-- ); diff --git a/core/auth.py b/core/auth.py new file mode 100644 index 0000000..60cef1f --- /dev/null +++ b/core/auth.py @@ -0,0 +1,58 @@ +import users +import 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: + return 401 + return 401 + + +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..e6dc581 --- /dev/null +++ b/core/notifications.py @@ -0,0 +1,74 @@ +""" +notifications.py - Multi-channel notification routing + +Supported channels: Discord webhook, ntfy +""" + +import 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..6032445 --- /dev/null +++ b/core/users.py @@ -0,0 +1,96 @@ +import uuid +import 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6a96754 --- /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: + - "5000:5000" + env_file: config/.env + depends_on: + db: + condition: service_healthy + + scheduler: + build: . + command: ["python", "scheduler/daemon.py"] + env_file: config/.env + depends_on: + db: + condition: service_healthy + + bot: + build: . + command: ["python", "bot/bot.py"] + 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/daemon.py b/scheduler/daemon.py new file mode 100644 index 0000000..6644e5f --- /dev/null +++ b/scheduler/daemon.py @@ -0,0 +1,37 @@ +""" +daemon.py - Background polling loop for scheduled tasks + +Override poll_callback() with your domain-specific logic. +""" + +import time +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +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) + + +if __name__ == "__main__": + import os + + daemon_loop()