import { type Client, EmbedBuilder } from "discord.js"; import type { Payload } from "payload"; import type { Mission, User } from "@/payload-types"; import { botConfig } from "@/bot/config"; import { READY_REMINDER_LEAD_DAYS, daysUntil, getReadyReminderMissions, isReminderDue, missionTitle, } from "@/lib/intelligence/readyReminders"; /** * Ready-reminder service: starting READY_REMINDER_LEAD_DAYS days before a * player-scheduled side op, the op's creator gets a once-per-day DM nudging * them to finish the op and mark it "Ready" — the status that posts the * roll-call, locks in details, and delivers the mission hooks. * * The DM deliberately ignores `preferences.discord.enabled` (the notification * bridge opt-in): that toggle defaults to false and is not yet exposed in the * web UI, so honoring it here would silently DM nobody. This is a dedicated * creator-prompt flow, not a forwarding of the generic notification stream * (the web half of each reminder is excluded from the bridge via * NON_BRIDGED_TYPES). A per-day marker on the mission (readyReminderDmSentAt) * keeps the cadence to one DM per day. */ const EMBED_COLOR = 0xf59e0b; const MISSIONS_PER_TICK = 10; const SEND_DELAY_MS = 300; let reconcileRunning = false; const errorMessage = (error: unknown): string => error instanceof Error ? error.message : "Unknown error"; const creatorIdOf = (mission: Mission): number | null => typeof mission.createdBy === "object" ? (mission.createdBy?.id ?? null) : (mission.createdBy ?? null); const renderReminderEmbed = (mission: Mission, now: Date): EmbedBuilder => { const days = daysUntil(mission.classification.startDateTime, now); const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`; return new EmbedBuilder() .setTitle(`⏰ Ready check: ${missionTitle(mission)}`) .setDescription( `Your op starts **${when}** and it isn't marked **Ready** yet.\n\n` + "Finish up the operation and set the mission status to **Ready** so the roll-call can be posted, the details can be locked in, and all the proper mission hooks can be delivered.", ) .setColor(EMBED_COLOR) .addFields({ name: "Open the op", value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`, }) .setFooter({ text: "You'll get this reminder once a day until the op is Ready." }); }; const processMission = async ( client: Client, payload: Payload, mission: Mission, now: Date, ): Promise => { const creatorId = creatorIdOf(mission); if (!creatorId) return false; const creator = (await payload.findByID({ collection: "users", id: creatorId, depth: 0, overrideAccess: true, })) as unknown as User; if (!creator.discordId) return false; const target = await client.users.fetch(creator.discordId); await target.send({ embeds: [renderReminderEmbed(mission, now)] }); await payload.update({ collection: "missions", id: mission.id, data: { readyReminderDmSentAt: now.toISOString() }, overrideAccess: true, }); payload.logger.info( `[Bot] Sent ready reminder DM for mission #${mission.id} (${mission.name}) to user #${creatorId}.`, ); return true; }; export interface ReconcileSummary { processed: number; sent: number; } export const reconcile = async (client: Client, payload: Payload): Promise => { if (reconcileRunning) return { processed: 0, sent: 0 }; reconcileRunning = true; let processed = 0; let sent = 0; try { const now = new Date(); const missions = (await getReadyReminderMissions(payload, now)).slice(0, MISSIONS_PER_TICK); for (const mission of missions) { if (!isReminderDue(mission.readyReminderDmSentAt, now)) continue; processed += 1; try { if (await processMission(client, payload, mission, now)) { sent += 1; await new Promise((resolve) => setTimeout(resolve, SEND_DELAY_MS)); } } catch (error) { payload.logger.error( `[Bot] Ready reminder failed for mission #${mission.id}: ${errorMessage(error)}`, ); } } } catch (error) { payload.logger.error(`[Bot] Ready reminder reconcile failed: ${errorMessage(error)}`); } finally { reconcileRunning = false; } return { processed, sent }; }; export const start = (client: Client, payload: Payload): void => { payload.logger.info("[Bot] Ready reminder service started"); void reconcile(client, payload); setInterval(() => { void reconcile(client, payload); }, botConfig.readyReminderPollMs); };