183 lines
18 KiB
Markdown
183 lines
18 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 (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): 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.
|
||
|
||
### 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.ts` holds the constants + query). Poll loop every `DISCORD_IMMINENT_POLL_MS` (default 60s), one-shot via the `imminentReminderSentAt` marker on Missions.
|
||
- **Action**: the bot opens a thread on the roll-call message ("Op starting soon: <title>", 100-char cap) and posts a reminder embed with the relative start time (`<t:...:R>`) plus a link to the mission page, pinging every yes-RSVP via `<@discordId>`. Pings ignore `preferences.discord.enabled` (same deliberate exception as evaluation reminders: a yes RSVP is explicit intent to attend). Unlinked attendees are skipped; a thread with no pings is still posted so players have a check-in point.
|
||
- **Staleness bound**: a missed reminder still fires within 15 minutes after the start (bot-restart grace), never later; the cancel flow clears the marker so a rescheduled op re-reminds for its new start time.
|
||
|
||
### F. Friday open-slot notice (pre-Friday)
|
||
|
||
- **Trigger**: at 8 PM Eastern on Wednesday (evaluated in `America/New_York`, so the deploy container's own timezone is irrelevant), when no non-cancelled mission (any status, any operation type) starts on that week's upcoming Friday. A draft (Concept) already counts as "someone is on the slot". Poll loop every `DISCORD_SIDE_OPS_POLL_MS` (default 30 min), so the post lands within half an hour of 8 PM ET. A restart later in the evening still catches up until ET midnight; a fully missed Wednesday waits for the following week's notice.
|
||
- **Action**: one message in the #side-ops channel (`DISCORD_SIDE_OPS_CHANNEL_ID`) pinging the Community Zeus role, saying the Friday slot is open, they are welcome to host an op (link to the Friday Ops calendar), and that if nobody picks it up the slot stays empty or Jason hosts it himself.
|
||
- **Role resolution**: `DISCORD_COMMUNITY_ZEUS_ROLE_ID` env if set, otherwise the bot resolves the role by name ("Community Zeus") in the guild cache. Neither available: the notice posts without the role ping and a warn is logged.
|
||
- **Dedup**: no DB state. The notice embeds a stable per-Friday `<t:epoch:D>` token; before posting, the service scans the last 100 channel messages for it, so restarts never double-post.
|
||
- **Claim credit**: when a non-cancelled mission later appears on that Friday whose creator is not a superuser (`getFridayClaimant` in `src/lib/intelligence/fridayOpsNotice.ts`, earliest `createdAt` wins; superuser- or staff-created missions never claim), the service edits the notice in place, appending "This slot was claimed by \<user\>. Thank you for hosting for our community!". If the claiming op is later cancelled or deleted, the claim line is stripped and the notice reverts to the open-slot state (a claim swap between polls re-credits the new claimant). Idempotent via a claim-line check on the message content; the edit half runs whenever the notice is still in the recent-messages window, regardless of day, so a Thursday claim still lands.
|
||
|
||
### 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 |
|
||
| `DISCORD_IMMINENT_POLL_MS` | no | default 60000 |
|
||
| `DISCORD_SIDE_OPS_CHANNEL_ID` | no | #side-ops channel for the Friday open-slot notice |
|
||
| `DISCORD_COMMUNITY_ZEUS_ROLE_ID` | no | Community Zeus role ping (falls back to resolving the role by name) |
|
||
| `DISCORD_SIDE_OPS_POLL_MS` | no | default 1800000 (30 min) |
|
||
|
||
`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.
|