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:
parent
308a522764
commit
35769ea0f6
11 changed files with 29339 additions and 3 deletions
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
129
src/bot/services/imminentReminders.ts
Normal file
129
src/bot/services/imminentReminders.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
57
src/lib/intelligence/imminentReminders.ts
Normal file
57
src/lib/intelligence/imminentReminders.ts
Normal 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[];
|
||||
};
|
||||
29109
src/migrations/20260918_170318_add_imminent_reminder_marker.json
Normal file
29109
src/migrations/20260918_170318_add_imminent_reminder_marker.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue