1
0
Fork 0
polaris-task-force/src/bot/services/notificationBridge.ts
Z8MB1E d757c58c5d feat(missions): daily ready-check reminders for unfinished side ops
Five days before a player-created side op starts, if it is not yet Ready, the creator gets one in-app notification per day (via mission-tick) plus one Discord DM per day (new bot service). Per-server-local-day dedupe via hidden readyReminderWebSentAt/readyReminderDmSentAt markers. The ready-reminder type is excluded from the notification bridge to avoid double-DMing. Markers are nulled on clone.
2026-09-07 05:07:46 -04:00

111 lines
3.7 KiB
TypeScript

import { type Client, EmbedBuilder } from "discord.js";
import type { Payload } from "payload";
import type { User, UserNotification } from "@/payload-types";
import { botConfig } from "@/bot/config";
import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes";
import { READY_REMINDER_NOTIFICATION_TYPE } from "@/lib/intelligence/readyReminders";
const MAX_SENDS_PER_TICK = 5;
const SEND_DELAY_MS = 300;
const EMBED_COLOR = 0x3b82f6;
// Transfer notifications are delivered by the dedicated transfer bot service
// (Phase 2), not this generic bridge. Ready reminders are delivered by the
// dedicated readyReminders DM service. Exclude exactly these types.
const NON_BRIDGED_TYPES = new Set<string>([
...Object.values(TRANSFER_NOTIFICATION_TYPES),
READY_REMINDER_NOTIFICATION_TYPE,
]);
let cursor: number | null = null;
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const initCursor = async (payload: Payload): Promise<void> => {
const res = await payload.find({
collection: "user-notifications",
sort: "-id",
limit: 1,
depth: 0,
overrideAccess: true,
});
cursor = res.docs[0]?.id ?? 0;
};
const notificationOwnerId = (notification: UserNotification): number =>
typeof notification.user === "object" ? notification.user.id : notification.user;
const eligibleForDm = (owner: User, type?: string | null): boolean => {
if (!owner.discordId) return false;
const discord = owner.preferences?.discord;
if (!discord || discord.enabled !== true) return false;
if (type && (discord.mutedTypes as string[] | null | undefined)?.includes(type)) return false;
return true;
};
const poll = async (client: Client, payload: Payload): Promise<void> => {
try {
if (cursor === null) {
await initCursor(payload);
}
const res = await payload.find({
collection: "user-notifications",
where: { id: { greater_than: cursor as number } },
sort: "id",
limit: 50,
depth: 0,
overrideAccess: true,
});
let processed = 0;
let sent = 0;
for (const notification of res.docs as unknown as UserNotification[]) {
processed++;
cursor = notification.id;
if (sent >= MAX_SENDS_PER_TICK) break;
if (notification.type && NON_BRIDGED_TYPES.has(notification.type)) continue;
try {
const owner = (await payload.findByID({
collection: "users",
id: notificationOwnerId(notification),
depth: 0,
overrideAccess: true,
})) as unknown as User;
if (!eligibleForDm(owner, notification.type)) continue;
const target = await client.users.fetch(owner.discordId as string);
await target.send({
embeds: [
new EmbedBuilder()
.setTitle(notification.title)
.setDescription(notification.message ?? null)
.setColor(EMBED_COLOR)
.addFields({
name: "Link",
value: `${botConfig.appUrl}${notification.link ?? ""}`,
}),
],
});
sent++;
if (sent < MAX_SENDS_PER_TICK) await sleep(SEND_DELAY_MS);
} catch {
// DM closed, user not found, or fetch failed — skip silently.
}
}
payload.logger.info(`[Bot] Notification bridge: processed ${processed}, sent ${sent}`);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
payload.logger.error(`[Bot] Notification bridge poll failed: ${message}`);
}
};
export const start = (client: Client, payload: Payload): void => {
payload.logger.info("[Bot] Notification bridge service started");
void poll(client, payload);
setInterval(() => {
void poll(client, payload);
}, botConfig.notificationPollMs);
};