1
0
Fork 0

feat(bot): add imminent-op reminder thread with attendee pings

15 minutes before a posted op starts, the bot opens a thread on the
roll-call message and pings everyone who RSVP'd yes, as a last-call
nudge that the op is imminent. One-shot per mission via a new
imminentReminderSentAt marker on Missions (migration included); the
cancel flow clears it so a rescheduled op re-reminds for its new start
time. Poll cadence via DISCORD_IMMINENT_POLL_MS (default 60s).
This commit is contained in:
Jason Fraley 2026-09-18 13:09:35 -04:00
parent 308a522764
commit 35769ea0f6
11 changed files with 29339 additions and 3 deletions

View file

@ -9,6 +9,7 @@ export interface BotConfig {
notificationPollMs: number;
evaluationPollMs: number;
readyReminderPollMs: number;
imminentReminderPollMs: number;
}
const required = (name: string): string => {
@ -50,4 +51,5 @@ export const botConfig: BotConfig = {
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
readyReminderPollMs: pollMs("DISCORD_READY_REMINDER_POLL_MS", 60_000),
imminentReminderPollMs: pollMs("DISCORD_IMMINENT_POLL_MS", 60_000),
};

View file

@ -0,0 +1,129 @@
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);
};

View file

@ -5,6 +5,7 @@ import { start as startNotificationBridge } from "./notificationBridge";
import { start as startTransferRequests } from "./transferRequests";
import { start as startEvaluationReminders } from "./evaluationReminders";
import { start as startReadyReminders } from "./readyReminders";
import { start as startImminentReminders } from "./imminentReminders";
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
@ -14,4 +15,5 @@ export const services: ServiceStarter[] = [
startTransferRequests,
startEvaluationReminders,
startReadyReminders,
startImminentReminders,
];

View file

@ -509,9 +509,9 @@ export const reconcileMission = async (
/**
* A cancelled op gets its roll-call edited into the cancelled state (buttons
* removed) plus a standalone cancellation notice. The message id, attendance
* hash, and one-shot send marker are cleared, and `discordCancelledAt` marks
* the cancellation so a later reschedule can be announced and get a fresh
* roll-call.
* hash, one-shot send marker, and imminent-reminder marker are cleared, and
* `discordCancelledAt` marks the cancellation so a later reschedule can be
* announced and get a fresh roll-call.
*/
const cancelMissionEmbed = async (
client: Client,
@ -536,6 +536,7 @@ const cancelMissionEmbed = async (
discordMessageId: null,
discordAttendanceHash: null,
discordAttendanceSentAt: null,
imminentReminderSentAt: null,
discordCancelledAt: new Date().toISOString(),
},
overrideAccess: true,

View file

@ -255,6 +255,15 @@ export const Missions: CollectionConfig = {
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",
type: "date",

View file

@ -0,0 +1,57 @@
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

View file

@ -0,0 +1,11 @@
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";`)
}

View file

@ -65,6 +65,7 @@ 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_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_20260918_170318_add_imminent_reminder_marker from "./20260918_170318_add_imminent_reminder_marker";
export const migrations = [
{
@ -402,4 +403,9 @@ export const migrations = [
down: migration_20260917_112258_add_zone_political_types.down,
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",
},
];

View file

@ -2602,6 +2602,11 @@ export const missions = pgTable(
withTimezone: true,
precision: 3,
}),
imminentReminderSentAt: timestamp("imminent_reminder_sent_at", {
mode: "string",
withTimezone: true,
precision: 3,
}),
ticketsSettledAt: timestamp("tickets_settled_at", {
mode: "string",
withTimezone: true,

View file

@ -2592,6 +2592,10 @@ 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).
*/
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).
*/
@ -6402,6 +6406,7 @@ export interface MissionsSelect<T extends boolean = true> {
evaluationRemindersSentAt?: T;
readyReminderWebSentAt?: T;
readyReminderDmSentAt?: T;
imminentReminderSentAt?: T;
ticketsSettledAt?: T;
createdBy?: T;
coverImage?: T;