# 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 `` 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::` (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, `` absolute + `` 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/`) 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.