- Cancelled ops: roll-call embed edited to the cancelled state (buttons removed) plus a standalone cancellation notice in the ops channel; clears the roll-call markers and sets the new discordCancelledAt marker on the mission. - Rescheduled ops (Cancelled back to Ready/Scheduled, future start): immediate announcement embed with the new start time and when the fresh roll-call goes up (usual 3-day lead, or live-in-channel when already due), then clears the marker so the normal flow posts a new roll-call. Prior RSVPs carry over. - Hidden bot-managed discordCancelledAt field on Missions + migration. - Bot embed poll loop handles both transitions idempotently.
165 lines
14 KiB
Markdown
165 lines
14 KiB
Markdown
# 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 (works in servers and DMs — credential-free, ephemeral reply only)
|
||
announce.ts # /announce (staff only)
|
||
remindEvaluations.ts # /remind-evaluations (staff only, manual evaluation-reminder trigger)
|
||
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`** (works in servers and DMs — takes no credentials and only replies ephemerally, so nothing sensitive is exposed; guild-usable so users with DMs disabled can still link, which unblocks the RSVP buttons): 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 (buttons removed), post a standalone cancellation notice, clear `discordMessageId` / `discordAttendanceHash` / `discordAttendanceSentAt`, and set the `discordCancelledAt` marker (so the same op can roll-call again later).
|
||
- Status back to `Ready`/`Scheduled` with `discordCancelledAt` set (rescheduled op) → immediately post a reschedule announcement: the new `<t:start>` time plus when the fresh roll-call goes up (the usual 3-day lead, or "live in this channel" when it is already due / already posted). Then clear the marker and let the normal flow post the fresh roll-call. Requires a future `startDateTime`.
|
||
- 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.
|
||
|
||
### D. Evaluation reminders (post-mission)
|
||
|
||
- **Trigger**: a mission becomes evaluable (status `Completed`, or `Scheduled`/`Active` whose `classification.startDateTime` has fully passed — same rule as `isMissionEvaluable` in `src/lib/evaluations`). Poll loop every `DISCORD_EVALUATION_POLL_MS` (default 60s) queries evaluable missions with no `evaluationRemindersSentAt` marker (one-shot, same pattern as `discordAttendanceSentAt`).
|
||
- **Recipients** (`computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`): participants = users with a "yes" RSVP. Each participant is reminded to rate their direct leader(s); each leader of an assignment containing a participant is reminded to rate that participant (subordinate kind needs no RSVP). Self-pairs skipped; a user who is both gets one merged DM.
|
||
- **DM**: embed with "Rate your leadership" / "Rate your subordinates" sections, each line linking to the ratee's profile page (`APP_URL + /profile/<username>`) where the rating dialog lives. Best-effort: DM failures are logged and skipped, marker is set after the pass (cap 40 DMs/tick, 300ms between sends; overflow retries next tick before the marker is set).
|
||
- **Deliberate exception**: these DMs ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Dedicated unit-admin flow, not notification-stream forwarding.
|
||
- **Manual trigger**: `/remind-evaluations` (staff only, guild-only). Optional `mission` option (mission ID, name, or code name — ambiguous matches list candidates) sends reminders for that one mission even if the marker is already set (as long as it's evaluable); omitted, it runs the reconcile sweep over all pending missions immediately and reports `{processed, sent}`. Replies are ephemeral; the interaction is deferred because DM sending takes minutes at scale.
|
||
|
||
### 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 |
|
||
| `DISCORD_EVALUATION_POLL_MS` | no | default 60000 |
|
||
|
||
`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.
|