18 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 (title links to the mission page), mission summary below the title (truncated to 300 characters with an ellipsis), <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, plus an "Open the op" field with the mission-page URL. attendanceHash carries an embed schema version so layout-only changes force one idempotent re-render of already-live roll-calls.
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.
E. Imminent-op reminders (pre-op)
- Trigger: a posted op (unit-visible, Ready/Scheduled, live roll-call message) is within 15 minutes of
classification.startDateTime(src/lib/intelligence/imminentReminders.tsholds the constants + query). Poll loop everyDISCORD_IMMINENT_POLL_MS(default 60s), one-shot via theimminentReminderSentAtmarker on Missions. - Action: the bot opens a thread on the roll-call message ("Op starting soon: