149 lines
9.4 KiB
Markdown
149 lines
9.4 KiB
Markdown
# 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.
|
|
|
|
## 4. Repository layout (recommended, not enforced)
|
|
|
|
```
|
|
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.
|