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([ ...Object.values(TRANSFER_NOTIFICATION_TYPES), READY_REMINDER_NOTIFICATION_TYPE, ]); let cursor: number | null = null; const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); const initCursor = async (payload: Payload): Promise => { 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 => { 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); };