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:
parent
7ccd825b8c
commit
efae94c8a9
4 changed files with 296 additions and 0 deletions
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
155
src/bot/services/fridayOpsNotice.ts
Normal file
155
src/bot/services/fridayOpsNotice.ts
Normal 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);
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
133
src/lib/intelligence/fridayOpsNotice.ts
Normal file
133
src/lib/intelligence/fridayOpsNotice.ts
Normal 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>`;
|
||||||
Loading…
Reference in a new issue