first commit
This commit is contained in:
2342
DOCUMENTATION.md
Normal file
2342
DOCUMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
10
Dockerfile
Normal file
10
Dockerfile
Normal 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
44
HOOKS.md
Normal 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
251
README.md
Normal 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
|
||||||
BIN
ai/__pycache__/parser.cpython-312.pyc
Normal file
BIN
ai/__pycache__/parser.cpython-312.pyc
Normal file
Binary file not shown.
15
ai/ai_config.json
Normal file
15
ai/ai_config.json
Normal 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
151
ai/parser.py
Normal 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
|
||||||
BIN
api/__pycache__/main.cpython-312.pyc
Normal file
BIN
api/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
139
api/main.py
Normal file
139
api/main.py
Normal 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)
|
||||||
BIN
api/routes/__pycache__/example.cpython-312.pyc
Normal file
BIN
api/routes/__pycache__/example.cpython-312.pyc
Normal file
Binary file not shown.
56
api/routes/example.py
Normal file
56
api/routes/example.py
Normal 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
160
api/routes/medications.py
Normal 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
204
api/routes/routines.py
Normal 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
|
||||||
BIN
bot/__pycache__/bot.cpython-312.pyc
Normal file
BIN
bot/__pycache__/bot.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/command_registry.cpython-312.pyc
Normal file
BIN
bot/__pycache__/command_registry.cpython-312.pyc
Normal file
Binary file not shown.
272
bot/bot.py
Normal file
272
bot/bot.py
Normal 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
35
bot/command_registry.py
Normal 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())
|
||||||
BIN
bot/commands/__pycache__/example.cpython-312.pyc
Normal file
BIN
bot/commands/__pycache__/example.cpython-312.pyc
Normal file
Binary file not shown.
63
bot/commands/example.py
Normal file
63
bot/commands/example.py
Normal 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)
|
||||||
30
bot/commands/medications.py
Normal file
30
bot/commands/medications.py
Normal 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
30
bot/commands/routines.py
Normal 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)
|
||||||
BIN
core/__pycache__/auth.cpython-312.pyc
Normal file
BIN
core/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/notifications.cpython-312.pyc
Normal file
BIN
core/__pycache__/notifications.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/postgres.cpython-312.pyc
Normal file
BIN
core/__pycache__/postgres.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/users.cpython-312.pyc
Normal file
BIN
core/__pycache__/users.cpython-312.pyc
Normal file
Binary file not shown.
58
core/auth.py
Normal file
58
core/auth.py
Normal 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
74
core/notifications.py
Normal 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
264
core/postgres.py
Normal 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
96
core/users.py
Normal 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
12
diagrams/README.md
Normal 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
15
diagrams/flow.mmd
Normal 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
61
diagrams/system.mmd
Normal 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
45
docker-compose.yml
Normal 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
7
requirements.txt
Normal 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
|
||||||
BIN
scheduler/__pycache__/daemon.cpython-312.pyc
Normal file
BIN
scheduler/__pycache__/daemon.cpython-312.pyc
Normal file
Binary file not shown.
57
scheduler/daemon.py
Normal file
57
scheduler/daemon.py
Normal 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()
|
||||||
Reference in New Issue
Block a user