1
0
Fork 0
polaris-task-force/docs/bot/design.md
Z8MB1E e3c4eada09 feat(bot): add post-mission evaluation reminder DMs
Sends one-shot DMs once a mission is evaluable asking members to rate their leadership (and leaders to rate subordinates), gated by a new evaluationRemindersSentAt marker on Missions with its migration. Adds the /remind-evaluations staff command (manual trigger/sweep) and DISCORD_EVALUATION_POLL_MS config; updates bot and root docs.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 01:24:17 -04:00

13 KiB
Raw Permalink Blame History

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, 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.

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.