Files
Lamont/README.md
2026-07-19 21:46:40 -05:00

384 lines
14 KiB
Markdown

# LLM Bot Framework
A small, cloneable Python 3.11 framework for Discord bots that turn natural
language into validated feature commands. Flask is the application boundary,
PostgreSQL stores users and durable work, and feature packages keep their bot
handler, API routes, prompt, migrations, and scheduled jobs together.
The included reminders package is both usable and a reference implementation.
## What is included
- Discord DM adapter with passwordless provider identities
- Two-stage, OpenAI-compatible command routing and parsing
- Deterministic feature discovery through `modules/*/register(registry)`
- Flask API with short-lived JWTs, user API keys, and scoped service keys
- Transactional PostgreSQL migrations and a small parameterized SQL layer
- Lease-based scheduled jobs and a durable outbound-message queue
- One-time, daily, and weekly reminders with IANA timezone support
- Docker Compose, focused Ruff checks, pytest coverage, and CI
## Configuration and credential safety
On a fresh checkout, copy the root template only if `.env` does not already
exist. All processes load this root file.
```powershell
if (-not (Test-Path .env)) { Copy-Item .env.example .env }
```
For an existing installation, do not overwrite either environment file.
Manually merge the needed values from a legacy `config/.env` into the root
`.env`, verify the application with the root file, rotate any live or
previously shared credentials, and only then retire the legacy copy.
Replace every placeholder before exposing the service. The real `.env` is
ignored by Git and Docker and must stay local. Never commit it or bake it into
an image.
If a populated environment file or image has ever been shared, assume its
values are exposed. Rotate the Discord token, database password, JWT secret,
bot service key, and model-provider key before deployment. Updating `.gitignore`
does not remove secrets from existing history.
Important settings:
| Variable | Purpose |
| --- | --- |
| `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | PostgreSQL connection |
| `JWT_SECRET` | Signs short-lived user sessions |
| `BOT_API_KEY` | Discord adapter service key; use at least 32 random characters |
| `BOT_API_KEY_SCOPES` | Normally `discord:session,outbox:claim,outbox:deliver` |
| `DISCORD_BOT_TOKEN` | Discord application token |
| `DISCORD_ENROLLMENT_MODE` | `allowlist` by default, or explicit `open` enrollment |
| `DISCORD_ALLOWLIST` | Comma- or whitespace-separated Discord user IDs |
| `API_URL` | API URL used by the bot; Compose default is `http://app:5000` |
| `OPENROUTER_API_KEY`, `OPENROUTER_BASE_URL` | OpenAI-compatible provider access |
| `AI_MODEL`, `AI_CONFIG_PATH` | Optional model and parser-config overrides |
| `DEFAULT_TIMEZONE` | IANA timezone for new users; defaults to `UTC` |
| `JOB_*`, `OUTBOX_*` | Worker polling, batch, and lease settings |
Generate secrets with a password manager or a cryptographic random generator.
The API registers `BOT_API_KEY` as a hashed service key on its first non-live
request; the Discord process must use the same raw value.
## Run with Docker
After configuring `.env`, start the complete stack:
```bash
docker compose up --build
```
The API is available at `http://localhost:8080`. The `migrate` one-shot service
must finish before the API, scheduler, and bot start. To work without Discord or
a live model provider, start only the database, migrations, API, and scheduler:
```bash
docker compose up --build db migrate app scheduler
```
Useful checks:
```bash
curl http://localhost:8080/health/live
curl http://localhost:8080/health/ready
docker compose logs -f app scheduler bot
```
`/health/live` only confirms that the API process is responding.
`/health/ready` also checks PostgreSQL.
## Run locally
Create a Python 3.11 virtual environment and install development dependencies:
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements-dev.txt
```
Install or connect to PostgreSQL 16, create the database/user named in `.env`,
and make sure it accepts connections at `DB_HOST` and `DB_PORT`. The example
uses `DB_HOST=127.0.0.1` for local development; Compose overrides that value
inside its containers. On PowerShell, check that the configured port responds:
```powershell
Test-NetConnection 127.0.0.1 -Port 5432
```
The Windows PostgreSQL installer includes pgAdmin, which can create the `app`
login role and `app` database from the example configuration. If `psql` is on
PATH, the equivalent commands are:
```powershell
psql -U postgres -c "CREATE ROLE app LOGIN PASSWORD 'replace-with-a-database-password';"
psql -U postgres -c "CREATE DATABASE app OWNER app;"
```
Use a different password in both `.env` and the role command. Then run each
process in its own terminal:
```bash
python -m core.migrations upgrade
python -m api.main
python -m scheduler.daemon
python -m bot.bot
```
The local API command uses Flask's development server. The Docker image uses
Gunicorn.
## Database migrations
Core migrations live in `config/migrations`; each feature may add numbered SQL
files under `modules/<name>/migrations`. Core migrations run first, followed by
feature namespaces in alphabetical and numeric order.
```bash
python -m core.migrations status
python -m core.migrations upgrade
```
Upgrades run transactionally under a PostgreSQL advisory lock. Applied
migrations are recorded with checksums. Do not edit an applied migration: a
changed checksum stops startup. Add the next numbered migration instead.
Back up an existing database before the first upgrade. Legacy password users
remain supported. To link one safely to Discord after migrating:
```bash
python -m core.manage link-discord --discord-id 123456789012345678 --username alice
python -m core.manage link-discord --discord-id 123456789012345678 --user-uuid 2d36fc2b-5145-4fca-a677-858ca6a36e2d
```
Choose exactly one of `--username` or `--user-uuid`. The command refuses to
move a Discord identity that is already linked to a different user.
## Authentication
The HTTP examples below use Compose's `http://localhost:8080`. For a locally
started Flask process, use `http://localhost:5000` instead.
There are three authentication paths:
- A password user registers and logs in for a short-lived JWT.
- That JWT can create, list, and revoke user API keys. The full key is returned
only at creation; only its prefix and hash are stored.
- The Discord adapter uses the scoped `BOT_API_KEY` to exchange a stable
Discord ID for a short-lived user JWT. It never receives a user's password.
Register and log in:
```bash
curl -X POST http://localhost:8080/api/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"replace-this-password","timezone":"America/Chicago"}'
curl -X POST http://localhost:8080/api/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"replace-this-password"}'
```
Use the returned JWT as `JWT_TOKEN` to create a long-lived user key:
```bash
curl -X POST http://localhost:8080/api/keys \
-H "Authorization: Bearer JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"local-cli"}'
```
Save the response's `key` immediately; it is shown once. A user API key acts as
its owner for user routes, but cannot manage API keys or delete the account.
```bash
curl http://localhost:8080/api/reminders \
-H "Authorization: Bearer USER_API_KEY"
curl http://localhost:8080/api/keys \
-H "Authorization: Bearer JWT_TOKEN"
curl -X DELETE http://localhost:8080/api/keys/KEY_UUID \
-H "Authorization: Bearer JWT_TOKEN"
```
Revocation takes effect on the next request. Optional `expires_at` values use
ISO-8601 timestamps.
### Discord enrollment
Enrollment is closed by default. Add stable Discord user IDs to the allowlist:
```dotenv
DISCORD_ENROLLMENT_MODE=allowlist
DISCORD_ALLOWLIST=123456789012345678,234567890123456789
```
To let any Discord user create a provider-backed account explicitly set:
```dotenv
DISCORD_ENROLLMENT_MODE=open
```
Display names are metadata only. Existing accounts are never matched
automatically by a mutable Discord display name; use `core.manage link-discord`
when joining a legacy password account.
## Feature modules
Every immediate, non-private package under `modules/` must export
`register(registry)`. Packages load alphabetically. Startup fails on malformed
registration, duplicate module names, duplicate command types, or duplicate job
types, so configuration errors surface early.
A typical package looks like this:
```text
modules/tasks/
__init__.py
commands.py
prompts.py
routes.py
service.py
migrations/
0001_tasks.sql
```
Its `__init__.py` owns registration:
```python
from modules.tasks.commands import handleTask, validateTask
from modules.tasks.prompts import TASK_PROMPT
from modules.tasks.routes import registerRoutes
from modules.tasks.service import runFollowUp
def register(registry):
registry.describe("Create and manage tasks")
registry.register_command(
"task",
handleTask,
prompt=TASK_PROMPT,
validator=validateTask,
description="Create, list, and complete tasks",
help_text=["add buy milk to my tasks", "list my tasks"],
)
registry.register_routes(registerRoutes)
registry.register_job("tasks.follow_up", runFollowUp)
```
`register_routes` and `register_job` are optional. A command handler keeps a
small async interface:
```python
async def handleTask(context, parsed):
result, status = await context.api.request("get", "/api/tasks")
if status == 200:
await context.reply(f"You have {len(result['tasks'])} tasks.")
```
`CommandContext` exposes `user_uuid`, `discord_user_id`, `timezone`, an async
authenticated `api` client, and `await reply(content)`. The API client's
`request(method, endpoint, data=None, params=None)` returns `(JSON body, HTTP
status)` and refreshes the Discord-issued JWT once after a 401. This keeps
feature handlers independent of discord.py message objects.
The parser first routes against registered command descriptions, then invokes
the selected module's focused prompt and Python validator. Prompt templates may
use `{user_input}`, `{history_context}`, `{current_time}`, and `{timezone}`;
literal JSON braces are preserved. Validators return a list of human-readable
errors, allowing the parser to retry malformed model output.
### Extension walkthrough
1. Create a package under `modules/`; no central import list is needed.
2. Add the feature's tables as `migrations/0001_<name>.sql`. Use the next number
for later schema changes.
3. Put business rules and owner-scoped database operations in `service.py`.
4. Add authenticated Flask routes with `api.security.requireUser()` and derive
ownership from `flask.g.user_uuid`, never request JSON.
5. Add a focused prompt, validator, and `async handler(context, parsed)`.
6. Register the command, optional routes, and optional job types in
`register(registry)`.
7. Run migration status, tests, and Ruff before starting the services.
Use the reminders package as the concrete example for transactions, ownership,
recurrence, jobs, and outbound delivery.
## Reminders
In a Discord DM, users can speak naturally:
```text
remind me tomorrow at 9 AM to call the dentist
remind me every day at 8 AM to take my medication
remind me every Friday at 5 PM to submit my timesheet
list my reminders
cancel reminder <id from the list>
set my timezone to America/Chicago
```
The REST API supports the same data:
```bash
curl -X POST http://localhost:8080/api/reminders \
-H "Authorization: Bearer USER_TOKEN_OR_KEY" \
-H "Content-Type: application/json" \
-d '{"message":"check the oven","run_at":"2099-01-02T18:00:00-06:00","recurrence":{"frequency":"daily","interval":1}}'
curl http://localhost:8080/api/reminders \
-H "Authorization: Bearer USER_TOKEN_OR_KEY"
curl -X DELETE http://localhost:8080/api/reminders/REMINDER_UUID \
-H "Authorization: Bearer USER_TOKEN_OR_KEY"
```
Omit `recurrence` for a one-time reminder. Supported frequencies are `daily`
and `weekly`, with an optional positive `interval`. Times are stored in UTC,
while recurring reminders preserve the user's local wall-clock time across
daylight-saving changes. User timezones must be IANA names such as `UTC` or
`America/Chicago`.
After downtime, a due occurrence is delivered once by the scheduler's normal
processing path and the next future occurrence is scheduled; missed intervals
are not replayed. Cancelling marks the reminder and its pending job/outbound
records together where possible.
## Jobs and outbound delivery
The scheduler atomically claims due jobs with PostgreSQL row locks and
`SKIP LOCKED`. Jobs and outbound messages use leases so another worker can
recover work after a crash. Defaults are a five-second poll, five-minute lease,
three attempts, and exponential retries beginning at 30 seconds and capped at
15 minutes.
Delivery is **at least once**. Idempotency keys prevent ordinary duplicate job
and outbox creation, but a process can crash after Discord accepts a message and
before the API records success. Consumers and message wording should tolerate a
rare duplicate.
Discord claims only `discord_dm` outbox records through scoped internal routes,
sends them, and reports `sent` or `retry`. The existing webhook and ntfy helpers
remain available for custom features but are not the reminder delivery path.
## Tests and lint
```bash
python -m pip install -r requirements-dev.txt
ruff check .
pytest
```
Local pytest reports coverage without failing when PostgreSQL-dependent tests
are skipped. CI enforces at least 80% coverage across runtime framework code
with those integration tests enabled; only the thin administrative CLI wrappers
are excluded from that metric. Tests mock Discord and model-provider calls;
GitHub Actions runs both the Python 3.11 suite against PostgreSQL 16 and a
Compose startup smoke test.
## License
MIT