diff --git a/src/bot/config.ts b/src/bot/config.ts index ec88378..0309ae0 100644 --- a/src/bot/config.ts +++ b/src/bot/config.ts @@ -5,11 +5,14 @@ export interface BotConfig { opsChannelId: string | null; announceChannelId: string | null; staffRoleIds: string[]; + sideOpsChannelId: string | null; + communityZeusRoleId: string | null; attendancePollMs: number; notificationPollMs: number; evaluationPollMs: number; readyReminderPollMs: number; imminentReminderPollMs: number; + sideOpsNoticePollMs: number; } const required = (name: string): string => { @@ -47,9 +50,12 @@ export const botConfig: BotConfig = { .split(",") .map((id) => id.trim()) .filter((id) => id.length > 0), + sideOpsChannelId: optional("DISCORD_SIDE_OPS_CHANNEL_ID"), + communityZeusRoleId: optional("DISCORD_COMMUNITY_ZEUS_ROLE_ID"), attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000), 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), + sideOpsNoticePollMs: pollMs("DISCORD_SIDE_OPS_POLL_MS", 1_800_000), }; diff --git a/src/bot/services/fridayOpsNotice.ts b/src/bot/services/fridayOpsNotice.ts new file mode 100644 index 0000000..2db2832 --- /dev/null +++ b/src/bot/services/fridayOpsNotice.ts @@ -0,0 +1,155 @@ +import { type Client, type Collection, type Message, type TextBasedChannelFields } from "discord.js"; +import type { Payload } from "payload"; +import { botConfig } from "@/bot/config"; +import { + SIDE_OPS_NOTICE_ROLE_NAME, + getFridayClaimant, + hasMissionOnFriday, + isNoticeDue, + noticeToken, + upcomingFridayRange, +} from "@/lib/intelligence/fridayOpsNotice"; + +/** + * Friday open-slot notice service: at 8 PM Eastern on Wednesday, if no op is + * scheduled for that week's Friday yet, post once in #side-ops pinging the + * Community Zeus role, offering the slot. + * + * When a non-superuser (a community zeus) later claims the slot, the notice + * is edited to credit them instead; if that claim op is later cancelled (or + * deleted), the notice reverts to the open-slot state. Dedup needs no DB + * state: the notice embeds a stable per-Friday timestamp token and the + * service scans recent channel messages for it (and for the claim line) + * before posting or editing, so bot restarts never double-post or re-credit. + */ + +const MESSAGE_SCAN_LIMIT = 100; + +let reconcileRunning = false; + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : "Unknown error"; + +type SideOpsChannel = TextBasedChannelFields & { + messages: { fetch(options: { limit: number }): Promise> }; +}; + +const getSideOpsChannel = async (client: Client, channelId: string): Promise => { + const channel = await client.channels.fetch(channelId); + if (!channel || !("send" in channel) || !("messages" in channel)) { + throw new Error(`[Bot] Side-ops channel ${channelId} is not a text channel.`); + } + return channel as unknown as SideOpsChannel; +}; + +/** Configured role ID wins; otherwise resolve the role by name in the guild. */ +const resolveZeusRoleId = async (client: Client): Promise => { + if (botConfig.communityZeusRoleId) return botConfig.communityZeusRoleId; + const guild = await client.guilds.fetch(botConfig.guildId); + const role = guild.roles.cache.find((role) => role.name === SIDE_OPS_NOTICE_ROLE_NAME); + return role?.id ?? null; +}; + +const renderNotice = (zeusRoleId: string | null): string => { + const mention = zeusRoleId ? `<@&${zeusRoleId}> ` : ""; + const { epoch } = upcomingFridayRange(new Date()); + return ( + `${mention}The Friday op slot () is still open, and you are welcome to host an op: ` + + `schedule one via the Friday Ops calendar at ${botConfig.appUrl}/intelligence/missions/calendar. ` + + "If nobody picks it up, the slot will either stay empty (no op that Friday) or Jason will host it himself." + ); +}; + +const CLAIM_MARKER = "This slot was claimed by"; +const CLAIM_LINE_PATTERN = + /\n\nThis slot was claimed by (.+)\. Thank you for hosting for our community!$/; + +const creditedNameOf = (content: string): string | null => + content.match(CLAIM_LINE_PATTERN)?.[1] ?? null; + +const findNoticeMessage = async ( + channel: SideOpsChannel, + token: string, +): Promise => { + const recent = await channel.messages.fetch({ limit: MESSAGE_SCAN_LIMIT }); + return recent.find((message) => message.content.includes(token)) ?? null; +}; + +const processNotice = async ( + client: Client, + payload: Payload, +): Promise<"posted" | "edited" | "idle"> => { + const channelId = botConfig.sideOpsChannelId; + if (!channelId) return "idle"; + + const now = new Date(); + const claim = await getFridayClaimant(payload, now); + + const channel = await getSideOpsChannel(client, channelId); + const token = noticeToken(now); + const notice = await findNoticeMessage(channel, token); + + if (!notice) { + const slotTaken = claim != null || (await hasMissionOnFriday(payload, now)); + if (!isNoticeDue(now) || slotTaken) return "idle"; + + const zeusRoleId = await resolveZeusRoleId(client); + if (!zeusRoleId) { + payload.logger.warn( + `[Bot] Friday open-slot notice: no "${SIDE_OPS_NOTICE_ROLE_NAME}" role found and DISCORD_COMMUNITY_ZEUS_ROLE_ID is not set; posting without the role ping.`, + ); + } + await channel.send(renderNotice(zeusRoleId)); + payload.logger.info( + `[Bot] Posted Friday open-slot notice for ${token} in the side-ops channel.`, + ); + return "posted"; + } + + const credited = creditedNameOf(notice.content); + if (!claim) { + if (!credited) return "idle"; + await notice.edit(notice.content.replace(CLAIM_LINE_PATTERN, "")); + payload.logger.info( + `[Bot] Friday open-slot notice for ${token}: claim by ${credited} was cancelled; notice reverted to the open-slot state.`, + ); + return "edited"; + } + if (credited !== claim.userName) { + const base = credited ? notice.content.replace(CLAIM_LINE_PATTERN, "") : notice.content; + await notice.edit(`${base}\n\n${CLAIM_MARKER} ${claim.userName}. Thank you for hosting for our community!`); + payload.logger.info( + `[Bot] Friday open-slot notice for ${token} claimed by ${claim.userName}; notice updated.`, + ); + return "edited"; + } + return "idle"; +}; + +export interface ReconcileSummary { + posted: number; + edited: number; +} + +export const reconcile = async (client: Client, payload: Payload): Promise => { + if (reconcileRunning) return { posted: 0, edited: 0 }; + if (!botConfig.sideOpsChannelId) return { posted: 0, edited: 0 }; + reconcileRunning = true; + try { + const result = await processNotice(client, payload); + return { posted: result === "posted" ? 1 : 0, edited: result === "edited" ? 1 : 0 }; + } catch (error) { + payload.logger.error(`[Bot] Friday open-slot notice failed: ${errorMessage(error)}`); + return { posted: 0, edited: 0 }; + } finally { + reconcileRunning = false; + } +}; + +export const start = (client: Client, payload: Payload): void => { + payload.logger.info("[Bot] Friday open-slot notice service started"); + void reconcile(client, payload); + setInterval(() => { + void reconcile(client, payload); + }, botConfig.sideOpsNoticePollMs); +}; diff --git a/src/bot/services/index.ts b/src/bot/services/index.ts index ed7827d..73de367 100644 --- a/src/bot/services/index.ts +++ b/src/bot/services/index.ts @@ -6,6 +6,7 @@ import { start as startTransferRequests } from "./transferRequests"; import { start as startEvaluationReminders } from "./evaluationReminders"; import { start as startReadyReminders } from "./readyReminders"; import { start as startImminentReminders } from "./imminentReminders"; +import { start as startFridayOpsNotice } from "./fridayOpsNotice"; export type ServiceStarter = (client: Client, payload: Payload) => Promise | void; @@ -16,4 +17,5 @@ export const services: ServiceStarter[] = [ startEvaluationReminders, startReadyReminders, startImminentReminders, + startFridayOpsNotice, ]; diff --git a/src/lib/intelligence/fridayOpsNotice.ts b/src/lib/intelligence/fridayOpsNotice.ts new file mode 100644 index 0000000..5ab3114 --- /dev/null +++ b/src/lib/intelligence/fridayOpsNotice.ts @@ -0,0 +1,133 @@ +import type { Payload } from "payload"; +import type { User } from "@/payload-types"; +import { superuserUserIds } from "@/utils/access-control/hasPermission"; + +/** + * Friday open-slot notice for the Discord bot. + * + * At 8 PM Eastern on Wednesday (the deploy container may run any server + * timezone, so the clock is evaluated in America/New_York) and no op is + * scheduled for that week's upcoming Friday yet, the bot posts once in + * #side-ops pinging the Community Zeus role: the slot is open, they are + * welcome to host an op, and if nobody picks it up the slot either stays + * empty (no op that Friday) or Jason hosts it himself. If the bot misses the + * whole Wednesday it stays quiet until the following week's notice. + * + * The shared logic lives here (not in the bot) to keep it testable and free + * of discord.js imports, mirroring the other reminder libs. + */ + +/** Fallback role name resolved in the guild when no role ID is configured. */ +export const SIDE_OPS_NOTICE_ROLE_NAME = "Community Zeus"; + +export const NOTICE_HOUR_ET = 20; + +const ET_TIME_ZONE = "America/New_York"; + +const etParts = (now: Date): { weekday: string; hour: number } => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: ET_TIME_ZONE, + weekday: "short", + hour: "numeric", + hour12: false, + }).formatToParts(now); + const get = (type: string): string => parts.find((part) => part.type === type)?.value ?? ""; + const hour = Number.parseInt(get("hour"), 10); + return { weekday: get("weekday"), hour: hour === 24 ? 0 : hour }; +}; + +/** + * The notice is due once Wednesday 8 PM Eastern has passed, and until the + * ET day rolls over to Thursday: a bot restart after 8 PM still catches up + * within the evening, but a fully missed Wednesday waits for next week. + */ +export const isNoticeDue = (now: Date): boolean => { + const { weekday, hour } = etParts(now); + return weekday === "Wed" && hour >= NOTICE_HOUR_ET; +}; + +/** + * Start/end/epoch of the upcoming Friday (server-local), strictly after + * `now`. Used both for the mission-existence query and for the stable + * per-Friday dedup token. + */ +export const upcomingFridayRange = (now: Date): { start: Date; end: Date; epoch: number } => { + const start = new Date(now); + const daysUntilFriday = (5 - start.getDay() + 7) % 7 || 7; + start.setDate(start.getDate() + daysUntilFriday); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setHours(23, 59, 59, 999); + return { start, end, epoch: Math.floor(start.getTime() / 1000) }; +}; + +/** + * A Friday slot counts as taken when any non-cancelled mission (any status, + * any operation type) starts on that Friday: even a draft means someone is + * already working on the slot. + */ +export const hasMissionOnFriday = async (payload: Payload, now: Date): Promise => { + const { start, end } = upcomingFridayRange(now); + const res = await payload.find({ + collection: "missions", + overrideAccess: true, + depth: 0, + limit: 1, + where: { + and: [ + { "classification.startDateTime": { greater_than_equal: start.toISOString() } }, + { "classification.startDateTime": { less_than_equal: end.toISOString() } }, + { "ownershipAndStatus.status": { not_equals: "Cancelled" } }, + ], + }, + }); + return res.totalDocs > 0; +}; + +export interface FridayClaim { + userName: string; +} + +/** + * The community claim on the upcoming Friday slot: the earliest-created + * non-cancelled mission whose creator is NOT a superuser, resolved to a + * display name. Jason (or any other superuser) hosting the op himself does + * not claim the slot as a community zeus claim, and staff-created missions + * without a creator do not either. Returns null when only superuser-created + * missions (or nothing) hold the Friday. + */ +export const getFridayClaimant = async ( + payload: Payload, + now: Date, +): Promise => { + const { start, end } = upcomingFridayRange(now); + const res = await payload.find({ + collection: "missions", + overrideAccess: true, + depth: 1, + limit: 25, + sort: "createdAt", + where: { + and: [ + { "classification.startDateTime": { greater_than_equal: start.toISOString() } }, + { "classification.startDateTime": { less_than_equal: end.toISOString() } }, + { "ownershipAndStatus.status": { not_equals: "Cancelled" } }, + ], + }, + }); + const creators = res.docs + .map((mission) => (typeof mission.createdBy === "object" ? (mission.createdBy as User) : null)) + .filter((creator): creator is User => creator != null); + if (creators.length === 0) return null; + const superIds = await superuserUserIds(payload); + const claimant = creators.find((creator) => !superIds.includes(Number(creator.id))); + if (!claimant) return null; + return { userName: claimant.displayName ?? claimant.username }; +}; + +/** + * Stable per-Friday token embedded in the notice message (renders as the + * Friday's date) so the bot can detect an existing notice by scanning recent + * channel messages, surviving restarts without any DB state. + */ +export const noticeToken = (now: Date): string => ``;