server/src/notifications/index.ts
Rama main · réplica de lectura del repositorio original.
import logger from '../utils/logger';
import { isServiceConfigured } from '../config';
import settingsRepo from '../db/repositories/settings';
import {
type NotificationData,
type NotificationEvent,
type DiscordMessage,
getPlainTextMessage,
getDiscordMessage,
} from './templates';
import { fanOutWebhooks } from './webhooks';
// ============================================================================
// Types
// ============================================================================
export interface NotificationConfig {
discord: {
enabled: boolean;
};
}
export interface NotificationResult {
channel: 'discord';
success: boolean;
error?: string;
}
// ============================================================================
// Notification Service Class
// ============================================================================
/**
* Service for sending notifications via Discord
*/
export class NotificationService {
private config: NotificationConfig;
constructor(notificationConfig?: Partial<NotificationConfig>) {
this.config = {
discord: {
enabled: isServiceConfigured('discord'),
...notificationConfig?.discord,
},
};
}
// ============================================================================
// Main Notification Method
// ============================================================================
/**
* Send notifications to all configured channels
*/
async notify(event: NotificationEvent | string, data: NotificationData): Promise<NotificationResult[]> {
logger.info(`Sending notification for event: ${event}`);
const notificationEvent = event as NotificationEvent;
const results: NotificationResult[] = [];
// One timestamp shared by Discord embeds and the webhook envelope.
const timestamp = new Date().toISOString();
// --- Discord: gated by the per-event preference + Discord config ---
// (Webhook targets carry their own per-event opt-in, so the preference
// gate must NOT short-circuit the whole method.)
if (this.isEventEnabled(notificationEvent)) {
const discordEnabled = settingsRepo.getBoolean('notifications_discordEnabled', false);
const discordWebhook = settingsRepo.getValue('notifications_discordWebhook');
if (discordEnabled && discordWebhook) {
try {
const lng = settingsRepo.getValue('notifications_language') || 'en';
const message = getDiscordMessage(notificationEvent, data, lng);
const result = await this.sendDiscord(discordWebhook, message);
results.push({
channel: 'discord',
success: result,
error: result ? undefined : 'Failed to send Discord message',
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Discord notification error:', error);
results.push({
channel: 'discord',
success: false,
error: errorMessage,
});
}
}
} else {
logger.info(`Discord notification for ${event} suppressed by user preference`);
}
// --- Outbound webhooks: independent per-target opt-in, fire-and-forget ---
try {
await fanOutWebhooks(notificationEvent, data, timestamp);
} catch (error) {
logger.error('Webhook fan-out error:', error);
}
// Log summary
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
logger.info(`Notification results: ${successful} successful, ${failed} failed`);
return results;
}
// ============================================================================
// Event Preference Checks
// ============================================================================
/**
* Check if a notification event is enabled based on user preferences.
* Note: SCAN_COMPLETE is also gated in the scheduler task itself for the
* 'flagged_only' vs 'always' distinction. This method handles the 'never' case
* and all deletion-related event preferences.
*/
private isEventEnabled(event: NotificationEvent): boolean {
switch (event) {
case 'SCAN_COMPLETE':
case 'SCAN_ERROR': {
const scanNotify = settingsRepo.getValue('notifications_scanNotify') || 'flagged_only';
return scanNotify !== 'never';
}
case 'ITEMS_MARKED': {
return settingsRepo.getBoolean('notifications_notifyOnQueue', true);
}
case 'DELETION_IMMINENT': {
return settingsRepo.getBoolean('notifications_notifyBeforeDeletion', true);
}
case 'DELETION_COMPLETE':
case 'DELETION_ERROR': {
return settingsRepo.getBoolean('notifications_notifyOnDeletion', true);
}
case 'DISK_PRESSURE_TRIGGERED': {
return settingsRepo.getBoolean('notifications_notifyOnDiskPressure', true);
}
default:
return true;
}
}
// ============================================================================
// Discord Methods
// ============================================================================
/**
* Send a Discord webhook message
*/
async sendDiscord(webhookUrl: string, message: DiscordMessage): Promise<boolean> {
if (!webhookUrl) {
logger.warn('Discord webhook URL not configured');
return false;
}
try {
logger.debug('Sending Discord notification');
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Discord API error: ${response.status} - ${errorText}`);
}
logger.info('Discord notification sent successfully');
return true;
} catch (error) {
logger.error('Failed to send Discord notification:', error);
return false;
}
}
/**
* Send a simple Discord text message
*/
async sendDiscordText(webhookUrl: string, content: string): Promise<boolean> {
return this.sendDiscord(webhookUrl, {
content,
username: 'Prunerr',
});
}
// ============================================================================
// Configuration Methods
// ============================================================================
/**
* Update notification configuration
*/
updateConfig(config: Partial<NotificationConfig>): void {
if (config.discord) {
this.config.discord = { ...this.config.discord, ...config.discord };
}
logger.info('Notification configuration updated');
}
/**
* Get current notification configuration
*/
getConfig(): NotificationConfig {
return { ...this.config };
}
/**
* Enable or disable a notification channel
*/
setChannelEnabled(channel: 'discord', enabled: boolean): void {
this.config.discord.enabled = enabled;
logger.info(`${channel} notifications ${enabled ? 'enabled' : 'disabled'}`);
}
/**
* Test notification to all configured channels
*/
async testNotifications(): Promise<NotificationResult[]> {
const testData: NotificationData = {
itemsScanned: 100,
itemsFlagged: 5,
itemsProtected: 10,
durationMs: 1234,
};
return this.notify('SCAN_COMPLETE', testData);
}
}
// Re-export types from templates for convenience
export { type NotificationEvent, type NotificationData } from './templates';
// ============================================================================
// Singleton Instance
// ============================================================================
let notificationServiceInstance: NotificationService | null = null;
/**
* Get the singleton notification service instance
*/
export function getNotificationService(): NotificationService {
if (!notificationServiceInstance) {
notificationServiceInstance = new NotificationService();
}
return notificationServiceInstance;
}
/**
* Create a new notification service instance with custom configuration
*/
export function createNotificationService(
config?: Partial<NotificationConfig>
): NotificationService {
return new NotificationService(config);
}
export default NotificationService;