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).
129 lines
4.4 KiB
TypeScript
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);
|
|
};
|