This commit is contained in:
Baobhan Sith
2025-04-26 19:39:03 +08:00
parent dbd2ce7f0f
commit 62e53c3cdc
13 changed files with 846 additions and 144 deletions
@@ -0,0 +1,94 @@
import { EventEmitter } from 'events';
// 定义支持的事件类型
// 这里可以根据 packages/backend/src/locales/zh-CN.json 中的 event 部分来扩展
export enum AppEventType {
TestNotification = 'testNotification', // 用于测试
LoginSuccess = 'LOGIN_SUCCESS',
LoginFailure = 'LOGIN_FAILURE',
Logout = 'LOGOUT',
PasswordChanged = 'PASSWORD_CHANGED',
TwoFactorEnabled = '2FA_ENABLED',
TwoFactorDisabled = '2FA_DISABLED',
PasskeyRegistered = 'PASSKEY_REGISTERED',
PasskeyDeleted = 'PASSKEY_DELETED',
ConnectionCreated = 'CONNECTION_CREATED',
ConnectionUpdated = 'CONNECTION_UPDATED',
ConnectionDeleted = 'CONNECTION_DELETED',
ConnectionTested = 'CONNECTION_TESTED',
ConnectionsImported = 'CONNECTIONS_IMPORTED',
ConnectionsExported = 'CONNECTIONS_EXPORTED',
ProxyCreated = 'PROXY_CREATED',
ProxyUpdated = 'PROXY_UPDATED',
ProxyDeleted = 'PROXY_DELETED',
TagCreated = 'TAG_CREATED',
TagUpdated = 'TAG_UPDATED',
TagDeleted = 'TAG_DELETED',
SettingsUpdated = 'SETTINGS_UPDATED',
IpWhitelistUpdated = 'IP_WHITELIST_UPDATED',
NotificationSettingCreated = 'NOTIFICATION_SETTING_CREATED',
NotificationSettingUpdated = 'NOTIFICATION_SETTING_UPDATED',
NotificationSettingDeleted = 'NOTIFICATION_SETTING_DELETED',
SftpAction = 'SFTP_ACTION',
SshConnectSuccess = 'SSH_CONNECT_SUCCESS',
SshConnectFailure = 'SSH_CONNECT_FAILURE',
SshShellFailure = 'SSH_SHELL_FAILURE',
ServerStarted = 'SERVER_STARTED',
ServerError = 'SERVER_ERROR',
DatabaseMigration = 'DATABASE_MIGRATION',
AdminSetupComplete = 'ADMIN_SETUP_COMPLETE',
// 可以根据需要添加更多事件类型
}
// 定义事件负载的通用接口,可以根据具体事件扩展
export interface AppEventPayload {
userId?: number; // 事件关联的用户 ID(如果适用)
timestamp: Date; // 事件发生的时间戳
details?: Record<string, any>; // 事件相关的具体数据
[key: string]: any; // 允许其他任意属性
}
class EventService extends EventEmitter {
constructor() {
super();
// 增加监听器数量限制,防止潜在的内存泄漏警告
this.setMaxListeners(50);
}
/**
* 触发一个应用事件
* @param eventType 事件类型
* @param payload 事件负载数据
*/
emitEvent(eventType: AppEventType, payload: Omit<AppEventPayload, 'timestamp'>) {
const fullPayload: AppEventPayload = {
...payload,
timestamp: new Date(),
};
this.emit(eventType, fullPayload);
console.log(`Event emitted: ${eventType}`, fullPayload); // 日志记录,方便调试
}
/**
* 注册事件监听器
* @param eventType 事件类型
* @param listener 监听函数
*/
onEvent(eventType: AppEventType, listener: (payload: AppEventPayload) => void) {
this.on(eventType, listener);
}
/**
* 移除事件监听器
* @param eventType 事件类型
* @param listener 监听函数
*/
offEvent(eventType: AppEventType, listener: (payload: AppEventPayload) => void) {
this.off(eventType, listener);
}
}
// 创建单例
const eventService = new EventService();
export default eventService;
@@ -0,0 +1,79 @@
import notificationProcessorService, { ProcessedNotification } from './notification.processor.service'; // 导入导出的接口
import { NotificationChannelType, NotificationChannelConfig } from '../types/notification.types';
// 1. 定义通知发送器接口
export interface INotificationSender {
send(notification: ProcessedNotification): Promise<void>;
}
// 导入具体的发送器实现
import telegramSenderService from './senders/telegram.sender.service';
import emailSenderService from './senders/email.sender.service';
import webhookSenderService from './senders/webhook.sender.service';
class NotificationDispatcherService {
// 使用 Map 来存储不同渠道类型的发送器实例
private senders: Map<NotificationChannelType, INotificationSender>;
constructor() {
this.senders = new Map();
// 注册具体的发送器实例
this.registerSender('telegram', telegramSenderService);
this.registerSender('email', emailSenderService);
this.registerSender('webhook', webhookSenderService);
this.listenForNotifications();
}
/**
* 注册一个通知发送器实例
* @param channelType 渠道类型
* @param sender 发送器实例
*/
registerSender(channelType: NotificationChannelType, sender: INotificationSender) {
if (this.senders.has(channelType)) {
console.warn(`[NotificationDispatcher] Sender for channel type '${channelType}' is already registered. Overwriting.`);
}
this.senders.set(channelType, sender);
console.log(`[NotificationDispatcher] Registered sender for channel type '${channelType}'.`);
}
private listenForNotifications() {
notificationProcessorService.on('sendNotification', (processedNotification: ProcessedNotification) => {
// 使用 setImmediate 避免阻塞
setImmediate(() => {
this.dispatchNotification(processedNotification).catch(error => {
console.error(`[NotificationDispatcher] Error dispatching notification for channel ${processedNotification.channelType}:`, error);
});
});
});
console.log('[NotificationDispatcher] Listening for processed notifications.');
}
private async dispatchNotification(notification: ProcessedNotification) {
const sender = this.senders.get(notification.channelType);
if (!sender) {
console.warn(`[NotificationDispatcher] No sender registered for channel type: ${notification.channelType}. Skipping notification.`);
return;
}
console.log(`[NotificationDispatcher] Dispatching notification via ${notification.channelType}`);
try {
await sender.send(notification);
console.log(`[NotificationDispatcher] Successfully sent notification via ${notification.channelType}`);
} catch (error) {
console.error(`[NotificationDispatcher] Failed to send notification via ${notification.channelType}:`, error);
// 这里可以添加失败重试或记录失败状态的逻辑
}
}
}
// 创建单例并导出
const notificationDispatcherService = new NotificationDispatcherService();
// 导出接口,以便其他发送器可以实现它
// (或者将接口移到 types 文件中)
export default notificationDispatcherService;
@@ -0,0 +1,278 @@
import eventService, { AppEventType, AppEventPayload } from './event.service';
import { NotificationSettingsRepository } from '../repositories/notification.repository';
import { NotificationSetting, NotificationEvent, NotificationChannelType, WebhookConfig, EmailConfig, TelegramConfig, NotificationChannelConfig } from '../types/notification.types';
import i18next, { i18nInitializationPromise } from '../i18n'; // Import the promise
import { EventEmitter } from 'events';
// 定义处理后的通知数据结构
// Exporting for use in dispatcher
export interface ProcessedNotification {
channelType: NotificationChannelType;
config: NotificationChannelConfig; // 包含发送所需的配置,如 URL, Token, SMTP 等
subject?: string; // 主要用于 Email
body: string; // 格式化后的通知内容主体
rawPayload: AppEventPayload; // 原始事件负载,可能需要传递给发送器
}
class NotificationProcessorService extends EventEmitter {
private repository: NotificationSettingsRepository;
private isInitialized = false; // Flag to track initialization
constructor() {
super();
this.repository = new NotificationSettingsRepository();
this.initialize(); // Call async initialization method
// Increase listener limit
this.setMaxListeners(50);
}
// Async initialization method
private async initialize(): Promise<void> {
try {
console.log('[NotificationProcessor] Waiting for i18n initialization...');
await i18nInitializationPromise; // Wait for i18n to load
console.log('[NotificationProcessor] i18n initialized. Registering event listeners...');
this.registerEventListeners();
this.isInitialized = true;
console.log('[NotificationProcessor] Initialization complete.');
} catch (error) {
console.error('[NotificationProcessor] Failed to initialize due to i18n error:', error);
// Handle initialization failure, maybe retry or log critical error
}
}
private registerEventListeners() {
if (this.isInitialized) {
console.warn('[NotificationProcessor] Attempted to register listeners multiple times.');
return; // Prevent double registration
}
// 监听所有 AppEventType 事件
Object.values(AppEventType).forEach(eventType => {
// Special handling for TestNotification is done inside processEvent handlers below
if (eventType !== AppEventType.TestNotification) {
eventService.onEvent(eventType, (payload) => {
// 使用 setImmediate 或 process.nextTick 避免阻塞事件循环
setImmediate(() => {
this.processStandardEvent(eventType, payload).catch(error => {
console.error(`[NotificationProcessor] Error processing event ${eventType}:`, error);
});
});
});
}
});
// Separate listener specifically for TestNotification
eventService.onEvent(AppEventType.TestNotification, (payload) => {
setImmediate(() => {
this.processTestEvent(payload).catch(error => {
console.error(`[NotificationProcessor] Error processing test event:`, error);
});
});
});
console.log('[NotificationProcessor] Registered listeners.');
}
// Handles standard events by fetching settings from DB
private async processStandardEvent(eventType: AppEventType, payload: AppEventPayload) {
if (!this.isInitialized) {
console.warn(`[NotificationProcessor] Received event ${eventType} before initialization. Skipping.`);
return;
}
console.log(`[NotificationProcessor] Received standard event: ${eventType}`, payload);
const eventKey = eventType as NotificationEvent; // 类型转换,假设 AppEventType 和 NotificationEvent 对应
try {
const applicableSettings = await this.repository.getEnabledByEvent(eventKey);
console.log(`[NotificationProcessor] Found ${applicableSettings.length} applicable settings for event ${eventKey}`);
if (applicableSettings.length === 0) {
return; // 没有配置需要处理
}
// TODO: 获取用户语言偏好,目前硬编码为 'zh-CN'
const userLang = 'zh-CN'; // 后续应从用户设置或请求中获取
// 1. 翻译事件名称
const translatedEvent = i18next.t(`event.${eventKey}`, { lng: userLang, defaultValue: eventKey });
// --- DEBUG LOG ---
console.log(`[NotificationProcessor] Translating event key '${eventKey}' for lang '${userLang}'. Result: '${translatedEvent}'`);
// --- END DEBUG LOG ---
// --- DEBUG LOG ---
console.log(`[NotificationProcessor] Translating event key '${eventKey}' for lang '${userLang}'. Result: '${translatedEvent}'`);
// --- END DEBUG LOG ---
for (const setting of applicableSettings) {
this.processSingleSetting(setting, eventType, payload, translatedEvent, userLang);
}
} catch (error) {
console.error(`[NotificationProcessor] Failed to fetch settings for event ${eventKey}:`, error);
}
}
// Handles the specific TestNotification event using config from payload
private async processTestEvent(payload: AppEventPayload) {
if (!this.isInitialized) {
console.warn(`[NotificationProcessor] Received test event before initialization. Skipping.`);
return;
}
console.log(`[NotificationProcessor] Received test event`, payload);
const { testTargetConfig, testTargetChannelType } = payload.details || {};
if (!testTargetConfig || !testTargetChannelType) {
console.error('[NotificationProcessor] Test event payload missing testTargetConfig or testTargetChannelType.');
return;
}
// Create a mock setting object for processing
const mockSetting: NotificationSetting = {
id: -1, // Indicate it's a test/mock
name: 'Test Setting',
enabled: true,
channel_type: testTargetChannelType,
config: testTargetConfig,
enabled_events: [AppEventType.TestNotification as NotificationEvent], // Doesn't really matter here
};
const userLang = 'zh-CN'; // TODO: Get user language preference
// For test events, use 'testNotification' as the key for i18n lookups
const translatedEvent = i18next.t(`event.${AppEventType.TestNotification}`, { lng: userLang, defaultValue: AppEventType.TestNotification });
// --- DEBUG LOG ---
console.log(`[NotificationProcessor] Translating event key '${AppEventType.TestNotification}' for lang '${userLang}'. Result: '${translatedEvent}'`);
// --- END DEBUG LOG ---
this.processSingleSetting(mockSetting, AppEventType.TestNotification, payload, translatedEvent, userLang);
}
// Processes a single setting (called by both standard and test event handlers)
private processSingleSetting(
setting: NotificationSetting,
eventType: AppEventType, // The actual event type (e.g., LOGIN_SUCCESS or TestNotification)
payload: AppEventPayload,
translatedEvent: string,
userLang: string
) {
try {
// i18nEventKey is no longer needed for template lookup here
// const i18nEventKey = eventType === AppEventType.TestNotification ? 'testNotification' : eventType;
const processedNotification = this.prepareNotificationContent(
setting,
// i18nEventKey, // Pass the original event type for context if needed later
eventType,
payload,
translatedEvent, // Pass the already translated event name
userLang
);
if (processedNotification) {
this.emit('sendNotification', processedNotification);
console.log(`[NotificationProcessor] Emitting sendNotification for ${setting.channel_type} (Setting ID: ${setting.id}, Event: ${eventType})`);
}
} catch (error) {
console.error(`[NotificationProcessor] Error preparing notification for setting ID ${setting.id} and event ${eventType}:`, error);
}
}
private prepareNotificationContent(
setting: NotificationSetting,
eventType: AppEventType, // Use the actual event type for context
payload: AppEventPayload,
translatedEvent: string, // The already translated event name (e.g., "登录成功")
lang: string // Keep lang for potential future use
): ProcessedNotification | null {
// Base data for interpolation, using the translated event name
const baseInterpolationData = {
event: translatedEvent, // Use the translated event name here!
rawEvent: eventType, // Keep original event type
timestamp: payload.timestamp.toISOString(),
// Safely stringify details, provide default
details: typeof payload.details === 'object' ? JSON.stringify(payload.details, null, 2) : (payload.details || ''),
userId: payload.userId || 'N/A',
// Flatten details for easier access in simple templates
...(typeof payload.details === 'object' ? payload.details : {}),
// Add specific fields from details if they exist (example for setting deletion)
settingId: payload.details?.settingId,
settingName: payload.details?.name,
settingType: payload.details?.type,
};
let subject: string | undefined = undefined;
let body: string = '';
// Define GENERIC fallback templates in code
const genericSubject = `通知: {event}`;
const genericEmailBody = `<p>事件: {event}</p><p>时间: {timestamp}</p><p>用户ID: {userId}</p><p>详情:</p><pre>{details}</pre>`;
const genericWebhookBody = JSON.stringify({ event: '{event}', timestamp: '{timestamp}', userId: '{userId}', details: '{details}' });
const genericTelegramBody = `*{event}*\n时间: {timestamp}\n用户ID: {userId}\n详情:\n\`\`\`\n{details}\n\`\`\``;
// Use user-defined template first, then the GENERIC fallback
switch (setting.channel_type) {
case 'email':
const emailConfig = setting.config as EmailConfig;
// Use user template OR generic fallback
const subjectTemplate = emailConfig.subjectTemplate || genericSubject;
subject = this.interpolate(subjectTemplate, baseInterpolationData);
// For email body, assume user template is HTML if provided, otherwise use generic HTML fallback
// Note: EmailConfig type currently doesn't have bodyTemplate. Using generic fallback.
body = this.interpolate(genericEmailBody, baseInterpolationData);
break;
case 'webhook':
const webhookConfig = setting.config as WebhookConfig;
// Use user template OR generic fallback
const webhookTemplate = webhookConfig.bodyTemplate || genericWebhookBody;
body = this.interpolate(webhookTemplate, baseInterpolationData);
break;
case 'telegram':
const telegramConfig = setting.config as TelegramConfig;
// Use user template OR generic fallback
const telegramTemplate = telegramConfig.messageTemplate || genericTelegramBody;
body = this.interpolate(telegramTemplate, baseInterpolationData);
break;
default:
console.warn(`[NotificationProcessor] Unsupported channel type: ${setting.channel_type}`);
return null;
}
return {
channelType: setting.channel_type,
config: setting.config,
subject: subject,
body: body,
rawPayload: payload // Pass original payload for potential use in senders
};
}
/**
* 简单的字符串模板插值替换
* @param template 模板字符串,例如 "Hello {name}"
* @param data 数据对象,例如 { name: "World" }
* @returns 替换后的字符串
*/
private interpolate(template: string, data: Record<string, any>): string {
if (!template) return '';
// 使用正则表达式全局替换 {key} 格式的占位符
return template.replace(/\{(\w+)\}/g, (match, key) => {
// 如果 data 中存在对应的 key,则返回值,否则返回原始匹配(例如 "{unknownKey}"
// Improved: Handle potential undefined/null values gracefully
return data.hasOwnProperty(key) && data[key] !== null && data[key] !== undefined ? String(data[key]) : match;
});
}
}
// 创建单例并导出
// The instance is created immediately, and its async initialize method is called.
// Other parts of the app that import this will get the instance,
// but event processing won't start until initialization completes.
const notificationProcessorService = new NotificationProcessorService();
export default notificationProcessorService;
@@ -0,0 +1,98 @@
import nodemailer from 'nodemailer';
import Mail from 'nodemailer/lib/mailer'; // Import Mail type for transporter
import { INotificationSender } from '../notification.dispatcher.service';
import { ProcessedNotification } from '../notification.processor.service';
import { EmailConfig } from '../../types/notification.types';
import { settingsService } from '../settings.service'; // Import settingsService
class EmailSenderService implements INotificationSender {
async send(notification: ProcessedNotification): Promise<void> {
const config = notification.config as EmailConfig;
const { to, subjectTemplate, smtpHost, smtpPort, smtpSecure, smtpUser, smtpPass, from } = config;
const subject = notification.subject || 'Notification'; // Use processed subject or default
const body = notification.body; // Use processed body (assuming HTML)
if (!to) {
console.error('[EmailSender] Missing recipient address (to) in configuration.');
throw new Error('Email configuration is incomplete (missing recipient address).');
}
try {
// Get global settings for fallback SMTP configuration using settingsService
const globalSmtpHost = await settingsService.getSetting('smtpHost');
const globalSmtpPortStr = await settingsService.getSetting('smtpPort');
const globalSmtpSecureStr = await settingsService.getSetting('smtpSecure');
const globalSmtpUser = await settingsService.getSetting('smtpUser');
const globalSmtpPass = await settingsService.getSetting('smtpPass');
const globalSmtpFrom = await settingsService.getSetting('smtpFrom');
// Determine SMTP settings: prioritize channel-specific, then global, then defaults
const finalSmtpHost = smtpHost || globalSmtpHost;
const finalSmtpPort = smtpPort ?? (globalSmtpPortStr ? parseInt(globalSmtpPortStr, 10) : 587); // Default port 587
const finalSmtpSecure = smtpSecure ?? (globalSmtpSecureStr === 'true') ?? false; // Default secure false
const finalSmtpUser = smtpUser || globalSmtpUser;
const finalSmtpPass = smtpPass || globalSmtpPass;
const finalFrom = from || globalSmtpFrom || 'noreply@nexus-terminal.local'; // Default from
if (!finalSmtpHost) {
console.error('[EmailSender] SMTP host is not configured (neither channel-specific nor global).');
throw new Error('SMTP host configuration is missing.');
}
// Basic validation for port
if (isNaN(finalSmtpPort) || finalSmtpPort <= 0) {
console.error(`[EmailSender] Invalid SMTP port configured: ${finalSmtpPort}. Using default 587.`);
// finalSmtpPort = 587; // Or throw error depending on strictness needed
throw new Error(`Invalid SMTP port configured: ${finalSmtpPort}`);
}
const transporterOptions: nodemailer.TransportOptions = {
host: finalSmtpHost,
port: finalSmtpPort,
secure: finalSmtpSecure, // true for 465, false for other ports
auth: (finalSmtpUser && finalSmtpPass) ? {
user: finalSmtpUser,
pass: finalSmtpPass,
} : undefined, // Only include auth if user/pass are provided
tls: {
// Do not fail on invalid certs if secure is false or not explicitly required
rejectUnauthorized: finalSmtpSecure // Stricter check based on finalSecure value
}
};
const transporter = nodemailer.createTransport(transporterOptions);
// Verify connection configuration (optional but recommended)
// try {
// await transporter.verify();
// console.log('[EmailSender] SMTP configuration verified successfully.');
// } catch (verifyError) {
// console.error('[EmailSender] SMTP configuration verification failed:', verifyError);
// throw new Error(`SMTP verification failed: ${verifyError.message}`);
// }
const mailOptions: Mail.Options = {
from: `"${finalFrom.split('@')[0]}" <${finalFrom}>`, // sender address format "Sender Name <sender@example.com>"
to: to, // list of receivers (comma-separated)
subject: subject, // Subject line
// text: 'Plain text body', // Plain text body (optional, provide if HTML is not supported well)
html: body, // html body
};
console.log(`[EmailSender] Sending email notification to: ${to} with subject: "${subject}"`);
const info = await transporter.sendMail(mailOptions);
console.log(`[EmailSender] Email sent successfully. Message ID: ${info.messageId}`);
} catch (error: any) {
console.error(`[EmailSender] Error sending email notification to ${to}:`, error);
// Provide more specific error message if possible
throw new Error(`Failed to send email notification: ${error.message || error}`);
}
}
}
// Create and export singleton instance
const emailSenderService = new EmailSenderService();
export default emailSenderService;
@@ -0,0 +1,53 @@
import axios from 'axios';
import { INotificationSender } from '../notification.dispatcher.service'; // Import the interface
import { ProcessedNotification } from '../notification.processor.service';
import { TelegramConfig } from '../../types/notification.types';
class TelegramSenderService implements INotificationSender {
async send(notification: ProcessedNotification): Promise<void> {
const config = notification.config as TelegramConfig;
const { botToken, chatId } = config;
const messageBody = notification.body;
if (!botToken || !chatId) {
console.error('[TelegramSender] Missing botToken or chatId in configuration.');
throw new Error('Telegram configuration is incomplete (missing botToken or chatId).');
}
const apiUrl = `https://api.telegram.org/bot${botToken}/sendMessage`;
try {
console.log(`[TelegramSender] Sending notification to chat ID: ${chatId}`);
const response = await axios.post(apiUrl, {
chat_id: chatId,
text: messageBody,
parse_mode: 'Markdown', // Use standard Markdown
disable_web_page_preview: true // Optional: disable link previews
}, {
timeout: 10000 // Set a timeout (e.g., 10 seconds)
});
if (response.data && response.data.ok) {
console.log(`[TelegramSender] Successfully sent notification to chat ID: ${chatId}`);
} else {
// Log Telegram's error description if available
const errorDescription = response.data?.description || 'Unknown error from Telegram API';
console.error(`[TelegramSender] Failed to send notification. Telegram API response: ${errorDescription}`, response.data);
throw new Error(`Telegram API error: ${errorDescription}`);
}
} catch (error: any) {
if (axios.isAxiosError(error)) {
console.error(`[TelegramSender] Axios error sending notification: ${error.message}`, error.response?.data);
throw new Error(`Failed to send Telegram notification (Axios Error): ${error.message}`);
} else {
console.error(`[TelegramSender] Unexpected error sending notification:`, error);
throw new Error(`Failed to send Telegram notification (Unexpected Error): ${error.message || error}`);
}
}
}
}
// Create and export singleton instance
const telegramSenderService = new TelegramSenderService();
export default telegramSenderService;
@@ -0,0 +1,107 @@
import axios, { Method } from 'axios';
import { INotificationSender } from '../notification.dispatcher.service';
import { ProcessedNotification } from '../notification.processor.service';
import { WebhookConfig } from '../../types/notification.types';
class WebhookSenderService implements INotificationSender {
async send(notification: ProcessedNotification): Promise<void> {
const config = notification.config as WebhookConfig;
const { url, method = 'POST', headers = {} } = config; // Default method to POST
const requestBody = notification.body; // Body is already processed by the processor
if (!url) {
console.error('[WebhookSender] Missing webhook URL in configuration.');
throw new Error('Webhook configuration is incomplete (missing URL).');
}
// Validate URL format (basic check)
try {
new URL(url);
} catch (e) {
console.error(`[WebhookSender] Invalid webhook URL format: ${url}`);
throw new Error(`Invalid webhook URL format: ${url}`);
}
// Prepare headers
const finalHeaders: Record<string, string> = {
'Content-Type': 'application/json', // Default Content-Type, can be overridden by config
...headers, // Merge custom headers from config
};
// Determine HTTP method
const requestMethod: Method = method.toUpperCase() as Method; // Ensure method is uppercase and valid Axios Method type
const validMethods: Method[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
if (!validMethods.includes(requestMethod)) {
console.error(`[WebhookSender] Invalid HTTP method specified: ${method}. Defaulting to POST.`);
// requestMethod = 'POST'; // Or throw an error
throw new Error(`Invalid HTTP method specified: ${method}`);
}
try {
console.log(`[WebhookSender] Sending ${requestMethod} notification to webhook URL: ${url}`);
// Prepare request data based on method
let requestData: any = undefined;
let requestParams: any = undefined;
// For GET requests, data is usually sent as query params.
// For POST/PUT/PATCH, data is sent in the body.
// We assume the processed `requestBody` is intended for the body.
// If the template was designed for GET params, this might need adjustment.
if (['POST', 'PUT', 'PATCH'].includes(requestMethod)) {
// Try to parse body as JSON if Content-Type suggests it, otherwise send as string
if (finalHeaders['Content-Type']?.toLowerCase().includes('application/json')) {
try {
requestData = JSON.parse(requestBody);
} catch (parseError) {
console.warn(`[WebhookSender] Failed to parse request body as JSON for Content-Type application/json. Sending as raw string. Body: ${requestBody.substring(0,100)}...`);
requestData = requestBody;
}
} else {
requestData = requestBody;
}
} else if (requestMethod === 'GET') {
// For GET, we might need to parse the body (if it's a query string) or handle differently.
// For simplicity now, we won't automatically convert body to params for GET.
// User should configure GET webhooks appropriately (e.g., URL includes params).
console.warn(`[WebhookSender] Sending data in body for GET request might not be standard. URL: ${url}`);
// If requestBody is intended as query params, parsing logic would be needed here.
// requestParams = querystring.parse(requestBody); // Example
}
const response = await axios({
method: requestMethod,
url: url,
headers: finalHeaders,
data: requestData,
params: requestParams,
timeout: 15000 // Set a timeout (e.g., 15 seconds)
});
// Check response status (e.g., 2xx indicates success)
if (response.status >= 200 && response.status < 300) {
console.log(`[WebhookSender] Successfully sent notification to webhook. Status: ${response.status}`);
} else {
console.warn(`[WebhookSender] Webhook endpoint responded with status: ${response.status}`, response.data);
// Consider throwing an error for non-2xx responses depending on requirements
// throw new Error(`Webhook endpoint responded with status: ${response.status}`);
}
} catch (error: any) {
if (axios.isAxiosError(error)) {
console.error(`[WebhookSender] Axios error sending notification to ${url}: ${error.message}`, error.response?.status, error.response?.data);
throw new Error(`Failed to send webhook notification (Axios Error): ${error.message}`);
} else {
console.error(`[WebhookSender] Unexpected error sending notification to ${url}:`, error);
throw new Error(`Failed to send webhook notification (Unexpected Error): ${error.message || error}`);
}
}
}
}
// Create and export singleton instance
const webhookSenderService = new WebhookSenderService();
export default webhookSenderService;