Compare commits
No commits in common. "7ccd825b8c2caaa2ccbdd4ce6792424d13f4a6c3" and "308a522764306a2dae9fc9b949ecd3dd06f9b5fc" have entirely different histories.
7ccd825b8c
...
308a522764
16 changed files with 6 additions and 29569 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), 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), 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), 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.
|
- **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), and `evaluationReminders` (post-mission evaluation reminder DMs); `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.
|
||||||
|
|
|
||||||
|
|
@ -129,12 +129,6 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
|
||||||
- **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.
|
- **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.
|
- **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.
|
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
@ -151,7 +145,6 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
|
||||||
| `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 |
|
| `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
`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.42",
|
"version": "0.2.41",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ bot/
|
||||||
missionEmbeds.ts # Attendance embed lifecycle + reconcile loop (poll tick)
|
missionEmbeds.ts # Attendance embed lifecycle + reconcile loop (poll tick)
|
||||||
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)
|
|
||||||
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
|
||||||
|
|
@ -62,10 +61,6 @@ Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility
|
||||||
|
|
||||||
Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start), sends a one-shot DM per linked user asking them to rate their leadership (leaders also get a "rate your subordinates" section). Recipient plan: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker `evaluationRemindersSentAt` on Missions (withheld if the 40-DM/tick cap is hit — retries next tick). DMs ignore `preferences.discord.enabled` (defaults false, not yet exposed in web UI). Staff can re-send manually via `/remind-evaluations`.
|
Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start), sends a one-shot DM per linked user asking them to rate their leadership (leaders also get a "rate your subordinates" section). Recipient plan: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker `evaluationRemindersSentAt` on Missions (withheld if the 40-DM/tick cap is hit — retries next tick). DMs ignore `preferences.discord.enabled` (defaults false, not yet exposed in web UI). Staff can re-send manually via `/remind-evaluations`.
|
||||||
|
|
||||||
## Feature flow: imminent op reminder
|
|
||||||
|
|
||||||
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: 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.
|
||||||
|
|
@ -79,7 +74,6 @@ Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start
|
||||||
| Modify embed lifecycle | `bot/services/missionEmbeds.ts` |
|
| Modify embed lifecycle | `bot/services/missionEmbeds.ts` |
|
||||||
| 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` |
|
|
||||||
| 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` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ export interface BotConfig {
|
||||||
notificationPollMs: number;
|
notificationPollMs: number;
|
||||||
evaluationPollMs: number;
|
evaluationPollMs: number;
|
||||||
readyReminderPollMs: number;
|
readyReminderPollMs: number;
|
||||||
imminentReminderPollMs: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = (name: string): string => {
|
const required = (name: string): string => {
|
||||||
|
|
@ -51,5 +50,4 @@ export const botConfig: BotConfig = {
|
||||||
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),
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
import { EmbedBuilder, type Client, type Message } from "discord.js";
|
|
||||||
import type { Payload } from "payload";
|
|
||||||
import type { Mission, User } from "@/payload-types";
|
|
||||||
import { botConfig } from "@/bot/config";
|
|
||||||
import { getMissionAttendance } from "@/lib/attendance";
|
|
||||||
import { missionTitle } from "@/lib/intelligence/readyReminders";
|
|
||||||
import { getImminentReminderMissions } from "@/lib/intelligence/imminentReminders";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imminent-op reminder service: 15 minutes before a posted op starts, open a
|
|
||||||
* thread on the roll-call message and ping everyone who RSVP'd yes, so the
|
|
||||||
* attendees get a last-call nudge that the op is imminent.
|
|
||||||
*
|
|
||||||
* The pings deliberately ignore `preferences.discord.enabled` (that toggle
|
|
||||||
* gates the generic notification DM bridge; a yes RSVP is an explicit intent
|
|
||||||
* to attend). One-shot per mission via the imminentReminderSentAt marker,
|
|
||||||
* which the cancel flow clears so a rescheduled op re-reminds.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const EMBED_COLOR = 0xf59e0b;
|
|
||||||
const MISSIONS_PER_TICK = 10;
|
|
||||||
const THREAD_NAME_MAX = 100;
|
|
||||||
|
|
||||||
let reconcileRunning = false;
|
|
||||||
|
|
||||||
const errorMessage = (error: unknown): string =>
|
|
||||||
error instanceof Error ? error.message : "Unknown error";
|
|
||||||
|
|
||||||
const renderReminderEmbed = (mission: Mission): EmbedBuilder => {
|
|
||||||
const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000);
|
|
||||||
return new EmbedBuilder()
|
|
||||||
.setTitle(`⏰ Op starting soon: ${missionTitle(mission)}`)
|
|
||||||
.setDescription(`The operation starts <t:${startUnix}:R>. Load up and check in here.`)
|
|
||||||
.setColor(EMBED_COLOR)
|
|
||||||
.addFields({
|
|
||||||
name: "Open the op",
|
|
||||||
value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const mentionsFor = (users: User[]): string[] =>
|
|
||||||
users
|
|
||||||
.filter((user) => user.discordId)
|
|
||||||
.map((user) => `<@${user.discordId as string}>`);
|
|
||||||
|
|
||||||
const fetchRollCallMessage = async (
|
|
||||||
client: Client,
|
|
||||||
opsChannelId: string,
|
|
||||||
messageId: string,
|
|
||||||
): Promise<Message> => {
|
|
||||||
const channel = await client.channels.fetch(opsChannelId);
|
|
||||||
if (!channel || !("messages" in channel)) {
|
|
||||||
throw new Error(`[Bot] Ops channel ${opsChannelId} is not a text channel.`);
|
|
||||||
}
|
|
||||||
return channel.messages.fetch(messageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const processMission = async (
|
|
||||||
client: Client,
|
|
||||||
payload: Payload,
|
|
||||||
mission: Mission,
|
|
||||||
now: Date,
|
|
||||||
): Promise<void> => {
|
|
||||||
const opsChannelId = botConfig.opsChannelId;
|
|
||||||
if (!opsChannelId || !mission.discordMessageId) return;
|
|
||||||
|
|
||||||
const summary = await getMissionAttendance(payload, mission.id);
|
|
||||||
const message = await fetchRollCallMessage(client, opsChannelId, mission.discordMessageId);
|
|
||||||
const thread = await message.startThread({
|
|
||||||
name: `Op starting soon: ${missionTitle(mission)}`.slice(0, THREAD_NAME_MAX),
|
|
||||||
autoArchiveDuration: 60,
|
|
||||||
});
|
|
||||||
const mentions = mentionsFor(summary.yes);
|
|
||||||
await thread.send({
|
|
||||||
content: mentions.length > 0 ? mentions.join(" ") : undefined,
|
|
||||||
embeds: [renderReminderEmbed(mission)],
|
|
||||||
});
|
|
||||||
|
|
||||||
await payload.update({
|
|
||||||
collection: "missions",
|
|
||||||
id: mission.id,
|
|
||||||
data: { imminentReminderSentAt: now.toISOString() },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
payload.logger.info(
|
|
||||||
`[Bot] Posted imminent-op reminder thread for mission #${mission.id} (${mission.name}); pinged ${mentions.length} attendee(s).`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ReconcileSummary {
|
|
||||||
processed: number;
|
|
||||||
sent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reconcile = async (client: Client, payload: Payload): Promise<ReconcileSummary> => {
|
|
||||||
if (reconcileRunning) return { processed: 0, sent: 0 };
|
|
||||||
if (!botConfig.opsChannelId) return { processed: 0, sent: 0 };
|
|
||||||
reconcileRunning = true;
|
|
||||||
let processed = 0;
|
|
||||||
let sent = 0;
|
|
||||||
try {
|
|
||||||
const now = new Date();
|
|
||||||
const missions = (await getImminentReminderMissions(payload, now)).slice(0, MISSIONS_PER_TICK);
|
|
||||||
for (const mission of missions) {
|
|
||||||
processed += 1;
|
|
||||||
try {
|
|
||||||
await processMission(client, payload, mission, now);
|
|
||||||
sent += 1;
|
|
||||||
} catch (error) {
|
|
||||||
payload.logger.error(
|
|
||||||
`[Bot] Imminent reminder failed for mission #${mission.id}: ${errorMessage(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
payload.logger.error(`[Bot] Imminent reminder reconcile failed: ${errorMessage(error)}`);
|
|
||||||
} finally {
|
|
||||||
reconcileRunning = false;
|
|
||||||
}
|
|
||||||
return { processed, sent };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const start = (client: Client, payload: Payload): void => {
|
|
||||||
payload.logger.info("[Bot] Imminent reminder service started");
|
|
||||||
void reconcile(client, payload);
|
|
||||||
setInterval(() => {
|
|
||||||
void reconcile(client, payload);
|
|
||||||
}, botConfig.imminentReminderPollMs);
|
|
||||||
};
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { start as startNotificationBridge } from "./notificationBridge";
|
||||||
import { start as startTransferRequests } from "./transferRequests";
|
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";
|
|
||||||
|
|
||||||
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
||||||
|
|
||||||
|
|
@ -15,5 +14,4 @@ export const services: ServiceStarter[] = [
|
||||||
startTransferRequests,
|
startTransferRequests,
|
||||||
startEvaluationReminders,
|
startEvaluationReminders,
|
||||||
startReadyReminders,
|
startReadyReminders,
|
||||||
startImminentReminders,
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -509,9 +509,9 @@ export const reconcileMission = async (
|
||||||
/**
|
/**
|
||||||
* A cancelled op gets its roll-call edited into the cancelled state (buttons
|
* A cancelled op gets its roll-call edited into the cancelled state (buttons
|
||||||
* removed) plus a standalone cancellation notice. The message id, attendance
|
* removed) plus a standalone cancellation notice. The message id, attendance
|
||||||
* hash, one-shot send marker, and imminent-reminder marker are cleared, and
|
* hash, and one-shot send marker are cleared, and `discordCancelledAt` marks
|
||||||
* `discordCancelledAt` marks the cancellation so a later reschedule can be
|
* the cancellation so a later reschedule can be announced and get a fresh
|
||||||
* announced and get a fresh roll-call.
|
* roll-call.
|
||||||
*/
|
*/
|
||||||
const cancelMissionEmbed = async (
|
const cancelMissionEmbed = async (
|
||||||
client: Client,
|
client: Client,
|
||||||
|
|
@ -536,7 +536,6 @@ const cancelMissionEmbed = async (
|
||||||
discordMessageId: null,
|
discordMessageId: null,
|
||||||
discordAttendanceHash: null,
|
discordAttendanceHash: null,
|
||||||
discordAttendanceSentAt: null,
|
discordAttendanceSentAt: null,
|
||||||
imminentReminderSentAt: null,
|
|
||||||
discordCancelledAt: new Date().toISOString(),
|
discordCancelledAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
|
|
|
||||||
|
|
@ -255,15 +255,6 @@ export const Missions: CollectionConfig = {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "imminentReminderSentAt",
|
|
||||||
type: "date",
|
|
||||||
admin: {
|
|
||||||
description:
|
|
||||||
"When the imminent-op thread reminder was posted to Discord for this mission (managed by the Discord bot, one-shot; cleared on cancel so a reschedule re-reminds).",
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "ticketsSettledAt",
|
name: "ticketsSettledAt",
|
||||||
type: "date",
|
type: "date",
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import type { Payload } from "payload";
|
|
||||||
import type { Mission } from "@/payload-types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imminent-op reminder for the Discord bot.
|
|
||||||
*
|
|
||||||
* When a posted op (roll-call already live in the ops channel) is 15 minutes
|
|
||||||
* from starting, the bot opens a thread on the roll-call message and pings
|
|
||||||
* everyone who RSVP'd yes, as a last-call reminder that the op is imminent.
|
|
||||||
*
|
|
||||||
* A one-shot marker on the mission (imminentReminderSentAt) keeps it to a
|
|
||||||
* single thread per occurrence; the bot's cancel flow clears the marker so a
|
|
||||||
* rescheduled op reminds again for its new start time.
|
|
||||||
*
|
|
||||||
* The shared query lives here (not in the bot) to keep the window logic
|
|
||||||
* testable and free of discord.js imports, mirroring readyReminders.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** How far before the start time the reminder fires. */
|
|
||||||
export const IMMINENT_REMINDER_WINDOW_MS = 15 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far past the start time a still-unsent reminder may fire (bounds staleness
|
|
||||||
* when the bot was down through the whole window).
|
|
||||||
*/
|
|
||||||
export const IMMINENT_REMINDER_GRACE_MS = 15 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ops due for the imminent reminder: unit-visible, Ready/Scheduled, with a
|
|
||||||
* live roll-call message, starting within the reminder window (or just past
|
|
||||||
* it, within the grace bound), and without the one-shot marker yet.
|
|
||||||
*/
|
|
||||||
export const getImminentReminderMissions = async (
|
|
||||||
payload: Payload,
|
|
||||||
now = new Date(),
|
|
||||||
): Promise<Mission[]> => {
|
|
||||||
const windowStartIso = new Date(now.getTime() - IMMINENT_REMINDER_GRACE_MS).toISOString();
|
|
||||||
const windowEndIso = new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS).toISOString();
|
|
||||||
const res = await payload.find({
|
|
||||||
collection: "missions",
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
sort: "classification.startDateTime",
|
|
||||||
limit: 50,
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
|
||||||
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } },
|
|
||||||
{ discordMessageId: { exists: true } },
|
|
||||||
{ imminentReminderSentAt: { exists: false } },
|
|
||||||
{ "classification.startDateTime": { greater_than: windowStartIso } },
|
|
||||||
{ "classification.startDateTime": { less_than_equal: windowEndIso } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return res.docs as unknown as Mission[];
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +0,0 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
|
||||||
|
|
||||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "missions" ADD COLUMN "imminent_reminder_sent_at" timestamp(3) with time zone;`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "missions" DROP COLUMN "imminent_reminder_sent_at";`)
|
|
||||||
}
|
|
||||||
|
|
@ -65,7 +65,6 @@ import * as migration_20260916_185544_construction_timestamp_model from "./20260
|
||||||
import * as migration_20260916_220224_add_map_labels_and_styling from "./20260916_220224_add_map_labels_and_styling";
|
import * as migration_20260916_220224_add_map_labels_and_styling from "./20260916_220224_add_map_labels_and_styling";
|
||||||
import * as migration_20260917_023028_add_node_and_zone_resources from "./20260917_023028_add_node_and_zone_resources";
|
import * as migration_20260917_023028_add_node_and_zone_resources from "./20260917_023028_add_node_and_zone_resources";
|
||||||
import * as migration_20260917_112258_add_zone_political_types from "./20260917_112258_add_zone_political_types";
|
import * as migration_20260917_112258_add_zone_political_types from "./20260917_112258_add_zone_political_types";
|
||||||
import * as migration_20260918_170318_add_imminent_reminder_marker from "./20260918_170318_add_imminent_reminder_marker";
|
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -403,9 +402,4 @@ export const migrations = [
|
||||||
down: migration_20260917_112258_add_zone_political_types.down,
|
down: migration_20260917_112258_add_zone_political_types.down,
|
||||||
name: "20260917_112258_add_zone_political_types",
|
name: "20260917_112258_add_zone_political_types",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
up: migration_20260918_170318_add_imminent_reminder_marker.up,
|
|
||||||
down: migration_20260918_170318_add_imminent_reminder_marker.down,
|
|
||||||
name: "20260918_170318_add_imminent_reminder_marker",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2602,11 +2602,6 @@ export const missions = pgTable(
|
||||||
withTimezone: true,
|
withTimezone: true,
|
||||||
precision: 3,
|
precision: 3,
|
||||||
}),
|
}),
|
||||||
imminentReminderSentAt: timestamp("imminent_reminder_sent_at", {
|
|
||||||
mode: "string",
|
|
||||||
withTimezone: true,
|
|
||||||
precision: 3,
|
|
||||||
}),
|
|
||||||
ticketsSettledAt: timestamp("tickets_settled_at", {
|
ticketsSettledAt: timestamp("tickets_settled_at", {
|
||||||
mode: "string",
|
mode: "string",
|
||||||
withTimezone: true,
|
withTimezone: true,
|
||||||
|
|
|
||||||
|
|
@ -2592,10 +2592,6 @@ export interface Mission {
|
||||||
* Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).
|
* Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).
|
||||||
*/
|
*/
|
||||||
readyReminderDmSentAt?: string | null;
|
readyReminderDmSentAt?: string | null;
|
||||||
/**
|
|
||||||
* When the imminent-op thread reminder was posted to Discord for this mission (managed by the Discord bot, one-shot; cleared on cancel so a reschedule re-reminds).
|
|
||||||
*/
|
|
||||||
imminentReminderSentAt?: string | null;
|
|
||||||
/**
|
/**
|
||||||
* When respawn tickets for this mission were settled into player balances (managed by the arma-bridge tickets route; one-shot per mission).
|
* When respawn tickets for this mission were settled into player balances (managed by the arma-bridge tickets route; one-shot per mission).
|
||||||
*/
|
*/
|
||||||
|
|
@ -6406,7 +6402,6 @@ export interface MissionsSelect<T extends boolean = true> {
|
||||||
evaluationRemindersSentAt?: T;
|
evaluationRemindersSentAt?: T;
|
||||||
readyReminderWebSentAt?: T;
|
readyReminderWebSentAt?: T;
|
||||||
readyReminderDmSentAt?: T;
|
readyReminderDmSentAt?: T;
|
||||||
imminentReminderSentAt?: T;
|
|
||||||
ticketsSettledAt?: T;
|
ticketsSettledAt?: T;
|
||||||
createdBy?: T;
|
createdBy?: T;
|
||||||
coverImage?: T;
|
coverImage?: T;
|
||||||
|
|
|
||||||
|
|
@ -1,214 +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 {
|
|
||||||
IMMINENT_REMINDER_GRACE_MS,
|
|
||||||
IMMINENT_REMINDER_WINDOW_MS,
|
|
||||||
getImminentReminderMissions,
|
|
||||||
} from "@/lib/intelligence/imminentReminders";
|
|
||||||
|
|
||||||
let payload: Payload;
|
|
||||||
|
|
||||||
const RUN = `imr-${Date.now().toString(36)}`;
|
|
||||||
|
|
||||||
describe("Imminent-op reminder query", () => {
|
|
||||||
let mapId: number;
|
|
||||||
let campaignId: number;
|
|
||||||
let authorId: number;
|
|
||||||
const missionIds: number[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const payloadConfig = await config;
|
|
||||||
payload = await getPayload({ config: payloadConfig });
|
|
||||||
|
|
||||||
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: "Imminent reminder 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
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.
|
|
||||||
const accounts = await payload
|
|
||||||
.find({
|
|
||||||
collection: "bank-accounts",
|
|
||||||
where: { ownerUser: { equals: authorId } },
|
|
||||||
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: authorId } },
|
|
||||||
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: authorId, overrideAccess: true })
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeMission = async (
|
|
||||||
label: string,
|
|
||||||
overrides: {
|
|
||||||
status?: Mission["ownershipAndStatus"]["status"];
|
|
||||||
visibility?: Mission["ownershipAndStatus"]["visibility"];
|
|
||||||
start: Date;
|
|
||||||
withRollCall?: boolean;
|
|
||||||
marked?: boolean;
|
|
||||||
},
|
|
||||||
): Promise<Mission> => {
|
|
||||||
const mission = (await payload.create({
|
|
||||||
collection: "missions",
|
|
||||||
data: {
|
|
||||||
name: `${RUN} ${label}`,
|
|
||||||
codeName: `${RUN}-${label}`,
|
|
||||||
summary: "Imminent reminder boundary test mission",
|
|
||||||
operationType: "main",
|
|
||||||
classification: {
|
|
||||||
map: mapId,
|
|
||||||
missionType: "PvE",
|
|
||||||
campaign: campaignId,
|
|
||||||
startDateTime: overrides.start.toISOString(),
|
|
||||||
estimatedDuration: 60,
|
|
||||||
},
|
|
||||||
ownershipAndStatus: {
|
|
||||||
authors: [authorId],
|
|
||||||
status: overrides.status ?? "Scheduled",
|
|
||||||
visibility: overrides.visibility ?? "unit",
|
|
||||||
},
|
|
||||||
missionRoles: {
|
|
||||||
maxPlayers: 16,
|
|
||||||
},
|
|
||||||
gameDetails: {
|
|
||||||
serverDetails: {
|
|
||||||
serverIp: "127.0.0.1",
|
|
||||||
serverPort: 2302,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
briefing: [],
|
|
||||||
...(overrides.withRollCall === false
|
|
||||||
? {}
|
|
||||||
: { discordMessageId: `${RUN}-${label}-message` }),
|
|
||||||
...(overrides.marked ? { imminentReminderSentAt: new Date().toISOString() } : {}),
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
})) as unknown as Mission;
|
|
||||||
missionIds.push(mission.id);
|
|
||||||
return mission;
|
|
||||||
};
|
|
||||||
|
|
||||||
it("finds posted Ready/Scheduled ops starting inside the 15 minute window", async () => {
|
|
||||||
const now = new Date();
|
|
||||||
const start = new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS - 60_000);
|
|
||||||
const ready = await makeMission("in-window-ready", { status: "Ready", start });
|
|
||||||
const scheduled = await makeMission("in-window-scheduled", { status: "Scheduled", start });
|
|
||||||
|
|
||||||
const found = await getImminentReminderMissions(payload, now);
|
|
||||||
expect(found.map((m) => m.id)).toContain(ready.id);
|
|
||||||
expect(found.map((m) => m.id)).toContain(scheduled.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes the exact window edge and the grace zone just past the start", async () => {
|
|
||||||
const now = new Date();
|
|
||||||
const edge = await makeMission("window-edge", {
|
|
||||||
start: new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS),
|
|
||||||
});
|
|
||||||
const justStarted = await makeMission("grace-zone", {
|
|
||||||
start: new Date(now.getTime() - 5 * 60_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
const found = await getImminentReminderMissions(payload, now);
|
|
||||||
expect(found.map((m) => m.id)).toContain(edge.id);
|
|
||||||
expect(found.map((m) => m.id)).toContain(justStarted.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes ops outside the window, past the grace bound, marked, without a roll-call, or not unit-visible", async () => {
|
|
||||||
const now = new Date();
|
|
||||||
const tooEarly = await makeMission("too-early", {
|
|
||||||
start: new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS + 60_000),
|
|
||||||
});
|
|
||||||
const tooLate = await makeMission("too-late", {
|
|
||||||
start: new Date(now.getTime() - IMMINENT_REMINDER_GRACE_MS - 60_000),
|
|
||||||
});
|
|
||||||
const alreadyReminded = await makeMission("already-reminded", {
|
|
||||||
start: new Date(now.getTime() + 10 * 60_000),
|
|
||||||
marked: true,
|
|
||||||
});
|
|
||||||
const noRollCall = await makeMission("no-roll-call", {
|
|
||||||
start: new Date(now.getTime() + 10 * 60_000),
|
|
||||||
withRollCall: false,
|
|
||||||
});
|
|
||||||
const notUnitVisible = await makeMission("not-unit-visible", {
|
|
||||||
start: new Date(now.getTime() + 10 * 60_000),
|
|
||||||
visibility: "leadership",
|
|
||||||
});
|
|
||||||
const draftStatus = await makeMission("draft-status", {
|
|
||||||
status: "Planning",
|
|
||||||
start: new Date(now.getTime() + 10 * 60_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
const foundIds = (await getImminentReminderMissions(payload, now)).map((m) => m.id);
|
|
||||||
for (const excluded of [
|
|
||||||
tooEarly,
|
|
||||||
tooLate,
|
|
||||||
alreadyReminded,
|
|
||||||
noRollCall,
|
|
||||||
notUnitVisible,
|
|
||||||
draftStatus,
|
|
||||||
]) {
|
|
||||||
expect(foundIds).not.toContain(excluded.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in a new issue