1
0
Fork 0
polaris-task-force/src/bot/services/imminentReminders.ts
Z8MB1E 35769ea0f6 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).
2026-09-18 13:09:35 -04:00

129 lines
4.4 KiB
TypeScript

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);
};