Compare commits
No commits in common. "fad3d8399bc2a2786b9d2d3f117b97221131ff57" and "7ccd825b8c2caaa2ccbdd4ce6792424d13f4a6c3" have entirely different histories.
fad3d8399b
...
7ccd825b8c
10 changed files with 7 additions and 636 deletions
|
|
@ -323,8 +323,8 @@ Five XP-earning minigames under one parent: routes live at `/minigames/*` (moved
|
||||||
|
|
||||||
A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.**
|
A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.**
|
||||||
|
|
||||||
- **Env / run**: requires `DISCORD_TOKEN` and `DISCORD_GUILD_ID` (fail-fast on missing required vars in `src/bot/config.ts`); optional `DISCORD_OPS_CHANNEL_ID`, `DISCORD_ANNOUNCE_CHANNEL_ID`, `DISCORD_STAFF_ROLE_IDS`, `DISCORD_ATTENDANCE_POLL_MS` (default 60s), `DISCORD_NOTIFICATION_POLL_MS` (default 20s), `DISCORD_EVALUATION_POLL_MS` (default 60s), `DISCORD_IMMINENT_POLL_MS` (default 60s), `DISCORD_SIDE_OPS_CHANNEL_ID` (Friday open-slot notice channel), `DISCORD_COMMUNITY_ZEUS_ROLE_ID` (role ping for that notice), `DISCORD_SIDE_OPS_POLL_MS` (default 30 min), plus existing `APP_URL`. Logs through `payload.logger`.
|
- **Env / run**: requires `DISCORD_TOKEN` and `DISCORD_GUILD_ID` (fail-fast on missing required vars in `src/bot/config.ts`); optional `DISCORD_OPS_CHANNEL_ID`, `DISCORD_ANNOUNCE_CHANNEL_ID`, `DISCORD_STAFF_ROLE_IDS`, `DISCORD_ATTENDANCE_POLL_MS` (default 60s), `DISCORD_NOTIFICATION_POLL_MS` (default 20s), `DISCORD_EVALUATION_POLL_MS` (default 60s), `DISCORD_IMMINENT_POLL_MS` (default 60s), plus existing `APP_URL`. Logs through `payload.logger`.
|
||||||
- **Structure**: `commands/` — `ping`, `signup`, `link`, `announce`, `remindEvaluations`; `events/interactionCreate.ts` — routes `ptf-att:` RSVP buttons; `services/` — `missionEmbeds` (attendance embed lifecycle + reconcile loop), `notificationBridge` (poll → Discord DMs), `evaluationReminders` (post-mission evaluation reminder DMs), `imminentReminders` (pre-op thread + attendee pings), and `fridayOpsNotice` (weekly #side-ops ping when the upcoming Friday has no op, edited to credit the community claimer); `lib/` — `roles.ts` (`isStaff`), `resolve.ts` (discordId ↔ Payload user lookups). Command registration scope: `signup`/`link`/`ping` are **global** (DM-usable — guild-scoped commands never appear in DMs), `announce`/`remind-evaluations` are guild-only.
|
- **Structure**: `commands/` — `ping`, `signup`, `link`, `announce`, `remindEvaluations`; `events/interactionCreate.ts` — routes `ptf-att:` RSVP buttons; `services/` — `missionEmbeds` (attendance embed lifecycle + reconcile loop), `notificationBridge` (poll → Discord DMs), `evaluationReminders` (post-mission evaluation reminder DMs), and `imminentReminders` (pre-op thread + attendee pings); `lib/` — `roles.ts` (`isStaff`), `resolve.ts` (discordId ↔ Payload user lookups). Command registration scope: `signup`/`link`/`ping` are **global** (DM-usable — guild-scoped commands never appear in DMs), `announce`/`remind-evaluations` are guild-only.
|
||||||
- **Evaluation reminders**: once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start — same rule as `isMissionEvaluable`), the bot sends a one-shot DM (per user with a linked `discordId`) asking them to rate their leadership, plus a "rate your subordinates" section for leaders whose members RSVP'd yes. Links go to each ratee's profile page where the rating dialog lives. Recipient computation: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker: `evaluationRemindersSentAt` on Missions (one-shot, same pattern as `discordAttendanceSentAt`; withheld if the 40-DM/tick cap is hit — retries next tick). These DMs deliberately ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Staff can trigger manually via `/remind-evaluations` (optional `mission` option accepts an ID, name, or code name and re-sends even if the marker is set; omitted, it sweeps all pending missions and reports `{processed, sent}`).
|
- **Evaluation reminders**: once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start — same rule as `isMissionEvaluable`), the bot sends a one-shot DM (per user with a linked `discordId`) asking them to rate their leadership, plus a "rate your subordinates" section for leaders whose members RSVP'd yes. Links go to each ratee's profile page where the rating dialog lives. Recipient computation: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker: `evaluationRemindersSentAt` on Missions (one-shot, same pattern as `discordAttendanceSentAt`; withheld if the 40-DM/tick cap is hit — retries next tick). These DMs deliberately ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Staff can trigger manually via `/remind-evaluations` (optional `mission` option accepts an ID, name, or code name and re-sends even if the marker is set; omitted, it sweeps all pending missions and reports `{processed, sent}`).
|
||||||
- **Sign-up / linking (feature 1)**: `/signup` is **DM-only** (the temp password flows through the DM). Creates the Payload user with `username` = `discordUsername` = the caller's Discord username, plus `discordId`, `displayName`, `steamId`, and a random temp password. The ephemeral reply carries the password as the guaranteed delivery path; `interaction.user.send()` is a best-effort persistent copy, so a blocked DM never orphans the account. `/link` works in servers **and** DMs (credential-free, ephemeral reply only) — matches `discordUsername` → sets `discordId`.
|
- **Sign-up / linking (feature 1)**: `/signup` is **DM-only** (the temp password flows through the DM). Creates the Payload user with `username` = `discordUsername` = the caller's Discord username, plus `discordId`, `displayName`, `steamId`, and a random temp password. The ephemeral reply carries the password as the guaranteed delivery path; `interaction.user.send()` is a best-effort persistent copy, so a blocked DM never orphans the account. `/link` works in servers **and** DMs (credential-free, ephemeral reply only) — matches `discordUsername` → sets `discordId`.
|
||||||
- **DM gotcha**: a user with "Allow direct messages from server members" off in Discord privacy settings can neither receive the bot's DMs nor open a DM with the bot. The `/signup` rejection message explains how to enable it.
|
- **DM gotcha**: a user with "Allow direct messages from server members" off in Discord privacy settings can neither receive the bot's DMs nor open a DM with the bot. The `/signup` rejection message explains how to enable it.
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
|
||||||
- `setMissionAttendance(...)` → immediately re-render + edit the embed (no waiting for the poll), update hash → ephemeral confirmation.
|
- `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`).
|
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.
|
**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)
|
### C. Notifications bridge + announcements (feature 3)
|
||||||
|
|
||||||
|
|
@ -135,14 +135,6 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
|
||||||
- **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.
|
- **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.
|
- **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`)
|
### 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.
|
`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.
|
||||||
|
|
@ -160,9 +152,6 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
|
||||||
| `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 |
|
| `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 |
|
||||||
| `DISCORD_EVALUATION_POLL_MS` | no | default 60000 |
|
| `DISCORD_EVALUATION_POLL_MS` | no | default 60000 |
|
||||||
| `DISCORD_IMMINENT_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.
|
`APP_URL` already exists — used for login links in DMs.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.43",
|
"version": "0.2.42",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ bot/
|
||||||
notificationBridge.ts # Poll user-notifications → Discord DMs
|
notificationBridge.ts # Poll user-notifications → Discord DMs
|
||||||
evaluationReminders.ts # Post-mission evaluation reminder DMs (one-shot marker on Missions)
|
evaluationReminders.ts # Post-mission evaluation reminder DMs (one-shot marker on Missions)
|
||||||
imminentReminders.ts # Pre-op reminder: thread on the roll-call message + attendee pings (one-shot marker on Missions)
|
imminentReminders.ts # Pre-op reminder: thread on the roll-call message + attendee pings (one-shot marker on Missions)
|
||||||
fridayOpsNotice.ts # Weekly notice: pings Community Zeus in #side-ops when the upcoming Friday has no op
|
|
||||||
signup.ts # Signup service logic
|
signup.ts # Signup service logic
|
||||||
transferRequests.ts # Polls assignment transfer requests awaiting leader decision
|
transferRequests.ts # Polls assignment transfer requests awaiting leader decision
|
||||||
transferDelivery.ts # Transfer leader-request / rejection / info delivery DMs
|
transferDelivery.ts # Transfer leader-request / rejection / info delivery DMs
|
||||||
|
|
@ -53,7 +52,7 @@ bot/
|
||||||
|
|
||||||
## Feature flow: attendance
|
## Feature flow: attendance
|
||||||
|
|
||||||
Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). The embed title links to the mission page, a mission summary sits under the title (truncated to 300 characters with an ellipsis), and an "Open the op" field carries the URL; `attendanceHash` includes an embed schema version so layout-only changes re-render live roll-calls once (idempotent). Web UI: `MissionAttendance` component.
|
Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). Web UI: `MissionAttendance` component.
|
||||||
|
|
||||||
## Feature flow: notifications
|
## Feature flow: notifications
|
||||||
|
|
||||||
|
|
@ -67,10 +66,6 @@ Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start
|
||||||
|
|
||||||
15 minutes before a posted op starts (`visibility: "unit"`, Ready/Scheduled, live roll-call message), `imminentReminders` opens a thread on the roll-call message ("Op starting soon: <name>") and pings every yes-RSVP via `<@discordId>` (pings ignore `preferences.discord.enabled`, same exception as evaluation reminders). One-shot via the `imminentReminderSentAt` marker on Missions; the cancel flow clears it so a rescheduled op re-reminds. Grace bound: a missed reminder still fires within 15 minutes after the start, never later. Query: `src/lib/intelligence/imminentReminders.ts` (window constants + `getImminentReminderMissions`).
|
15 minutes before a posted op starts (`visibility: "unit"`, Ready/Scheduled, live roll-call message), `imminentReminders` opens a thread on the roll-call message ("Op starting soon: <name>") and pings every yes-RSVP via `<@discordId>` (pings ignore `preferences.discord.enabled`, same exception as evaluation reminders). One-shot via the `imminentReminderSentAt` marker on Missions; the cancel flow clears it so a rescheduled op re-reminds. Grace bound: a missed reminder still fires within 15 minutes after the start, never later. Query: `src/lib/intelligence/imminentReminders.ts` (window constants + `getImminentReminderMissions`).
|
||||||
|
|
||||||
## Feature flow: Friday open-slot notice
|
|
||||||
|
|
||||||
At 8 PM Eastern on Wednesday (`America/New_York`, evaluated via `isNoticeDue` in `src/lib/intelligence/fridayOpsNotice.ts`; catch-up until ET midnight, a fully missed Wednesday skips to next week), if no non-cancelled mission starts on that week's upcoming Friday, `fridayOpsNotice` posts once in #side-ops pinging the Community Zeus role: the slot is open, hosting is welcome, and if nobody picks it up the slot stays empty or Jason hosts it himself. When a non-superuser creator later claims the slot (`getFridayClaimant`, earliest-created mission wins; superuser/staff-created missions never claim), the notice is edited to append "This slot was claimed by \<user\>. Thank you for hosting for our community!"; if that op is later cancelled or deleted, the claim line is stripped and the notice reverts to the open-slot state (a between-polls claim swap re-credits the new claimant). Role: `DISCORD_COMMUNITY_ZEUS_ROLE_ID` or resolved by name in the guild; channel: `DISCORD_SIDE_OPS_CHANNEL_ID`. Dedup without DB state: the message embeds a stable per-Friday `<t:epoch:D>` token and the service scans recent channel messages for it (claim idempotency via a marker in the message content). Drafts (Concept) count as the slot being taken.
|
|
||||||
|
|
||||||
## Feature flow: assignment transfers
|
## Feature flow: assignment transfers
|
||||||
|
|
||||||
`transferRequests` polls `assignment-transfers` for requests awaiting leader decision; leaders approve/deny via buttons handled in `transferInteractions.ts`; DMs are built by `lib/transferMessaging.ts` and sent through `transferDelivery.ts` (leader request, requester rejection notice, decision info). Web side: `src/lib/transfers/` + `/transfers` page.
|
`transferRequests` polls `assignment-transfers` for requests awaiting leader decision; leaders approve/deny via buttons handled in `transferInteractions.ts`; DMs are built by `lib/transferMessaging.ts` and sent through `transferDelivery.ts` (leader request, requester rejection notice, decision info). Web side: `src/lib/transfers/` + `/transfers` page.
|
||||||
|
|
@ -85,7 +80,6 @@ At 8 PM Eastern on Wednesday (`America/New_York`, evaluated via `isNoticeDue` in
|
||||||
| Change DM bridging | `bot/services/notificationBridge.ts` |
|
| Change DM bridging | `bot/services/notificationBridge.ts` |
|
||||||
| Evaluation reminder logic | `bot/services/evaluationReminders.ts` + `src/lib/evaluations/reminders.ts` |
|
| Evaluation reminder logic | `bot/services/evaluationReminders.ts` + `src/lib/evaluations/reminders.ts` |
|
||||||
| Imminent-op reminder logic | `bot/services/imminentReminders.ts` + `src/lib/intelligence/imminentReminders.ts` |
|
| Imminent-op reminder logic | `bot/services/imminentReminders.ts` + `src/lib/intelligence/imminentReminders.ts` |
|
||||||
| Friday open-slot notice | `bot/services/fridayOpsNotice.ts` + `src/lib/intelligence/fridayOpsNotice.ts` |
|
|
||||||
| Transfer workflow | `bot/services/transferRequests.ts` + `lib/transferMessaging.ts` |
|
| Transfer workflow | `bot/services/transferRequests.ts` + `lib/transferMessaging.ts` |
|
||||||
| User lookup patterns | `bot/lib/resolve.ts` |
|
| User lookup patterns | `bot/lib/resolve.ts` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,11 @@ export interface BotConfig {
|
||||||
opsChannelId: string | null;
|
opsChannelId: string | null;
|
||||||
announceChannelId: string | null;
|
announceChannelId: string | null;
|
||||||
staffRoleIds: string[];
|
staffRoleIds: string[];
|
||||||
sideOpsChannelId: string | null;
|
|
||||||
communityZeusRoleId: string | null;
|
|
||||||
attendancePollMs: number;
|
attendancePollMs: number;
|
||||||
notificationPollMs: number;
|
notificationPollMs: number;
|
||||||
evaluationPollMs: number;
|
evaluationPollMs: number;
|
||||||
readyReminderPollMs: number;
|
readyReminderPollMs: number;
|
||||||
imminentReminderPollMs: number;
|
imminentReminderPollMs: number;
|
||||||
sideOpsNoticePollMs: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = (name: string): string => {
|
const required = (name: string): string => {
|
||||||
|
|
@ -50,12 +47,9 @@ export const botConfig: BotConfig = {
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((id) => id.trim())
|
.map((id) => id.trim())
|
||||||
.filter((id) => id.length > 0),
|
.filter((id) => id.length > 0),
|
||||||
sideOpsChannelId: optional("DISCORD_SIDE_OPS_CHANNEL_ID"),
|
|
||||||
communityZeusRoleId: optional("DISCORD_COMMUNITY_ZEUS_ROLE_ID"),
|
|
||||||
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
|
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
|
||||||
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
|
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
|
||||||
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
|
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
|
||||||
readyReminderPollMs: pollMs("DISCORD_READY_REMINDER_POLL_MS", 60_000),
|
readyReminderPollMs: pollMs("DISCORD_READY_REMINDER_POLL_MS", 60_000),
|
||||||
imminentReminderPollMs: pollMs("DISCORD_IMMINENT_POLL_MS", 60_000),
|
imminentReminderPollMs: pollMs("DISCORD_IMMINENT_POLL_MS", 60_000),
|
||||||
sideOpsNoticePollMs: pollMs("DISCORD_SIDE_OPS_POLL_MS", 1_800_000),
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
import { type Client, type Collection, type Message, type TextBasedChannelFields } from "discord.js";
|
|
||||||
import type { Payload } from "payload";
|
|
||||||
import { botConfig } from "@/bot/config";
|
|
||||||
import {
|
|
||||||
SIDE_OPS_NOTICE_ROLE_NAME,
|
|
||||||
getFridayClaimant,
|
|
||||||
hasMissionOnFriday,
|
|
||||||
isNoticeDue,
|
|
||||||
noticeToken,
|
|
||||||
upcomingFridayRange,
|
|
||||||
} from "@/lib/intelligence/fridayOpsNotice";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Friday open-slot notice service: at 8 PM Eastern on Wednesday, if no op is
|
|
||||||
* scheduled for that week's Friday yet, post once in #side-ops pinging the
|
|
||||||
* Community Zeus role, offering the slot.
|
|
||||||
*
|
|
||||||
* When a non-superuser (a community zeus) later claims the slot, the notice
|
|
||||||
* is edited to credit them instead; if that claim op is later cancelled (or
|
|
||||||
* deleted), the notice reverts to the open-slot state. Dedup needs no DB
|
|
||||||
* state: the notice embeds a stable per-Friday timestamp token and the
|
|
||||||
* service scans recent channel messages for it (and for the claim line)
|
|
||||||
* before posting or editing, so bot restarts never double-post or re-credit.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const MESSAGE_SCAN_LIMIT = 100;
|
|
||||||
|
|
||||||
let reconcileRunning = false;
|
|
||||||
|
|
||||||
const errorMessage = (error: unknown): string =>
|
|
||||||
error instanceof Error ? error.message : "Unknown error";
|
|
||||||
|
|
||||||
type SideOpsChannel = TextBasedChannelFields & {
|
|
||||||
messages: { fetch(options: { limit: number }): Promise<Collection<string, Message>> };
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSideOpsChannel = async (client: Client, channelId: string): Promise<SideOpsChannel> => {
|
|
||||||
const channel = await client.channels.fetch(channelId);
|
|
||||||
if (!channel || !("send" in channel) || !("messages" in channel)) {
|
|
||||||
throw new Error(`[Bot] Side-ops channel ${channelId} is not a text channel.`);
|
|
||||||
}
|
|
||||||
return channel as unknown as SideOpsChannel;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Configured role ID wins; otherwise resolve the role by name in the guild. */
|
|
||||||
const resolveZeusRoleId = async (client: Client): Promise<string | null> => {
|
|
||||||
if (botConfig.communityZeusRoleId) return botConfig.communityZeusRoleId;
|
|
||||||
const guild = await client.guilds.fetch(botConfig.guildId);
|
|
||||||
const role = guild.roles.cache.find((role) => role.name === SIDE_OPS_NOTICE_ROLE_NAME);
|
|
||||||
return role?.id ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderNotice = (zeusRoleId: string | null): string => {
|
|
||||||
const mention = zeusRoleId ? `<@&${zeusRoleId}> ` : "";
|
|
||||||
const { epoch } = upcomingFridayRange(new Date());
|
|
||||||
return (
|
|
||||||
`${mention}The Friday op slot (<t:${epoch}:D>) is still open, and you are welcome to host an op: ` +
|
|
||||||
`schedule one via the Friday Ops calendar at ${botConfig.appUrl}/intelligence/missions/calendar. ` +
|
|
||||||
"If nobody picks it up, the slot will either stay empty (no op that Friday) or Jason will host it himself."
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CLAIM_MARKER = "This slot was claimed by";
|
|
||||||
const CLAIM_LINE_PATTERN =
|
|
||||||
/\n\nThis slot was claimed by (.+)\. Thank you for hosting for our community!$/;
|
|
||||||
|
|
||||||
const creditedNameOf = (content: string): string | null =>
|
|
||||||
content.match(CLAIM_LINE_PATTERN)?.[1] ?? null;
|
|
||||||
|
|
||||||
const findNoticeMessage = async (
|
|
||||||
channel: SideOpsChannel,
|
|
||||||
token: string,
|
|
||||||
): Promise<Message | null> => {
|
|
||||||
const recent = await channel.messages.fetch({ limit: MESSAGE_SCAN_LIMIT });
|
|
||||||
return recent.find((message) => message.content.includes(token)) ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const processNotice = async (
|
|
||||||
client: Client,
|
|
||||||
payload: Payload,
|
|
||||||
): Promise<"posted" | "edited" | "idle"> => {
|
|
||||||
const channelId = botConfig.sideOpsChannelId;
|
|
||||||
if (!channelId) return "idle";
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const claim = await getFridayClaimant(payload, now);
|
|
||||||
|
|
||||||
const channel = await getSideOpsChannel(client, channelId);
|
|
||||||
const token = noticeToken(now);
|
|
||||||
const notice = await findNoticeMessage(channel, token);
|
|
||||||
|
|
||||||
if (!notice) {
|
|
||||||
const slotTaken = claim != null || (await hasMissionOnFriday(payload, now));
|
|
||||||
if (!isNoticeDue(now) || slotTaken) return "idle";
|
|
||||||
|
|
||||||
const zeusRoleId = await resolveZeusRoleId(client);
|
|
||||||
if (!zeusRoleId) {
|
|
||||||
payload.logger.warn(
|
|
||||||
`[Bot] Friday open-slot notice: no "${SIDE_OPS_NOTICE_ROLE_NAME}" role found and DISCORD_COMMUNITY_ZEUS_ROLE_ID is not set; posting without the role ping.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await channel.send(renderNotice(zeusRoleId));
|
|
||||||
payload.logger.info(
|
|
||||||
`[Bot] Posted Friday open-slot notice for ${token} in the side-ops channel.`,
|
|
||||||
);
|
|
||||||
return "posted";
|
|
||||||
}
|
|
||||||
|
|
||||||
const credited = creditedNameOf(notice.content);
|
|
||||||
if (!claim) {
|
|
||||||
if (!credited) return "idle";
|
|
||||||
await notice.edit(notice.content.replace(CLAIM_LINE_PATTERN, ""));
|
|
||||||
payload.logger.info(
|
|
||||||
`[Bot] Friday open-slot notice for ${token}: claim by ${credited} was cancelled; notice reverted to the open-slot state.`,
|
|
||||||
);
|
|
||||||
return "edited";
|
|
||||||
}
|
|
||||||
if (credited !== claim.userName) {
|
|
||||||
const base = credited ? notice.content.replace(CLAIM_LINE_PATTERN, "") : notice.content;
|
|
||||||
await notice.edit(`${base}\n\n${CLAIM_MARKER} ${claim.userName}. Thank you for hosting for our community!`);
|
|
||||||
payload.logger.info(
|
|
||||||
`[Bot] Friday open-slot notice for ${token} claimed by ${claim.userName}; notice updated.`,
|
|
||||||
);
|
|
||||||
return "edited";
|
|
||||||
}
|
|
||||||
return "idle";
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ReconcileSummary {
|
|
||||||
posted: number;
|
|
||||||
edited: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reconcile = async (client: Client, payload: Payload): Promise<ReconcileSummary> => {
|
|
||||||
if (reconcileRunning) return { posted: 0, edited: 0 };
|
|
||||||
if (!botConfig.sideOpsChannelId) return { posted: 0, edited: 0 };
|
|
||||||
reconcileRunning = true;
|
|
||||||
try {
|
|
||||||
const result = await processNotice(client, payload);
|
|
||||||
return { posted: result === "posted" ? 1 : 0, edited: result === "edited" ? 1 : 0 };
|
|
||||||
} catch (error) {
|
|
||||||
payload.logger.error(`[Bot] Friday open-slot notice failed: ${errorMessage(error)}`);
|
|
||||||
return { posted: 0, edited: 0 };
|
|
||||||
} finally {
|
|
||||||
reconcileRunning = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const start = (client: Client, payload: Payload): void => {
|
|
||||||
payload.logger.info("[Bot] Friday open-slot notice service started");
|
|
||||||
void reconcile(client, payload);
|
|
||||||
setInterval(() => {
|
|
||||||
void reconcile(client, payload);
|
|
||||||
}, botConfig.sideOpsNoticePollMs);
|
|
||||||
};
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { start as startTransferRequests } from "./transferRequests";
|
||||||
import { start as startEvaluationReminders } from "./evaluationReminders";
|
import { start as startEvaluationReminders } from "./evaluationReminders";
|
||||||
import { start as startReadyReminders } from "./readyReminders";
|
import { start as startReadyReminders } from "./readyReminders";
|
||||||
import { start as startImminentReminders } from "./imminentReminders";
|
import { start as startImminentReminders } from "./imminentReminders";
|
||||||
import { start as startFridayOpsNotice } from "./fridayOpsNotice";
|
|
||||||
|
|
||||||
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
||||||
|
|
||||||
|
|
@ -17,5 +16,4 @@ export const services: ServiceStarter[] = [
|
||||||
startEvaluationReminders,
|
startEvaluationReminders,
|
||||||
startReadyReminders,
|
startReadyReminders,
|
||||||
startImminentReminders,
|
startImminentReminders,
|
||||||
startFridayOpsNotice,
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -64,22 +64,8 @@ const operationTypeLabel = (type: Mission["operationType"]): string => {
|
||||||
/**
|
/**
|
||||||
* Deterministic fingerprint of the attendance state and scheduled start time
|
* Deterministic fingerprint of the attendance state and scheduled start time
|
||||||
* so reconcile can skip embeds that haven't changed (anti-loop), while still
|
* so reconcile can skip embeds that haven't changed (anti-loop), while still
|
||||||
* detecting mission date changes. The schema version forces one re-render of
|
* detecting mission date changes.
|
||||||
* already-live roll-calls when the embed layout changes (v2 added the
|
|
||||||
* mission-page link); bump it again for future layout-only changes.
|
|
||||||
*/
|
*/
|
||||||
const EMBED_SCHEMA_VERSION = 3;
|
|
||||||
|
|
||||||
const SUMMARY_MAX_CHARS = 300;
|
|
||||||
|
|
||||||
const renderSummaryForEmbed = (summary: string | null | undefined): string => {
|
|
||||||
if (!summary?.trim()) return "";
|
|
||||||
const text = summary.trim();
|
|
||||||
return text.length > SUMMARY_MAX_CHARS
|
|
||||||
? `${text.slice(0, SUMMARY_MAX_CHARS).trimEnd()}...`
|
|
||||||
: text;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const attendanceHash = (docs: MissionAttendance[], startDateTime?: string): string => {
|
export const attendanceHash = (docs: MissionAttendance[], startDateTime?: string): string => {
|
||||||
const rows = docs
|
const rows = docs
|
||||||
.map((doc) => {
|
.map((doc) => {
|
||||||
|
|
@ -87,7 +73,7 @@ export const attendanceHash = (docs: MissionAttendance[], startDateTime?: string
|
||||||
return [userId, doc.response] as const;
|
return [userId, doc.response] as const;
|
||||||
})
|
})
|
||||||
.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]));
|
.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]));
|
||||||
return JSON.stringify({ rows, startDateTime: startDateTime ?? null, v: EMBED_SCHEMA_VERSION });
|
return JSON.stringify({ rows, startDateTime: startDateTime ?? null });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAttendanceDocs = async (
|
export const fetchAttendanceDocs = async (
|
||||||
|
|
@ -186,15 +172,11 @@ export const renderAttendanceEmbed = (
|
||||||
summary: MissionAttendanceSummary,
|
summary: MissionAttendanceSummary,
|
||||||
): EmbedBuilder => {
|
): EmbedBuilder => {
|
||||||
const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000);
|
const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000);
|
||||||
const missionUrl = `${botConfig.appUrl}/intelligence/missions/${mission.id}`;
|
|
||||||
const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "-";
|
const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "-";
|
||||||
const summaryText = renderSummaryForEmbed(mission.summary);
|
|
||||||
return new EmbedBuilder()
|
return new EmbedBuilder()
|
||||||
.setTitle(missionTitle(mission))
|
.setTitle(missionTitle(mission))
|
||||||
.setURL(missionUrl)
|
|
||||||
.setDescription(
|
.setDescription(
|
||||||
[
|
[
|
||||||
...(summaryText ? [summaryText, ""] : []),
|
|
||||||
`**Operation Type:** ${operationTypeLabel(mission.operationType)}`,
|
`**Operation Type:** ${operationTypeLabel(mission.operationType)}`,
|
||||||
`**Starts:** <t:${startUnix}:F> (<t:${startUnix}:R>)`,
|
`**Starts:** <t:${startUnix}:F> (<t:${startUnix}:R>)`,
|
||||||
`**Max Players:** ${mission.missionRoles.maxPlayers}`,
|
`**Max Players:** ${mission.missionRoles.maxPlayers}`,
|
||||||
|
|
@ -209,7 +191,6 @@ export const renderAttendanceEmbed = (
|
||||||
inline: true,
|
inline: true,
|
||||||
},
|
},
|
||||||
{ name: `❌ No (${summary.counts.no})`, value: names(summary.no), inline: true },
|
{ name: `❌ No (${summary.counts.no})`, value: names(summary.no), inline: true },
|
||||||
{ name: "Open the op", value: missionUrl },
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
import type { Payload } from "payload";
|
|
||||||
import type { User } from "@/payload-types";
|
|
||||||
import { superuserUserIds } from "@/utils/access-control/hasPermission";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Friday open-slot notice for the Discord bot.
|
|
||||||
*
|
|
||||||
* At 8 PM Eastern on Wednesday (the deploy container may run any server
|
|
||||||
* timezone, so the clock is evaluated in America/New_York) and no op is
|
|
||||||
* scheduled for that week's upcoming Friday yet, the bot posts once in
|
|
||||||
* #side-ops pinging the Community Zeus role: the slot is open, they are
|
|
||||||
* welcome to host an op, and if nobody picks it up the slot either stays
|
|
||||||
* empty (no op that Friday) or Jason hosts it himself. If the bot misses the
|
|
||||||
* whole Wednesday it stays quiet until the following week's notice.
|
|
||||||
*
|
|
||||||
* The shared logic lives here (not in the bot) to keep it testable and free
|
|
||||||
* of discord.js imports, mirroring the other reminder libs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Fallback role name resolved in the guild when no role ID is configured. */
|
|
||||||
export const SIDE_OPS_NOTICE_ROLE_NAME = "Community Zeus";
|
|
||||||
|
|
||||||
export const NOTICE_HOUR_ET = 20;
|
|
||||||
|
|
||||||
const ET_TIME_ZONE = "America/New_York";
|
|
||||||
|
|
||||||
const etParts = (now: Date): { weekday: string; hour: number } => {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: ET_TIME_ZONE,
|
|
||||||
weekday: "short",
|
|
||||||
hour: "numeric",
|
|
||||||
hour12: false,
|
|
||||||
}).formatToParts(now);
|
|
||||||
const get = (type: string): string => parts.find((part) => part.type === type)?.value ?? "";
|
|
||||||
const hour = Number.parseInt(get("hour"), 10);
|
|
||||||
return { weekday: get("weekday"), hour: hour === 24 ? 0 : hour };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The notice is due once Wednesday 8 PM Eastern has passed, and until the
|
|
||||||
* ET day rolls over to Thursday: a bot restart after 8 PM still catches up
|
|
||||||
* within the evening, but a fully missed Wednesday waits for next week.
|
|
||||||
*/
|
|
||||||
export const isNoticeDue = (now: Date): boolean => {
|
|
||||||
const { weekday, hour } = etParts(now);
|
|
||||||
return weekday === "Wed" && hour >= NOTICE_HOUR_ET;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start/end/epoch of the upcoming Friday (server-local), strictly after
|
|
||||||
* `now`. Used both for the mission-existence query and for the stable
|
|
||||||
* per-Friday dedup token.
|
|
||||||
*/
|
|
||||||
export const upcomingFridayRange = (now: Date): { start: Date; end: Date; epoch: number } => {
|
|
||||||
const start = new Date(now);
|
|
||||||
const daysUntilFriday = (5 - start.getDay() + 7) % 7 || 7;
|
|
||||||
start.setDate(start.getDate() + daysUntilFriday);
|
|
||||||
start.setHours(0, 0, 0, 0);
|
|
||||||
const end = new Date(start);
|
|
||||||
end.setHours(23, 59, 59, 999);
|
|
||||||
return { start, end, epoch: Math.floor(start.getTime() / 1000) };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Friday slot counts as taken when any non-cancelled mission (any status,
|
|
||||||
* any operation type) starts on that Friday: even a draft means someone is
|
|
||||||
* already working on the slot.
|
|
||||||
*/
|
|
||||||
export const hasMissionOnFriday = async (payload: Payload, now: Date): Promise<boolean> => {
|
|
||||||
const { start, end } = upcomingFridayRange(now);
|
|
||||||
const res = await payload.find({
|
|
||||||
collection: "missions",
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
limit: 1,
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ "classification.startDateTime": { greater_than_equal: start.toISOString() } },
|
|
||||||
{ "classification.startDateTime": { less_than_equal: end.toISOString() } },
|
|
||||||
{ "ownershipAndStatus.status": { not_equals: "Cancelled" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return res.totalDocs > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface FridayClaim {
|
|
||||||
userName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The community claim on the upcoming Friday slot: the earliest-created
|
|
||||||
* non-cancelled mission whose creator is NOT a superuser, resolved to a
|
|
||||||
* display name. Jason (or any other superuser) hosting the op himself does
|
|
||||||
* not claim the slot as a community zeus claim, and staff-created missions
|
|
||||||
* without a creator do not either. Returns null when only superuser-created
|
|
||||||
* missions (or nothing) hold the Friday.
|
|
||||||
*/
|
|
||||||
export const getFridayClaimant = async (
|
|
||||||
payload: Payload,
|
|
||||||
now: Date,
|
|
||||||
): Promise<FridayClaim | null> => {
|
|
||||||
const { start, end } = upcomingFridayRange(now);
|
|
||||||
const res = await payload.find({
|
|
||||||
collection: "missions",
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 1,
|
|
||||||
limit: 25,
|
|
||||||
sort: "createdAt",
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ "classification.startDateTime": { greater_than_equal: start.toISOString() } },
|
|
||||||
{ "classification.startDateTime": { less_than_equal: end.toISOString() } },
|
|
||||||
{ "ownershipAndStatus.status": { not_equals: "Cancelled" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const creators = res.docs
|
|
||||||
.map((mission) => (typeof mission.createdBy === "object" ? (mission.createdBy as User) : null))
|
|
||||||
.filter((creator): creator is User => creator != null);
|
|
||||||
if (creators.length === 0) return null;
|
|
||||||
const superIds = await superuserUserIds(payload);
|
|
||||||
const claimant = creators.find((creator) => !superIds.includes(Number(creator.id)));
|
|
||||||
if (!claimant) return null;
|
|
||||||
return { userName: claimant.displayName ?? claimant.username };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stable per-Friday token embedded in the notice message (renders as the
|
|
||||||
* Friday's date) so the bot can detect an existing notice by scanning recent
|
|
||||||
* channel messages, surviving restarts without any DB state.
|
|
||||||
*/
|
|
||||||
export const noticeToken = (now: Date): string => `<t:${upcomingFridayRange(now).epoch}:D>`;
|
|
||||||
|
|
@ -1,297 +0,0 @@
|
||||||
import { getPayload, Payload } from "payload";
|
|
||||||
import config from "@/payload.config";
|
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
||||||
import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
|
|
||||||
import {
|
|
||||||
getFridayClaimant,
|
|
||||||
hasMissionOnFriday,
|
|
||||||
isNoticeDue,
|
|
||||||
noticeToken,
|
|
||||||
upcomingFridayRange,
|
|
||||||
} from "@/lib/intelligence/fridayOpsNotice";
|
|
||||||
|
|
||||||
let payload: Payload;
|
|
||||||
|
|
||||||
const RUN = `fon-${Date.now().toString(36)}`;
|
|
||||||
|
|
||||||
// Hermetic window: 2027-01-13 is a Wednesday far enough out that the shared
|
|
||||||
// dev database holds no real missions on its Friday (2027-01-15).
|
|
||||||
const WEDNESDAY = new Date(2027, 0, 13, 12, 0, 0, 0);
|
|
||||||
const TUESDAY = new Date(2027, 0, 12, 12, 0, 0, 0);
|
|
||||||
const FRIDAY = new Date(2027, 0, 15, 12, 0, 0, 0);
|
|
||||||
const FRIDAY_EVENING = new Date(2027, 0, 15, 20, 0, 0, 0);
|
|
||||||
const SATURDAY = new Date(2027, 0, 16, 20, 0, 0, 0);
|
|
||||||
|
|
||||||
describe("Friday open-slot notice", () => {
|
|
||||||
let mapId: number;
|
|
||||||
let campaignId: number;
|
|
||||||
let authorId: number;
|
|
||||||
let zeusId: number;
|
|
||||||
let superuserId: number;
|
|
||||||
const missionIds: number[] = [];
|
|
||||||
const userIds: number[] = [];
|
|
||||||
const roleIds: number[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const payloadConfig = await config;
|
|
||||||
payload = await getPayload({ config: payloadConfig });
|
|
||||||
|
|
||||||
// Sweep leftovers from crashed earlier runs: a stray Concept mission in
|
|
||||||
// the fixture window would flip the first hasMissionOnFriday assertion.
|
|
||||||
const stale = await payload.find({
|
|
||||||
collection: "missions",
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
limit: 100,
|
|
||||||
where: { codeName: { like: "fon-" } },
|
|
||||||
});
|
|
||||||
for (const doc of stale.docs) {
|
|
||||||
await payload.delete({ collection: "missions", id: doc.id, overrideAccess: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = (await payload.create({
|
|
||||||
collection: "maps",
|
|
||||||
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
})) as unknown as MissionMap;
|
|
||||||
mapId = map.id;
|
|
||||||
|
|
||||||
const campaign = (await payload.create({
|
|
||||||
collection: "campaigns",
|
|
||||||
data: { name: `${RUN} Campaign`, summary: "Friday notice test campaign", status: "concept", campaignMode: "custom" },
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
})) as unknown as Campaign;
|
|
||||||
campaignId = campaign.id;
|
|
||||||
|
|
||||||
const author = (await payload.create({
|
|
||||||
collection: "users",
|
|
||||||
data: {
|
|
||||||
username: `${RUN}-author`,
|
|
||||||
discordUsername: `${RUN}-author`,
|
|
||||||
displayName: "AUTHOR",
|
|
||||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
|
||||||
password: "Test123",
|
|
||||||
roles: ["user"],
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
})) as unknown as User;
|
|
||||||
authorId = author.id;
|
|
||||||
userIds.push(authorId);
|
|
||||||
|
|
||||||
const zeus = (await payload.create({
|
|
||||||
collection: "users",
|
|
||||||
data: {
|
|
||||||
username: `${RUN}-zeus`,
|
|
||||||
discordUsername: `${RUN}-zeus`,
|
|
||||||
displayName: "ZEUS",
|
|
||||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
|
||||||
password: "Test123",
|
|
||||||
roles: ["user"],
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
})) as unknown as User;
|
|
||||||
zeusId = zeus.id;
|
|
||||||
userIds.push(zeusId);
|
|
||||||
|
|
||||||
const superRole = (await payload.create({
|
|
||||||
collection: "roles",
|
|
||||||
data: { name: `FON Superuser ${RUN}`, slug: "", isSuperuser: true },
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
})) as unknown as { id: number };
|
|
||||||
roleIds.push(superRole.id);
|
|
||||||
|
|
||||||
const superuser = (await payload.create({
|
|
||||||
collection: "users",
|
|
||||||
data: {
|
|
||||||
username: `${RUN}-su`,
|
|
||||||
discordUsername: `${RUN}-su`,
|
|
||||||
displayName: "SU",
|
|
||||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
|
||||||
password: "Test123",
|
|
||||||
roles: ["user"],
|
|
||||||
roleDocs: [superRole.id],
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
})) as unknown as User;
|
|
||||||
superuserId = superuser.id;
|
|
||||||
userIds.push(superuserId);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
for (const id of missionIds) {
|
|
||||||
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
await payload
|
|
||||||
.delete({ collection: "campaigns", id: campaignId, overrideAccess: true })
|
|
||||||
.catch(() => {});
|
|
||||||
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
|
|
||||||
// User deletion trips FK constraints unless the hook-provisioned
|
|
||||||
// personal bank account and profile are removed first.
|
|
||||||
for (const uid of userIds) {
|
|
||||||
const accounts = await payload
|
|
||||||
.find({
|
|
||||||
collection: "bank-accounts",
|
|
||||||
where: { ownerUser: { equals: uid } },
|
|
||||||
limit: 5,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
})
|
|
||||||
.catch(() => null);
|
|
||||||
for (const account of accounts?.docs ?? []) {
|
|
||||||
await payload
|
|
||||||
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
const profiles = await payload
|
|
||||||
.find({
|
|
||||||
collection: "profiles",
|
|
||||||
where: { user: { equals: uid } },
|
|
||||||
limit: 5,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
})
|
|
||||||
.catch(() => null);
|
|
||||||
for (const profile of profiles?.docs ?? []) {
|
|
||||||
await payload
|
|
||||||
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
await payload.delete({ collection: "users", id: uid, overrideAccess: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
for (const rid of roleIds) {
|
|
||||||
await payload.delete({ collection: "roles", id: rid, overrideAccess: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeMission = async (
|
|
||||||
label: string,
|
|
||||||
start: Date,
|
|
||||||
status: Mission["ownershipAndStatus"]["status"] = "Scheduled",
|
|
||||||
creatorId?: number,
|
|
||||||
): Promise<Mission> => {
|
|
||||||
const mission = (await payload.create({
|
|
||||||
collection: "missions",
|
|
||||||
data: {
|
|
||||||
name: `${RUN} ${label}`,
|
|
||||||
codeName: `${RUN}-${label}`,
|
|
||||||
summary: "Friday notice test mission",
|
|
||||||
operationType: "side",
|
|
||||||
classification: {
|
|
||||||
map: mapId,
|
|
||||||
missionType: "PvE",
|
|
||||||
campaign: campaignId,
|
|
||||||
startDateTime: start.toISOString(),
|
|
||||||
estimatedDuration: 60,
|
|
||||||
},
|
|
||||||
ownershipAndStatus: {
|
|
||||||
authors: [authorId],
|
|
||||||
status,
|
|
||||||
visibility: "unit",
|
|
||||||
},
|
|
||||||
missionRoles: {
|
|
||||||
maxPlayers: 16,
|
|
||||||
},
|
|
||||||
gameDetails: {
|
|
||||||
serverDetails: {
|
|
||||||
serverIp: "127.0.0.1",
|
|
||||||
serverPort: 2302,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
briefing: [],
|
|
||||||
...(creatorId ? { createdBy: creatorId } : {}),
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
})) as unknown as Mission;
|
|
||||||
missionIds.push(mission.id);
|
|
||||||
return mission;
|
|
||||||
};
|
|
||||||
|
|
||||||
it("is due on Wednesdays at 8 PM Eastern, until the ET day rolls over", () => {
|
|
||||||
// 2026-09-16 is a Wednesday in EDT (UTC-4); January is EST (UTC-5).
|
|
||||||
expect(isNoticeDue(new Date("2026-09-17T00:30:00Z"))).toBe(true); // Wed 20:30 ET
|
|
||||||
expect(isNoticeDue(new Date("2026-09-17T03:59:00Z"))).toBe(true); // Wed 23:59 ET
|
|
||||||
expect(isNoticeDue(new Date("2026-09-15T20:30:00Z"))).toBe(false); // Tue 16:30 ET
|
|
||||||
expect(isNoticeDue(new Date("2026-09-16T16:00:00Z"))).toBe(false); // Wed 12:00 ET
|
|
||||||
expect(isNoticeDue(new Date("2026-09-16T23:59:00Z"))).toBe(false); // Wed 19:59 ET
|
|
||||||
expect(isNoticeDue(new Date("2026-09-17T00:00:00Z"))).toBe(true); // Wed 20:00 ET sharp
|
|
||||||
expect(isNoticeDue(new Date("2026-09-17T04:00:00Z"))).toBe(false); // Thu 00:00 ET
|
|
||||||
expect(isNoticeDue(new Date("2026-01-15T01:00:00Z"))).toBe(true); // Wed 20:00 ET (EST)
|
|
||||||
});
|
|
||||||
|
|
||||||
it("targets the upcoming Friday, strictly in the future", () => {
|
|
||||||
const fromWednesday = upcomingFridayRange(WEDNESDAY);
|
|
||||||
expect(fromWednesday.start).toEqual(new Date(2027, 0, 15, 0, 0, 0, 0));
|
|
||||||
expect(fromWednesday.end).toEqual(new Date(2027, 0, 15, 23, 59, 59, 999));
|
|
||||||
expect(fromWednesday.epoch).toBe(Math.floor(fromWednesday.start.getTime() / 1000));
|
|
||||||
|
|
||||||
// Earlier in the week still lands on the same Friday.
|
|
||||||
expect(upcomingFridayRange(new Date(2027, 0, 11, 8, 0, 0, 0)).start).toEqual(
|
|
||||||
fromWednesday.start,
|
|
||||||
);
|
|
||||||
// On a Friday itself, the next Friday is targeted instead.
|
|
||||||
expect(upcomingFridayRange(FRIDAY).start).toEqual(new Date(2027, 0, 22, 0, 0, 0, 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds a stable per-Friday dedup token", () => {
|
|
||||||
expect(noticeToken(WEDNESDAY)).toBe(noticeToken(TUESDAY));
|
|
||||||
expect(noticeToken(WEDNESDAY)).toBe(`<t:${upcomingFridayRange(WEDNESDAY).epoch}:D>`);
|
|
||||||
expect(noticeToken(FRIDAY)).not.toBe(noticeToken(WEDNESDAY));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats the Friday slot as open only when a non-cancelled mission starts that Friday", async () => {
|
|
||||||
await expect(hasMissionOnFriday(payload, WEDNESDAY)).resolves.toBe(false);
|
|
||||||
|
|
||||||
const concept = await makeMission("concept-friday", FRIDAY_EVENING, "Concept");
|
|
||||||
await expect(hasMissionOnFriday(payload, WEDNESDAY)).resolves.toBe(true);
|
|
||||||
|
|
||||||
await makeMission("cancelled-friday", FRIDAY_EVENING, "Cancelled");
|
|
||||||
await expect(hasMissionOnFriday(payload, WEDNESDAY)).resolves.toBe(true);
|
|
||||||
|
|
||||||
await payload.delete({ collection: "missions", id: concept.id, overrideAccess: true });
|
|
||||||
await expect(hasMissionOnFriday(payload, WEDNESDAY)).resolves.toBe(false);
|
|
||||||
|
|
||||||
const saturday = await makeMission("saturday-mission", SATURDAY);
|
|
||||||
await expect(hasMissionOnFriday(payload, WEDNESDAY)).resolves.toBe(false);
|
|
||||||
|
|
||||||
await payload.delete({ collection: "missions", id: saturday.id, overrideAccess: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("credits the earliest non-superuser creator as the claimant", async () => {
|
|
||||||
await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toBeNull();
|
|
||||||
|
|
||||||
const zeusMission = await makeMission("zeus-claim", FRIDAY_EVENING, "Scheduled", zeusId);
|
|
||||||
await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toEqual({ userName: "ZEUS" });
|
|
||||||
|
|
||||||
await makeMission("su-claim", FRIDAY_EVENING, "Scheduled", superuserId);
|
|
||||||
await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toEqual({ userName: "ZEUS" });
|
|
||||||
|
|
||||||
await payload.delete({ collection: "missions", id: zeusMission.id, overrideAccess: true });
|
|
||||||
await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toBeNull();
|
|
||||||
|
|
||||||
const first = await makeMission("zeus-first", FRIDAY_EVENING, "Scheduled", zeusId);
|
|
||||||
const second = await makeMission("zeus-second", FRIDAY_EVENING, "Scheduled", zeusId);
|
|
||||||
await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toEqual({ userName: "ZEUS" });
|
|
||||||
expect(first.createdAt <= second.createdAt).toBe(true);
|
|
||||||
|
|
||||||
await payload.delete({ collection: "missions", id: first.id, overrideAccess: true });
|
|
||||||
await payload.delete({ collection: "missions", id: second.id, overrideAccess: true });
|
|
||||||
|
|
||||||
const claimer = await makeMission("zeus-cancelled", FRIDAY_EVENING, "Scheduled", zeusId);
|
|
||||||
await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toEqual({ userName: "ZEUS" });
|
|
||||||
await payload.update({
|
|
||||||
collection: "missions",
|
|
||||||
id: claimer.id,
|
|
||||||
data: { ownershipAndStatus: { status: "Cancelled" } },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
await expect(getFridayClaimant(payload, WEDNESDAY)).resolves.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in a new issue