first commit

This commit is contained in:
2026-02-12 22:11:52 -06:00
commit 25d05e0e86
37 changed files with 4492 additions and 0 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
DB_PASS=y8Khu7pJQZq6ywFDIJiqpx4zYmclHGHw

2342
DOCUMENTATION.md Normal file

File diff suppressed because it is too large Load Diff

10
Dockerfile Normal file
View File

@@ -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"]

44
HOOKS.md Normal file
View File

@@ -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

251
README.md Normal file
View File

@@ -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

Binary file not shown.

15
ai/ai_config.json Normal file
View File

@@ -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": {}
}
}

151
ai/parser.py Normal file
View File

@@ -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

Binary file not shown.

139
api/main.py Normal file
View File

@@ -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/<username>", 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/<userUUID>", 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/<userUUID>", 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/<userUUID>", 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)

Binary file not shown.

56
api/routes/example.py Normal file
View File

@@ -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

160
api/routes/medications.py Normal file
View File

@@ -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/<med_id>", 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/<med_id>", 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/<med_id>", 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/<med_id>/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/<med_id>/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/<med_id>/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/<med_id>/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/<med_id>/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/<med_id>/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

204
api/routes/routines.py Normal file
View File

@@ -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/<routine_id>", 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/<routine_id>", 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/<routine_id>", 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/<routine_id>/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/<routine_id>/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/<routine_id>/steps/<step_id>", 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/<routine_id>/steps/<step_id>", 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/<routine_id>/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/<routine_id>/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/<session_id>/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/<session_id>/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/<session_id>/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/<routine_id>/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/<routine_id>/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/<routine_id>/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/<routine_id>/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

Binary file not shown.

Binary file not shown.

272
bot/bot.py Normal file
View File

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

35
bot/command_registry.py Normal file
View File

@@ -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())

Binary file not shown.

63
bot/commands/example.py Normal file
View File

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

View File

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

30
bot/commands/routines.py Normal file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

58
core/auth.py Normal file
View File

@@ -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

74
core/notifications.py Normal file
View File

@@ -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

264
core/postgres.py Normal file
View File

@@ -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()]

96
core/users.py Normal file
View File

@@ -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, []

12
diagrams/README.md Normal file
View File

@@ -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.

15
diagrams/flow.mmd Normal file
View File

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

61
diagrams/system.mmd Normal file
View File

@@ -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

45
docker-compose.yml Normal file
View File

@@ -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:

7
requirements.txt Normal file
View File

@@ -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

Binary file not shown.

57
scheduler/daemon.py Normal file
View File

@@ -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()