1
0
Fork 0
polaris-task-force/docs/bot/design.md

154 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Discord Bot — Design
Companion to `context.md` (requirements). Status: **approved design, not yet implemented**. Decisions here are locked unless noted; revisit via discussion, not silently.
## Architecture at a glance
- Bot lives in `src/bot/` — a standalone long-running process (`bun run src/bot/index.ts`) importing `@payload-config` directly, same pattern as `src/scripts/` and `src/tools/seed/`.
- The web app and the bot are **two independent Payload processes sharing one Postgres DB**. Both write via Payload's API with `overrideAccess` where needed; **the bot applies its own authz** (Discord roles + app roles) on top.
- App → Discord is one-way: the bot **polls** (notifications bridge + attendance hash reconcile). No webhooks, no changes to existing notify call sites beyond adding new ones.
## Web-side changes
### 1. `mission-attendances` collection (`src/collections/intelligence/MissionAttendances.ts`)
One row per (mission, user). Slug: `mission-attendances`, admin group: Intelligence.
- `mission` → missions (relationship, required, indexed)
- `user` → users (relationship, required)
- `response` → select: `yes` | `no` | `tentative` (required)
- Access: read any logged-in user; create/update = owner-of-self or admin/developer; delete = admin/developer
- **Uniqueness (mission, user) enforced in the service lib** (single write path), not a DB constraint
### 2. Attendance service lib (`src/lib/attendance/index.ts`)
Single source of truth, modeled on `src/lib/banking/index.ts`:
- `setMissionAttendance(payload, { missionId, userId, response })` — find-or-update; emits game event `mission:attendance-change` (`EventTypes` addition); used by **both** the web action and the bot
- `getMissionAttendance(payload, missionId)` — `{ yes: User[], no: User[], tentative: User[], counts }` for embed + web rendering
- `getUserAttendance(payload, userId, missionIds)` — for showing the user's current response on the mission page
### 3. Attendance UI
Add an attendance section to the existing mission detail page `src/app/(frontend)/intelligence/missions/[id]/page.tsx`: three buttons (Yes / Tentative / No) showing the current user's selection, plus lists with counts. Server action `setAttendance` in `src/app/(frontend)/intelligence/missions/actions.ts` → calls the service lib.
### 4. `discordId` on users (`src/collections/users/Users.ts`)
Text field, optional, unique-when-set, indexed. The bot writes it via `overrideAccess` during `/signup` (snowflake known) and `/link`. Needed for: DMs (feature 3), RSVP identity mapping (feature 2), role checks.
### 5. Discord notification preferences
Extend the existing `preferences` group on users with a `discord` sub-group:
- `enabled` (checkbox, default false — **opt-in**: DMs are more intrusive than in-app)
- `mutedTypes` (select, hasMany, reuses `MUTEABLE_NOTIFICATION_TYPES`)
Update `updatePreferences` action + `PreferencesForm` to include it.
### 6. New notification types + notify sites
`MUTEABLE_NOTIFICATION_TYPES` gains (for feature 3 examples):
- `finance:deposit`, `finance:withdraw`, `finance:transfer` (banking)
- `shipment:completed` (shipments)
Add `notifyUser(...)` calls at the corresponding success points in `banking/actions.ts` and `shipments/actions.ts` (market already notifies — verified 8 sites in `market/actions.ts`).
### 7. Template generation bin (`src/scripts/generateNextMission.ts`)
Registered in `payload.config.ts` `bin` array as `{ key: "generate-mission", scriptPath: .../scripts/generateNextMission.ts }` (same shape as `game-tick`/`market-tick`, line 147–156 of `payload.config.ts`). Run by external cron weekly.
- Clone the latest `operationType: "main"` mission (fallback: latest mission of any type; abort with a clear log if no mission exists yet — the GM creates the first one manually)
- Copy config/narrative/server/advanced fields; exclude `id`, timestamps
- `startDateTime` = next Saturday 20:00 (same logic as the collection default), `status` → `Concept` (so the bot won't post it until Ready/Scheduled), `codeName` → `${original}-${YYYYMMDD}` (guarantees uniqueness against the source)
- Idempotent: skip if a mission with that codeName already exists
## Bot structure (`src/bot/`)
```
src/bot/
index.ts # entry: getPayload → client login → register commands → start loops
config.ts # env parsing (see Env below); fails fast on missing required vars
commands/ # slash command definitions (builders) + execute handlers
signup.ts # /signup (DM)
link.ts # /link (DM)
announce.ts # /announce (staff only)
events/
interactionCreate.ts # routes buttons + commands; the RSVP button handler lives here
ready.ts # startup: sync guild commands, kick off poll loops
services/
missionEmbeds.ts # embed render + post/edit lifecycle + attendance-hash reconcile loop
notificationBridge.ts # user-notifications poll → Discord DMs
signup.ts # user creation w/ temp password, validation, /link logic
lib/
roles.ts # isStaff(member) — Discord role IDs or app admin/developer
resolve.ts # discordId ↔ Payload userId lookups
```
Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun run src/bot/index.ts"`.
## Feature flows
### A. Sign-up and linking
- **`/signup`** (DM only; never accept credentials in a public channel). Options: `displayName`, `steamId`. `username` = the caller's Discord username (shown, not editable — that's the link).
- Validate: username free (not taken in `users.username` **or** `users.discordUsername` — both are set to it), snowflake not already linked, steamId is a 17-digit numeric string.
- Create user: `username`, `discordUsername` (same value), `discordId` (snowflake), `displayName`, `steamId`, `roles: ["user"]`, `password` = cryptographically random temp (16 chars, `crypto.randomBytes`).
- DM the temp password + `APP_URL` login instructions + "change it in Account settings".
- **`/link`** (DM): for people with an existing site account. Look up user by `discordUsername` == caller's Discord username; set `discordId`. No match → "no account with that Discord username — use /signup". Already linked to a different snowflake → error, contact staff.
### B. Attendance embeds (feature 2)
**Which missions get embeds**: `startDateTime` in the future **and** `ownershipAndStatus.status` in `[Ready, Scheduled]`. Posting rule: embed only missions with `visibility: "unit"` (the ops channel is unit-wide; leadership-only missions stay off Discord).
**Lifecycle**:
1. Periodic reconcile loop (every `DISCORD_ATTENDANCE_POLL_MS`, default 60s): query eligible missions.
- No `discordMessageId` yet → post embed + button row, store `discordMessageId` on the mission.
- Has `discordMessageId` → compute attendance hash (sorted member lists + counts, JSON) → if ≠ stored `discordAttendanceHash`, edit the message in place, store the new hash.
- Status `Cancelled` → edit embed to a cancelled state, stop polling it.
- Past end time → leave final attendance as-is, stop polling.
2. **RSVP buttons**: action row on each embed: ✅ Yes / 🤔 Tentative / ❌ No, customId `ptf-att:<missionId>:<response>` (well under Discord's 100-char limit).
- Click → resolve user via `discordId`; unlinked → ephemeral "Run /link or /signup first."
- `setMissionAttendance(...)` → immediately re-render + edit the embed (no waiting for the poll), update hash → ephemeral confirmation.
3. **Web → Discord**: web action → `setMissionAttendance` → next reconcile tick sees a hash difference → edits the embed. **Loop safety**: editing the embed never mutates attendance data, so the hash stabilizes — no feedback loop. Hash stored on the mission doc makes reconcile stateless across bot restarts (two new optional text fields on missions: `discordMessageId`, `discordAttendanceHash`).
**Embed contents**: mission name + codeName, `<t:epoch:F>` absolute + `<t:epoch:R>` relative start time, operationType, maxPlayers, and three sections — ✅ Yes (n), 🤔 Tentative (n), ❌ No (n) — with member display names.
### C. Notifications bridge + announcements (feature 3)
- **Poll loop** (every `DISCORD_NOTIFICATION_POLL_MS`, default 20s): query `user-notifications` with `id > lastSeenId`, sorted ascending, limit 50. In-memory cursor; on startup, cursor = current max id (never DM pre-startup history).
- Per notification: load user → `preferences.discord.enabled` true **and** type not in `mutedTypes` → resolve `discordId` → DM an embed (title, message, link = `APP_URL + link`).
- **Rate limits**: DM sends are ~5/5s per channel. Sequential sends with a small queue; cap ~5 DMs per tick. Backpressure by taking a longer poll interval if the queue stays full.
- **`/announce`** (staff only, `isStaff(member)`): options `message` (+ optional `channel`, default `DISCORD_ANNOUNCE_CHANNEL_ID`). Posts a formatted announcement embed. App → Discord announcements are a later iteration, not v1.
### Authz (`src/bot/lib/roles.ts`)
`isStaff(member)`: caller has any `DISCORD_STAFF_ROLE_IDS`, **or** their linked Payload user has roles `admin`/`developer`. RSVP/signup/link are open to all members. The bot performs Payload writes with `overrideAccess` and relies on these checks + its own validations.
## Env / secrets (add to `.env` + `.env.example`)
| Var | Required | Purpose |
|---|---|---|
| `DISCORD_TOKEN` | yes | bot token |
| `DISCORD_GUILD_ID` | yes | guild for slash commands |
| `DISCORD_OPS_CHANNEL_ID` | yes | attendance embeds |
| `DISCORD_ANNOUNCE_CHANNEL_ID` | no | default `/announce` target |
| `DISCORD_STAFF_ROLE_IDS` | no | comma-separated role IDs granting staff |
| `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 |
| `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 |
`APP_URL` already exists — used for login links in DMs.
## Build sequencing (each step independently verifiable)
1. **Foundation**: `discord.js` dep, `src/bot/{index,config}.ts`, env vars, `bot` script → bot comes online and registers `/ping` in the test server.
2. **Web model**: `mission-attendances` collection + attendance lib + `mission:attendance-change` event type + attendance UI on the mission page + `discordId` field on users → attendance works fully in the browser.
3. **Template bin**: `generate-mission` script → run twice, second run is a no-op.
4. **Feature 1**: `/signup` + `/link` + temp password flow → fresh Discord user signs up, logs into the site, changes password.
5. **Feature 2**: embed lifecycle + RSVP buttons + reconcile → RSVP in Discord updates the site; changing attendance on the site updates the embed within one poll.
6. **Feature 3**: `preferences.discord` group + bridge loop + DMs + `/announce` + new notify sites (banking/shipments) → market offer DMs an opted-in user; `/announce` posts.
7. **Polish**: preferences UI for Discord toggles, restart behavior checks, error paths (message deleted, DM closed, unlinked user).
## Open questions (non-blocking, can decide during build)
- Steam ID validation: strict 17-digit numeric, or lenient?
- Keep past-mission embeds in the channel (history) vs auto-delete — default: keep.
- `preferences.discord` labels for banking/shipment types once added.