update
This commit is contained in:
@@ -7,51 +7,55 @@ import fs from 'fs';
|
||||
const localesDir = path.join(__dirname, 'locales');
|
||||
let dynamicSupportedLngs: string[] = [];
|
||||
try {
|
||||
// 同步读取 locales 目录下的所有条目
|
||||
const entries = fs.readdirSync(localesDir, { withFileTypes: true });
|
||||
// 过滤出目录,并将目录名作为支持的语言代码
|
||||
// Filter for .json files directly, assuming filenames are language codes (e.g., en-US.json)
|
||||
dynamicSupportedLngs = entries
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name);
|
||||
.filter(dirent => dirent.isFile() && dirent.name.endsWith('.json'))
|
||||
.map(dirent => dirent.name.replace('.json', '')); // Extract lang code from filename
|
||||
console.log('[i18next] Dynamically detected languages:', dynamicSupportedLngs);
|
||||
} catch (err) {
|
||||
console.error('[i18next] Error reading locales directory:', err);
|
||||
// 如果读取目录失败,可以回退到默认值或抛出错误
|
||||
dynamicSupportedLngs = ['en']; // 至少包含默认语言作为回退
|
||||
dynamicSupportedLngs = ['en-US']; // Fallback
|
||||
}
|
||||
|
||||
// 确保默认语言在支持列表中,如果目录扫描失败则添加
|
||||
export const defaultLng = 'en-US'; // 更新为 en-US
|
||||
export const defaultLng = 'en-US';
|
||||
if (!dynamicSupportedLngs.includes(defaultLng)) {
|
||||
dynamicSupportedLngs.push(defaultLng);
|
||||
console.warn(`[i18next] Default language '${defaultLng}' not found in detected directories, adding it to supported list.`);
|
||||
console.warn(`[i18next] Default language '${defaultLng}' not found in detected files, adding it to supported list.`);
|
||||
}
|
||||
export const supportedLngs = dynamicSupportedLngs; // 导出动态获取的列表
|
||||
export const supportedLngs = dynamicSupportedLngs;
|
||||
// --- 结束动态确定 ---
|
||||
|
||||
|
||||
i18next
|
||||
.use(Backend)
|
||||
.init({
|
||||
debug: process.env.NODE_ENV === 'development', // Enable debug logging in dev
|
||||
supportedLngs: supportedLngs, // 使用动态获取的列表
|
||||
fallbackLng: defaultLng,
|
||||
// lng: defaultLng, // Remove explicit lng setting here, let it be determined later or by detector
|
||||
preload: supportedLngs, // 使用动态获取的列表进行预加载
|
||||
ns: ['notifications'], // 命名空间,用于组织翻译
|
||||
defaultNS: 'notifications',
|
||||
backend: {
|
||||
// path where resources get loaded from
|
||||
loadPath: path.join(localesDir, '{{lng}}/{{ns}}.json'), // 直接使用 localesDir
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false, // Not needed for react apps
|
||||
},
|
||||
}, (err, t) => { // Add init callback
|
||||
if (err) {
|
||||
return console.error('[i18next] Error during initialization:', err);
|
||||
}
|
||||
console.log('[i18next] Initialization complete. Loaded languages:', Object.keys(i18next.store.data));
|
||||
});
|
||||
let i18nInitialized = false;
|
||||
// Create a promise that resolves when i18next is initialized
|
||||
const i18nInitializationPromise = new Promise<void>((resolve, reject) => {
|
||||
i18next
|
||||
.use(Backend)
|
||||
.init({
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
supportedLngs: supportedLngs,
|
||||
fallbackLng: defaultLng,
|
||||
preload: supportedLngs,
|
||||
// ns and defaultNS removed as translations are now in root language files (e.g., en-US.json)
|
||||
backend: {
|
||||
loadPath: path.join(localesDir, '{{lng}}.json'), // Load root JSON files directly
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
}, (err, t) => { // Init callback
|
||||
if (err) {
|
||||
console.error('[i18next] Error during initialization:', err);
|
||||
i18nInitialized = false; // Mark as not initialized on error
|
||||
return reject(err); // Reject the promise on error
|
||||
}
|
||||
console.log('[i18next] Initialization complete. Loaded languages:', Object.keys(i18next.store.data || {})); // Safe access to store.data
|
||||
i18nInitialized = true; // Mark as initialized
|
||||
resolve(); // Resolve the promise on success
|
||||
});
|
||||
});
|
||||
|
||||
export default i18next;
|
||||
// Export the promise and a function to check status (optional)
|
||||
export { i18nInitializationPromise, i18nInitialized };
|
||||
export default i18next; // Export the instance as well
|
||||
|
||||
@@ -23,6 +23,11 @@ import appearanceRoutes from './appearance/appearance.routes';
|
||||
import { initializeWebSocket } from './websocket';
|
||||
import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware';
|
||||
|
||||
// --- 初始化通知系统 (导入即初始化单例) ---
|
||||
import './services/event.service'; // 确保事件服务被加载
|
||||
import './services/notification.processor.service'; // 确保处理器被加载并监听事件
|
||||
import './services/notification.dispatcher.service'; // 确保分发器被加载并监听处理器事件
|
||||
// --- 结束通知系统初始化 ---
|
||||
// --- 环境变量和密钥初始化 ---
|
||||
const initializeEnvironment = async () => {
|
||||
const rootEnvPath = path.resolve(__dirname, '../../.env'); // 指向项目根目录的 .env
|
||||
|
||||
+4
-17
@@ -2,14 +2,14 @@
|
||||
"testNotification": {
|
||||
"subject": "Nexus Terminal Test Notification ({event})",
|
||||
"email": {
|
||||
"body": "This is a test email from Nexus Terminal for event '{{event}}'.\n\nIf you received this, your SMTP configuration is working.\n\nTimestamp: {{timestamp}}",
|
||||
"bodyHtml": "<p>This is a test email from <b>Nexus Terminal</b> for event '{{event}}'.</p><p>If you received this, your SMTP configuration is working.</p><p>Timestamp: {{timestamp}}</p>"
|
||||
"body": "This is a test email from Nexus Terminal for event '{event}'.\n\nIf you received this, your SMTP configuration is working.\n\nTimestamp: {timestamp}",
|
||||
"bodyHtml": "<p>This is a test email from <b>Nexus Terminal</b> for event '{event}'.</p><p>If you received this, your SMTP configuration is working.</p><p>Timestamp: {timestamp}</p>"
|
||||
},
|
||||
"webhook": {
|
||||
"detailsMessage": "This is a test notification from Nexus Terminal (Webhook - i18n) for event '{{event}}'."
|
||||
"detailsMessage": "This is a test notification from Nexus Terminal (Webhook - i18n) for event '{event}'."
|
||||
},
|
||||
"telegram": {
|
||||
"detailsMessage": "This is a test notification from Nexus Terminal (Telegram - i18n) for event '{{event}}'.",
|
||||
"detailsMessage": "This is a test notification from Nexus Terminal (Telegram - i18n) for event '{event}'.",
|
||||
"bodyTemplate": "*Nexus Terminal Test Notification*\nEvent: `{event}`\nTimestamp: {timestamp}\nDetails:\n```\n{details}\n```"
|
||||
}
|
||||
},
|
||||
@@ -47,18 +47,5 @@
|
||||
"SERVER_ERROR": "Server Error",
|
||||
"DATABASE_MIGRATION": "Database Migration",
|
||||
"ADMIN_SETUP_COMPLETE": "Initial Admin Setup Completed"
|
||||
|
||||
},
|
||||
"eventBody": {
|
||||
|
||||
"SETTINGS_UPDATED": "Event: {{event}}\nTimestamp: {{timestamp}}\nDetails:\n{{details}}"
|
||||
},
|
||||
"connection": {
|
||||
"testSuccess": "Connection test successful for '{{name}}'!",
|
||||
"testFailed": "Connection test failed for '{{name}}': {{error}}"
|
||||
},
|
||||
"settings": {
|
||||
"ipWhitelistUpdated": "IP Whitelist updated successfully.",
|
||||
"updated": "Settings updated successfully."
|
||||
}
|
||||
}
|
||||
+4
-15
@@ -2,14 +2,14 @@
|
||||
"testNotification": {
|
||||
"subject": "星枢ターミナル テスト通知 ({event})",
|
||||
"email": {
|
||||
"body": "これは、星枢ターミナルからのイベント'{{event}}'に関するテストメールです。\n\nこのメールを受信した場合、SMTP 設定は正常に機能しています。\n\nタイムスタンプ: {{timestamp}}",
|
||||
"bodyHtml": "<p>これは、<b>星枢ターミナル</b>からのイベント'{{event}}'に関するテストメールです。</p><p>このメールを受信した場合、SMTP 設定は正常に機能しています。</p><p>タイムスタンプ: {{timestamp}}</p>"
|
||||
"body": "これは、星枢ターミナルからのイベント'{event}'に関するテストメールです。\n\nこのメールを受信した場合、SMTP 設定は正常に機能しています。\n\nタイムスタンプ: {timestamp}",
|
||||
"bodyHtml": "<p>これは、<b>星枢ターミナル</b>からのイベント'{event}'に関するテストメールです。</p><p>このメールを受信した場合、SMTP 設定は正常に機能しています。</p><p>タイムスタンプ: {timestamp}</p>"
|
||||
},
|
||||
"webhook": {
|
||||
"detailsMessage": "これは星枢ターミナルからのテスト通知 (Webhook - i18n) です。 イベント:'{{event}}'。"
|
||||
"detailsMessage": "これは星枢ターミナルからのテスト通知 (Webhook - i18n) です。 イベント:'{event}'。"
|
||||
},
|
||||
"telegram": {
|
||||
"detailsMessage": "これは星枢ターミナルからのテスト通知 (Telegram - i18n) です。 イベント:'{{event}}'。",
|
||||
"detailsMessage": "これは星枢ターミナルからのテスト通知 (Telegram - i18n) です。 イベント:'{event}'。",
|
||||
"bodyTemplate": "*星枢ターミナル テスト通知*\nイベント: `{event}`\nタイムスタンプ: {timestamp}\n詳細:\n```\n{details}\n```"
|
||||
}
|
||||
},
|
||||
@@ -47,16 +47,5 @@
|
||||
"SERVER_ERROR": "サーバーエラー",
|
||||
"DATABASE_MIGRATION": "データベース移行",
|
||||
"ADMIN_SETUP_COMPLETE": "初期管理者設定完了"
|
||||
},
|
||||
"eventBody": {
|
||||
"SETTINGS_UPDATED": "イベント: {{event}}\nタイムスタンプ: {{timestamp}}\n詳細:\n{{details}}"
|
||||
},
|
||||
"connection": {
|
||||
"testSuccess": "接続 '{{name}}' のテストに成功しました!",
|
||||
"testFailed": "接続 '{{name}}' のテストに失敗しました: {{error}}"
|
||||
},
|
||||
"settings": {
|
||||
"ipWhitelistUpdated": "IP ホワイトリストを更新しました。",
|
||||
"updated": "設定を更新しました。"
|
||||
}
|
||||
}
|
||||
+2
-28
@@ -1,18 +1,4 @@
|
||||
{
|
||||
"testNotification": {
|
||||
"subject": "星枢终端测试通知 ({event})",
|
||||
"email": {
|
||||
"body": "这是一封来自星枢终端关于事件 '{{event}}' 的测试邮件。\n\n如果您收到此邮件,表示您的 SMTP 配置工作正常。\n\n时间戳: {{timestamp}}",
|
||||
"bodyHtml": "<p>这是一封来自 <b>星枢终端</b> 关于事件 '{{event}}' 的测试邮件。</p><p>如果您收到此邮件,表示您的 SMTP 配置工作正常。</p><p>时间戳: {{timestamp}}</p>"
|
||||
},
|
||||
"webhook": {
|
||||
"detailsMessage": "这是一条来自星枢终端的测试通知 (Webhook - i18n),事件:'{{event}}'。"
|
||||
},
|
||||
"telegram": {
|
||||
"detailsMessage": "这是一条来自星枢终端的测试通知 (Telegram - i18n),事件:'{{event}}'。",
|
||||
"bodyTemplate": "*星枢终端测试通知*\n事件: `{event}`\n时间戳: {timestamp}\n详情:\n```\n{details}\n```"
|
||||
}
|
||||
},
|
||||
"event": {
|
||||
"LOGIN_SUCCESS": "登录成功",
|
||||
"LOGIN_FAILURE": "登录失败",
|
||||
@@ -46,19 +32,7 @@
|
||||
"SERVER_STARTED": "服务器已启动",
|
||||
"SERVER_ERROR": "服务器错误",
|
||||
"DATABASE_MIGRATION": "数据库迁移",
|
||||
"ADMIN_SETUP_COMPLETE": "初始管理员设置完成"
|
||||
|
||||
},
|
||||
"eventBody": {
|
||||
|
||||
"SETTINGS_UPDATED": "事件: {{event}}\n时间戳: {{timestamp}}\n详情:\n{{details}}"
|
||||
},
|
||||
"connection": {
|
||||
"testSuccess": "连接 '{{name}}' 测试成功!",
|
||||
"testFailed": "连接 '{{name}}' 测试失败: {{error}}"
|
||||
},
|
||||
"settings": {
|
||||
"ipWhitelistUpdated": "IP 白名单更新成功。",
|
||||
"updated": "设置更新成功。"
|
||||
"ADMIN_SETUP_COMPLETE": "初始管理员设置完成",
|
||||
"testNotification": "测试通知"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,30 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import { NotificationSetting } from '../types/notification.types';
|
||||
import { AuditLogService } from '../services/audit.service';
|
||||
import { NotificationSettingsRepository } from '../repositories/notification.repository'; // Use repository
|
||||
import { NotificationSetting, NotificationChannelType, NotificationChannelConfig, WebhookConfig, EmailConfig, TelegramConfig, NotificationEvent } from '../types/notification.types';
|
||||
import { AuditLogService } from '../services/audit.service'; // Keep for now if other parts use it
|
||||
import { AppEventType, default as eventService } from '../services/event.service'; // Import event service
|
||||
|
||||
const auditLogService = new AuditLogService();
|
||||
// Remove sender imports as they are no longer called directly for testing
|
||||
// import telegramSenderService from '../services/senders/telegram.sender.service';
|
||||
// import emailSenderService from '../services/senders/email.sender.service';
|
||||
// import webhookSenderService from '../services/senders/webhook.sender.service';
|
||||
// import { ProcessedNotification } from '../services/notification.processor.service'; // Not needed here
|
||||
|
||||
// Removed escapeTelegramMarkdownV2 helper function
|
||||
|
||||
const auditLogService = new AuditLogService(); // Keep for now if other parts use it, but prefer eventService
|
||||
|
||||
export class NotificationController {
|
||||
private notificationService: NotificationService;
|
||||
private repository: NotificationSettingsRepository; // Use repository
|
||||
|
||||
constructor() {
|
||||
this.notificationService = new NotificationService();
|
||||
this.repository = new NotificationSettingsRepository(); // Instantiate repository
|
||||
}
|
||||
|
||||
// GET /api/v1/notifications
|
||||
getAll = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = await this.notificationService.getAllSettings();
|
||||
const settings = await this.repository.getAll(); // Use repository
|
||||
res.status(200).json(settings);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ message: '获取通知设置失败', error: error.message });
|
||||
@@ -32,11 +41,14 @@ export class NotificationController {
|
||||
}
|
||||
|
||||
try {
|
||||
const newSettingId = await this.notificationService.createSetting(settingData);
|
||||
const newSetting = await this.notificationService.getSettingById(newSettingId);
|
||||
// 记录审计日志
|
||||
const newSettingId = await this.repository.create(settingData); // Use repository
|
||||
const newSetting = await this.repository.getById(newSettingId);
|
||||
// 记录审计日志 (Use event service)
|
||||
if (newSetting) {
|
||||
auditLogService.logAction('NOTIFICATION_SETTING_CREATED', { settingId: newSetting.id, name: newSetting.name, type: newSetting.channel_type });
|
||||
eventService.emitEvent(AppEventType.NotificationSettingCreated, {
|
||||
userId: (req.session as any).userId, // Assuming userId is in session
|
||||
details: { settingId: newSetting.id, name: newSetting.name, type: newSetting.channel_type }
|
||||
});
|
||||
}
|
||||
res.status(201).json(newSetting);
|
||||
} catch (error: any) {
|
||||
@@ -59,11 +71,14 @@ export class NotificationController {
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await this.notificationService.updateSetting(id, settingData);
|
||||
const success = await this.repository.update(id, settingData); // Use repository
|
||||
if (success) {
|
||||
const updatedSetting = await this.notificationService.getSettingById(id);
|
||||
// 记录审计日志
|
||||
auditLogService.logAction('NOTIFICATION_SETTING_UPDATED', { settingId: id, updatedFields: Object.keys(settingData) });
|
||||
const updatedSetting = await this.repository.getById(id);
|
||||
// 记录审计日志 (Use event service)
|
||||
eventService.emitEvent(AppEventType.NotificationSettingUpdated, {
|
||||
userId: (req.session as any).userId,
|
||||
details: { settingId: id, updatedFields: Object.keys(settingData) }
|
||||
});
|
||||
res.status(200).json(updatedSetting);
|
||||
} else {
|
||||
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` });
|
||||
@@ -83,81 +98,100 @@ export class NotificationController {
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await this.notificationService.deleteSetting(id);
|
||||
const settingToDelete = await this.repository.getById(id); // Get details before deleting for audit log
|
||||
if (!settingToDelete) {
|
||||
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` });
|
||||
return;
|
||||
}
|
||||
const success = await this.repository.delete(id); // Use repository
|
||||
if (success) {
|
||||
// 记录审计日志
|
||||
auditLogService.logAction('NOTIFICATION_SETTING_DELETED', { settingId: id });
|
||||
// 记录审计日志 (Use event service)
|
||||
eventService.emitEvent(AppEventType.NotificationSettingDeleted, {
|
||||
userId: (req.session as any).userId,
|
||||
details: { settingId: id, name: settingToDelete.name, type: settingToDelete.channel_type } // Include name/type in audit
|
||||
});
|
||||
res.status(204).send(); // No Content
|
||||
} else {
|
||||
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` });
|
||||
// Should not happen if getById succeeded, but handle defensively
|
||||
res.status(404).json({ message: `删除 ID 为 ${id} 的通知设置失败,可能已被删除` });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ message: '删除通知设置失败', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// --- Refactored Test Endpoints ---
|
||||
|
||||
// Removed executeTestSend method as testing now goes through the event system
|
||||
|
||||
// POST /api/v1/notifications/:id/test
|
||||
// Tests an existing, saved setting configuration by triggering a test event
|
||||
testSetting = async (req: Request, res: Response): Promise<void> => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const { config } = req.body;
|
||||
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ message: '无效的通知设置 ID' });
|
||||
return;
|
||||
}
|
||||
if (!config) {
|
||||
res.status(400).json({ message: '缺少用于测试的配置信息' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const originalSetting = await this.notificationService.getSettingById(id);
|
||||
if (!originalSetting) {
|
||||
const settingToTest = await this.repository.getById(id);
|
||||
if (!settingToTest) {
|
||||
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` });
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.notificationService.testSetting(originalSetting.channel_type, config);
|
||||
// Trigger the standard test event, passing the config to be used by the processor
|
||||
eventService.emitEvent(AppEventType.TestNotification, {
|
||||
userId: (req.session as any).userId, // Optional: associate test with user
|
||||
details: {
|
||||
message: `为设置 ID ${id} (${settingToTest.name}) 触发的测试`,
|
||||
testTargetConfig: settingToTest.config, // Pass the config to use
|
||||
testTargetChannelType: settingToTest.channel_type // Pass the channel type
|
||||
}
|
||||
});
|
||||
|
||||
// Respond immediately confirming the event was triggered
|
||||
res.status(200).json({ message: '测试通知事件已触发。请检查对应渠道的接收情况。' });
|
||||
|
||||
if (result.success) {
|
||||
// 记录审计日志 (可选,根据需要决定是否记录测试操作)
|
||||
|
||||
res.status(200).json({ message: result.message });
|
||||
} else {
|
||||
// 记录审计日志 (可选)
|
||||
|
||||
res.status(500).json({ message: result.message });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message });
|
||||
console.error(`[NotificationController] Error triggering test for setting ${id}:`, error);
|
||||
res.status(500).json({ message: '触发测试通知时发生内部错误', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/v1/notifications/test-unsaved
|
||||
// Tests configuration data provided in the request body by triggering a test event
|
||||
testUnsavedSetting = async (req: Request, res: Response): Promise<void> => {
|
||||
const { channel_type, config } = req.body;
|
||||
const { channel_type, config } = req.body as { channel_type: NotificationChannelType, config: NotificationChannelConfig };
|
||||
|
||||
if (!channel_type || !config) {
|
||||
res.status(400).json({ message: '缺少必要的测试信息 (channel_type, config)' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic validation for channel type
|
||||
if (!['webhook', 'email', 'telegram'].includes(channel_type)) {
|
||||
res.status(400).json({ message: '无效的渠道类型' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.notificationService.testSetting(channel_type, config);
|
||||
// Trigger the standard test event, passing the unsaved config to be used by the processor
|
||||
eventService.emitEvent(AppEventType.TestNotification, {
|
||||
userId: (req.session as any).userId,
|
||||
details: {
|
||||
message: `为未保存的 ${channel_type} 配置触发的测试`,
|
||||
testTargetConfig: config, // Pass the unsaved config to use
|
||||
testTargetChannelType: channel_type // Pass the channel type
|
||||
}
|
||||
});
|
||||
|
||||
// Respond immediately confirming the event was triggered
|
||||
res.status(200).json({ message: '测试通知事件已触发。请检查对应渠道的接收情况。' });
|
||||
|
||||
if (result.success) {
|
||||
res.status(200).json({ message: result.message });
|
||||
} else {
|
||||
res.status(500).json({ message: result.message });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message });
|
||||
console.error(`[NotificationController] Error triggering test for unsaved ${channel_type}:`, error);
|
||||
res.status(500).json({ message: '触发测试通知时发生内部错误', error: error.message });
|
||||
}
|
||||
};
|
||||
}
|
||||
} // End of class NotificationController
|
||||
|
||||
@@ -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;
|
||||
@@ -82,8 +82,8 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
// The component handles its own 'testingNotification' state.
|
||||
error.value = null; // Clear previous general errors
|
||||
try {
|
||||
// Send the config to test in the request body
|
||||
const response = await apiClient.post<{ message: string }>(`/notifications/${id}/test`, { config }); // 使用 apiClient
|
||||
// Send the request without a body, as the backend uses the saved config for the given ID
|
||||
const response = await apiClient.post<{ message: string }>(`/notifications/${id}/test`); // 使用 apiClient, removed config from body
|
||||
return { success: true, message: response.data.message || '测试成功' };
|
||||
} catch (err: any) {
|
||||
console.error(`Error testing notification setting ${id}:`, err);
|
||||
|
||||
Reference in New Issue
Block a user