- 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.
14 KiB
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-configdirectly, same pattern assrc/scripts/andsrc/tools/seed/. - The web app and the bot are two independent Payload processes sharing one Postgres DB. Both write via Payload's API with
overrideAccesswhere 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 eventmission:attendance-change(EventTypesaddition); used by both the web action and the botgetMissionAttendance(payload, missionId)—{ yes: User[], no: User[], tentative: User[], counts }for embed + web renderinggetUserAttendance(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, reusesMUTEABLE_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.usernameorusers.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_URLlogin instructions + "change it in Account settings".
- Validate: username free (not taken in
/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 bydiscordUsername== caller's Discord username; setdiscordId. 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:
- Periodic reconcile loop (every
DISCORD_ATTENDANCE_POLL_MS, default 60s): query eligible missions.- No
discordMessageIdyet → post embed + button row, storediscordMessageIdon the mission. - Has
discordMessageId→ compute attendance hash (sorted member lists + counts, JSON) → if ≠ storeddiscordAttendanceHash, edit the message in place, store the new hash. - Status
Cancelled→ edit embed to a cancelled state (buttons removed), post a standalone cancellation notice, cleardiscordMessageId/discordAttendanceHash/discordAttendanceSentAt, and set thediscordCancelledAtmarker (so the same op can roll-call again later). - Status back to
Ready/ScheduledwithdiscordCancelledAtset (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 futurestartDateTime. - Past end time → leave final attendance as-is, stop polling.
- No
- 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.
- Click → resolve user via
- 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): queryuser-notificationswithid > 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.enabledtrue and type not inmutedTypes→ resolvediscordId→ 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)): optionsmessage(+ optionalchannel, defaultDISCORD_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, orScheduled/Activewhoseclassification.startDateTimehas fully passed — same rule asisMissionEvaluableinsrc/lib/evaluations). Poll loop everyDISCORD_EVALUATION_POLL_MS(default 60s) queries evaluable missions with noevaluationRemindersSentAtmarker (one-shot, same pattern asdiscordAttendanceSentAt). - Recipients (
computeEvaluationReminderPlaninsrc/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). Optionalmissionoption (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)
- Foundation:
discord.jsdep,src/bot/{index,config}.ts, env vars,botscript → bot comes online and registers/pingin the test server. - Web model:
mission-attendancescollection + attendance lib +mission:attendance-changeevent type + attendance UI on the mission page +discordIdfield on users → attendance works fully in the browser. - Template bin:
generate-missionscript → run twice, second run is a no-op. - Feature 1:
/signup+/link+ temp password flow → fresh Discord user signs up, logs into the site, changes password. - Feature 2: embed lifecycle + RSVP buttons + reconcile → RSVP in Discord updates the site; changing attendance on the site updates the embed within one poll.
- Feature 3:
preferences.discordgroup + bridge loop + DMs +/announce+ new notify sites (banking/shipments) → market offer DMs an opted-in user;/announceposts. - 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.discordlabels for banking/shipment types once added.