1
0
Fork 0

feat(bot): add Friday open-slot notice with claim credit and revert

At 8 PM Eastern on Wednesday, if no non-cancelled mission starts on that
week's upcoming Friday, post once in #side-ops pinging the Community
Zeus role (DISCORD_COMMUNITY_ZEUS_ROLE_ID, or the role resolved by
name): the slot is open, hosting is welcome, and otherwise the slot
stays empty or Jason hosts it himself. Dedup uses a stable per-Friday
timestamp token scanned from recent channel messages, so no DB state.
When a non-superuser later claims the slot the notice is edited to
credit them; a cancelled or deleted claim reverts the notice, and a
between-polls claim swap re-credits the new claimant.
This commit is contained in:
Jason Fraley 2026-09-18 14:52:39 -04:00
parent 7ccd825b8c
commit efae94c8a9
4 changed files with 296 additions and 0 deletions

View file

@ -5,11 +5,14 @@ export interface BotConfig {
opsChannelId: string | null; opsChannelId: string | null;
announceChannelId: string | null; announceChannelId: string | null;
staffRoleIds: string[]; staffRoleIds: string[];
sideOpsChannelId: string | null;
communityZeusRoleId: string | null;
attendancePollMs: number; attendancePollMs: number;
notificationPollMs: number; notificationPollMs: number;
evaluationPollMs: number; evaluationPollMs: number;
readyReminderPollMs: number; readyReminderPollMs: number;
imminentReminderPollMs: number; imminentReminderPollMs: number;
sideOpsNoticePollMs: number;
} }
const required = (name: string): string => { const required = (name: string): string => {
@ -47,9 +50,12 @@ export const botConfig: BotConfig = {
.split(",") .split(",")
.map((id) => id.trim()) .map((id) => id.trim())
.filter((id) => id.length > 0), .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), attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000), notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000), evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
readyReminderPollMs: pollMs("DISCORD_READY_REMINDER_POLL_MS", 60_000), readyReminderPollMs: pollMs("DISCORD_READY_REMINDER_POLL_MS", 60_000),
imminentReminderPollMs: pollMs("DISCORD_IMMINENT_POLL_MS", 60_000), imminentReminderPollMs: pollMs("DISCORD_IMMINENT_POLL_MS", 60_000),
sideOpsNoticePollMs: pollMs("DISCORD_SIDE_OPS_POLL_MS", 1_800_000),
}; };

View file

@ -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<Collection<string, Message>> };
};
const getSideOpsChannel = async (client: Client, channelId: string): Promise<SideOpsChannel> => {
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<string | null> => {
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 (<t:${epoch}:D>) 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<Message | null> => {
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<ReconcileSummary> => {
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);
};

View file

@ -6,6 +6,7 @@ import { start as startTransferRequests } from "./transferRequests";
import { start as startEvaluationReminders } from "./evaluationReminders"; import { start as startEvaluationReminders } from "./evaluationReminders";
import { start as startReadyReminders } from "./readyReminders"; import { start as startReadyReminders } from "./readyReminders";
import { start as startImminentReminders } from "./imminentReminders"; import { start as startImminentReminders } from "./imminentReminders";
import { start as startFridayOpsNotice } from "./fridayOpsNotice";
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void; export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
@ -16,4 +17,5 @@ export const services: ServiceStarter[] = [
startEvaluationReminders, startEvaluationReminders,
startReadyReminders, startReadyReminders,
startImminentReminders, startImminentReminders,
startFridayOpsNotice,
]; ];

View file

@ -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<boolean> => {
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<FridayClaim | null> => {
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 => `<t:${upcomingFridayRange(now).epoch}:D>`;