Files
Media-Server-Setup/bot/AGENT.md
2026-11-02 22:44:09 -06:00

9.4 KiB

Vanguard Discord bot — brief for the agent building it

You are building the Discord bot for Vanguard. What the bot does is up to the community and you. This file only covers where it runs, what it is given, and the few rules that keep it from breaking the box it shares. Nothing here obliges you to integrate with the other services on that box.

1. Hosting

One Debian server runs a set of Docker Compose services from ~/vanguard-stream/. You own exactly one of them: the discord-bot container. You do not own the compose file, the reverse proxy (Caddy), the firewall, the identity provider (Authentik), or the database server config. If you need any of those changed, ask the admin with the exact change (see §6).

What you are given:

Runtime python:3.12-slim + ffmpeg + curl, and nothing else. Your dependencies ship with your code (see §1a), imported from /app/vendor via PYTHONPATH. Adding one needs no image rebuild.
Your code a release bundle from your CI, installed at /srv/bot/app and bind-mounted at /app (§1a). Treat /app as read-only; write to /data.
Entry point the container runs python -u /app/main.py and restarts it if it exits. With no bundle installed it idles and logs a hint.
User uid 1000
Database MariaDB, database vanguardbot, user bot, reachable at DB_HOST:DB_PORT. Yours alone. Other databases on the same server belong to other services; never touch them.
Persistent disk /data, yours
Web UI bind WEB_HOST:WEB_PORT (0.0.0.0:8000) — one port for your API and your pages. The proxy publishes it as https://bot.<domain>/ behind login (§3).
Discord DISCORD_BOT_TOKEN, DISCORD_GUILD_ID. The bot user belongs to the community's existing Discord application.
Clock TZ=America/New_York

Environment variables

DISCORD_BOT_TOKEN, DISCORD_GUILD_ID, DB_BACKEND=mariadb, DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DATA_DIR=/data, WEB_HOST, WEB_PORT, WEB_PUBLIC_URL, WEB_SERVE_UI=true, WEB_ALLOWED_GROUPS=bot-users,admins, AUTH_HEADER_USER, AUTH_HEADER_GROUPS, AUTH_HEADER_EMAIL, MEDIA_CLIPS, PYTHONPATH=/app/vendor, TZ. Secrets arrive only through env. Never write them to the repo, /data, or logs.

SQLAlchemy URL: mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME} (async: mysql+aiomysql).

1a. Releases: one bundle, dependencies included

Your CI (.gitea/workflows/ci.yml, job bundle) runs after the tests and produces the artifact vanguard-bot-bundle — a single tar.gz whose contents are /app:

main.py  app/  website/  version.json  BUILD.json  vendor/

vendor/ is pip install --only-binary=:all: --target vendor -r requirements.txt, built on python:3.12 so the wheels match the runtime image's ABI. BUILD.json records the version, commit, build time, the package list and a sha256 of every file.

A push to main deploys itself. The workflow's last job posts the bundle to the server's deploy service, signed with the repository secret BOT_DEPLOY_SECRET; the server verifies the signature and every checksum, keeps the previous release for rollback, and restarts the container. The install log comes back in your CI job, so a failed deploy fails the run. Branches and pull requests test and build, and deploy nothing.

Your settings travel with it. DISCORD_BOT_TOKEN (an Actions secret) and DISCORD_GUILD_ID (an Actions variable) are signed together with the bundle and written into the container's environment by the server, so this repo is where they are managed and rotated. The server accepts only the names its BOT_ENV_KEYS allows and reports the rest as settings_refused; it keeps ownership of what it generates, such as DB_PASSWORD. A new setting means asking the admin to add its name to that list (§6).

Rules that follow from this:

  • Keep requirements.txt resolvable with prebuilt wheels only; a dependency that must compile will fail the build. Ask the admin if you truly need one.
  • Don't import anything that is not in requirements.txt or the standard library.
  • Nothing is installed on the server at deploy time, and the server reaches no package index.

2. Process model

  • One process, one asyncio loop: the Discord gateway client and the web server run together (discord.py plus aiohttp or FastAPI via uvicorn's programmatic API). Do not spawn a second container.
  • Log to stdout. The admin reads docker compose logs -f discord-bot.
  • On fatal misconfiguration (no token, DB unreachable after retries) log the reason and exit non-zero; the restart policy retries.
  • Handle SIGTERM: close the gateway and the web server cleanly.

3. Web UI: login is handled outside your code

Every request to https://bot.<domain>/ passes through Caddy, which sends the user to Authentik (Discord login) and, once authenticated, forwards the request to you with identity headers:

header content
X-Authentik-Username the user's account name
X-Authentik-Groups group names, separated by | or , — the community uses admins, editors, streamers; others may be added
X-Authentik-Email, X-Authentik-Name, X-Authentik-Uid extras

WEB_SERVE_UI=true is set on the server, so this process serves your HTML pages at / and their assets at /assets/ as well as /api/… — same origin, one port, no second service and no proxy of your own. Locally the separate website launcher still works; it is off unless WEB_SERVE_UI says otherwise.

Rules:

  1. Implement no login page, no sessions, no passwords. Identity is the headers, per request.
  2. Port 8000 is loopback-only on the host, so the headers can be trusted. Still fail closed: a request without the username header gets 401.
  3. Authorize by group, read from the header each time. Don't cache roles in your DB.
  4. Serve under /; the proxy does no path rewriting for this host.
  5. Build absolute links from WEB_PUBLIC_URL.
  6. The Authentik username may differ from the Discord username. Use the header value as the user key; if you need the Discord snowflake, resolve it through your gateway client and cache it in your DB.

Until the admin creates the matching Authentik application, the hostname returns an error, never an open page. Test against http://127.0.0.1:8000/ on the server with the headers set by hand.

bot/
  AGENT.md, README.md            this brief; the ops cheat-sheet (keep README current)
  Dockerfile, entrypoint.sh      admin-owned; ask before changing
  app/
    main.py                      config → migrations → gateway + web in one loop → clean shutdown
    requirements.txt             pin versions once past the prototype
    config.py                    the only module that reads os.environ
    bot/                         discord.py client, cogs/ (one feature per cog), permission checks
    web/                         routes/, templates/, static/, auth.py (header → user/groups)
    db/                          SQLAlchemy models, session, alembic/ migrations (run at startup)
    services/                    the actual logic, shared by cogs and web; no Discord or HTTP types
    tests/                       pytest; must pass offline with no token

Slash commands scoped to the guild so they appear instantly. Prefer slash commands over reading message content; Message Content is a privileged intent, enable it only if you need it.

5. Shared ground on the box

  • /media/clips is mounted read-write. It is the community's clip storage, organized as one folder per user. You may add files there; never modify, move or delete files you did not create (parts of it are mirrored from members' machines and will fight back). If you write a video, write <name>.part then rename to <name>.mp4, and put a <name>.json beside it with at least {"timestamp": "<UTC ISO-8601 creation time>", "source": "...", "uploader": "<discord username>"}. Other services on the box index that folder and read the sidecar. If you never touch this mount, none of this applies.
  • The same bot user is used by another service for REST calls (posting notices, reading scheduled events). Sharing a token is fine. Don't rename the bot or change its avatar; pick your own channels.
  • Don't bind other ports, don't start other containers, don't write outside /app, /data, /media/clips.

6. Working with the admin

  • Source: push to the community Gitea. CI builds the release bundle on the python312 runner label (CPython 3.12 plus node) and, on main, installs it on the server. The test jobs are commented out: run the suites locally before you push (python -m pytest -q and node --test app/tests/*.test.js). What CI still enforces: the bundle must import cleanly with only its own vendor/ on the path, every file must match BUILD.json, and the bot must answer on its port within 45 s of the swap or the deploy fails and the previous release stays rollback-ready.
  • Need infra? Open a Gitea issue titled infra: … stating the exact change: a new env var and why, a mount, a Caddy path that should skip login (e.g. a public webhook endpoint), a new database. Don't work around the proxy or the firewall.
  • Dependencies changed? Just push: they travel inside the bundle. Only a new system package (beyond ffmpeg and curl) needs the admin to rebuild the image.
  • Keep README.md updated with every command, env var, and table you add.