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.
111 lines
3.7 KiB
TypeScript
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);
|
|
};
|