import type { LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; import { getEnabledNotificationSettings } from '../db/repositories/notification-repository'; // ── Types ── type Db = LibSQLDatabase; /** Payload shape for notification callbacks from QueueService. */ export interface NotificationEvent { contentTitle: string; channelName: string; platform: string; url: string; filePath?: string; error?: string; attempt?: number; maxAttempts?: number; } /** Discord embed field shape. */ interface DiscordEmbedField { name: string; value: string; inline?: boolean; } /** Discord embed shape sent in webhook payload. */ interface DiscordEmbed { title: string; description: string; color: number; fields: DiscordEmbedField[]; timestamp: string; } // ── Colors ── const COLOR_GRAB = 0x3498db; // blue const COLOR_DOWNLOAD = 0x2ecc71; // green const COLOR_FAILURE = 0xe74c3c; // red // ── NotificationService ── /** * Dispatches notification embeds to configured Discord webhooks. * All dispatch is fire-and-forget — errors are logged but never thrown. */ export class NotificationService { constructor(private readonly db: Db) {} /** * Notify about a content grab (added to queue). */ async notifyGrab( contentTitle: string, channelName: string, platform: string, url: string ): Promise { const embed: DiscordEmbed = { title: '📥 Content Grabbed', description: `**${contentTitle}**`, color: COLOR_GRAB, fields: [ { name: 'Channel', value: channelName, inline: true }, { name: 'Platform', value: platform, inline: true }, { name: 'URL', value: url }, ], timestamp: new Date().toISOString(), }; await this.dispatch('onGrab', embed); } /** * Notify about a successful download. */ async notifyDownload( contentTitle: string, channelName: string, platform: string, url: string, filePath?: string ): Promise { const fields: DiscordEmbedField[] = [ { name: 'Channel', value: channelName, inline: true }, { name: 'Platform', value: platform, inline: true }, { name: 'URL', value: url }, ]; if (filePath) { fields.push({ name: 'File', value: filePath }); } const embed: DiscordEmbed = { title: '✅ Download Complete', description: `**${contentTitle}**`, color: COLOR_DOWNLOAD, fields, timestamp: new Date().toISOString(), }; await this.dispatch('onDownload', embed); } /** * Notify about a download failure. */ async notifyFailure( contentTitle: string, channelName: string, platform: string, error: string, attempt: number, maxAttempts: number ): Promise { const embed: DiscordEmbed = { title: '❌ Download Failed', description: `**${contentTitle}**`, color: COLOR_FAILURE, fields: [ { name: 'Channel', value: channelName, inline: true }, { name: 'Platform', value: platform, inline: true }, { name: 'Error', value: error.slice(0, 1024) }, { name: 'Attempt', value: `${attempt} / ${maxAttempts}`, inline: true }, ], timestamp: new Date().toISOString(), }; await this.dispatch('onFailure', embed); } // ── Private ── /** * Load enabled notification settings with the matching event toggle, * and POST the embed to each Discord webhook. Errors are logged with * channel name but never thrown — dispatch is fire-and-forget. */ private async dispatch( eventType: 'onGrab' | 'onDownload' | 'onFailure', embed: DiscordEmbed ): Promise { let settings; try { settings = await getEnabledNotificationSettings(this.db); } catch (err) { console.log( `[notification] failed to load settings: ${err instanceof Error ? err.message : String(err)}` ); return; } // Filter to settings that have the matching event toggle enabled const matching = settings.filter((s) => s[eventType] === true); for (const setting of matching) { const config = setting.config as { webhookUrl?: string }; const webhookUrl = config?.webhookUrl; if (!webhookUrl) { console.log( `[notification] skip channel="${setting.name}" — no webhook URL configured` ); continue; } try { const response = await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ embeds: [embed] }), }); if (!response.ok) { console.log( `[notification] dispatch failed channel="${setting.name}" event=${eventType} httpStatus=${response.status}` ); } else { console.log( `[notification] dispatch success channel="${setting.name}" event=${eventType}` ); } } catch (err) { console.log( `[notification] dispatch error channel="${setting.name}" event=${eventType} error="${err instanceof Error ? err.message : String(err)}"` ); } } } }