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

14 KiB

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.

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:

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:

docker compose up --build db migrate app scheduler

Useful checks:

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:

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:

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:

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:

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.

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:

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:

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:

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.

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:

DISCORD_ENROLLMENT_MODE=allowlist
DISCORD_ALLOWLIST=123456789012345678,234567890123456789

To let any Discord user create a provider-backed account explicitly set:

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:

modules/tasks/
  __init__.py
  commands.py
  prompts.py
  routes.py
  service.py
  migrations/
    0001_tasks.sql

Its __init__.py owns registration:

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:

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:

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:

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

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