import axios, { AxiosRequestConfig } from "axios"; import { NotificationSettingsRepository } from "../repositories/notification.repository"; import { NotificationSetting, NotificationEvent, NotificationPayload, WebhookConfig, EmailConfig, TelegramConfig, NotificationChannelConfig, NotificationChannelType, } from "../types/notification.types"; import * as nodemailer from "nodemailer"; import Mail from "nodemailer/lib/mailer"; import i18next, { defaultLng, supportedLngs } from "../i18n"; import { settingsService } from "./settings.service"; import { formatInTimeZone } from "date-fns-tz"; const testSubjectKey = "testNotification.subject"; const testEmailBodyKey = "testNotification.email.body"; const testEmailBodyHtmlKey = "testNotification.email.bodyHtml"; const testWebhookDetailsKey = "testNotification.webhook.detailsMessage"; const testTelegramDetailsKey = "testNotification.telegram.detailsMessage"; const testTelegramBodyTemplateKey = "testNotification.telegram.bodyTemplate"; export class NotificationService { private repository: NotificationSettingsRepository; constructor() { this.repository = new NotificationSettingsRepository(); } async getAllSettings(): Promise { return this.repository.getAll(); } async getSettingById(id: number): Promise { return this.repository.getById(id); } async createSetting( settingData: Omit ): Promise { return this.repository.create(settingData); } async updateSetting( id: number, settingData: Partial< Omit > ): Promise { return this.repository.update(id, settingData); } async deleteSetting(id: number): Promise { return this.repository.delete(id); } async testSetting( channelType: NotificationChannelType, config: NotificationChannelConfig ): Promise<{ success: boolean; message: string }> { switch (channelType) { case "email": return this._testEmailSetting(config as EmailConfig); case "webhook": return this._testWebhookSetting(config as WebhookConfig); case "telegram": return this._testTelegramSetting(config as TelegramConfig); default: console.warn(`[通知测试] 不支持的测试渠道类型: ${channelType}`); return { success: false, message: `不支持测试此渠道类型 (${channelType})`, }; } } private async _testEmailSetting( config: EmailConfig ): Promise<{ success: boolean; message: string }> { console.log("[通知测试 - 邮件] 开始测试..."); if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) { console.error("[通知测试 - 邮件] 缺少必要的配置。"); return { success: false, message: "测试邮件失败:缺少必要的 SMTP 配置信息 (收件人, 主机, 端口, 发件人)。", }; } let userLang = defaultLng; try { const langSetting = await settingsService.getSetting("language"); if (langSetting && supportedLngs.includes(langSetting)) { userLang = langSetting; } console.log(`[通知测试 - 邮件] 使用语言: ${userLang}`); } catch (error) { console.error( `[通知测试 - 邮件] 获取语言设置时出错,使用默认 (${defaultLng}):`, error ); } const transporterOptions = { host: config.smtpHost, port: config.smtpPort, secure: config.smtpSecure ?? true, auth: config.smtpUser || config.smtpPass ? { user: config.smtpUser, pass: config.smtpPass, } : undefined, }; const transporter = nodemailer.createTransport(transporterOptions); const eventDisplayName = i18next.t(`event.SETTINGS_UPDATED`, { lng: userLang, defaultValue: "SETTINGS_UPDATED", }); const mailOptions: Mail.Options = { from: config.from, to: config.to, subject: i18next.t(testSubjectKey, { lng: userLang, defaultValue: "Nexus Terminal Test Notification ({event})", eventDisplay: eventDisplayName, }), text: i18next.t(testEmailBodyKey, { lng: userLang, timestamp: new Date().toISOString(), defaultValue: `This is a test email from Nexus Terminal for event '{{event}}'.\n\nIf you received this, your SMTP configuration is working.\n\nTimestamp: {{timestamp}}`, eventDisplay: eventDisplayName, }), html: i18next.t(testEmailBodyHtmlKey, { lng: userLang, timestamp: new Date().toISOString(), defaultValue: `

This is a test email from Nexus Terminal for event '{{event}}'.

If you received this, your SMTP configuration is working.

Timestamp: {{timestamp}}

`, eventDisplay: eventDisplayName, }), }; try { console.log( `[通知测试 - 邮件] 尝试通过 ${config.smtpHost}:${config.smtpPort} 发送测试邮件至 ${config.to}` ); const info = await transporter.sendMail(mailOptions); console.log(`[通知测试 - 邮件] 测试邮件发送成功: ${info.messageId}`); return { success: true, message: "测试邮件发送成功!请检查收件箱。" }; } catch (error: any) { console.error(`[通知测试 - 邮件] 发送测试邮件时出错:`, error); return { success: false, message: `测试邮件发送失败: ${error.message || "未知错误"}`, }; } } private async _testWebhookSetting( config: WebhookConfig ): Promise<{ success: boolean; message: string }> { console.log("[通知测试 - Webhook] 开始测试..."); if (!config.url) { console.error("[通知测试 - Webhook] 缺少 URL。"); return { success: false, message: "测试 Webhook 失败:缺少 URL。" }; } let userLang = defaultLng; try { const langSetting = await settingsService.getSetting("language"); if (langSetting && supportedLngs.includes(langSetting)) { userLang = langSetting; } console.log(`[通知测试 - Webhook] 使用语言: ${userLang}`); } catch (error) { console.error( `[通知测试 - Webhook] 获取语言设置时出错,使用默认 (${defaultLng}):`, error ); } const testPayload: NotificationPayload = { event: "SETTINGS_UPDATED", timestamp: Date.now(), details: { message: i18next.t(testWebhookDetailsKey, { lng: userLang, defaultValue: "This is a test notification from Nexus Terminal (Webhook).", }), }, }; const translatedWebhookMessage = typeof testPayload.details === "object" && testPayload.details?.message ? testPayload.details.message : "Details 不是带有 message 属性的对象"; console.log( `[通知测试 - Webhook] 测试负载已创建。翻译后的 details.message:`, translatedWebhookMessage ); const eventDisplayName = i18next.t(`event.${testPayload.event}`, { lng: userLang, defaultValue: testPayload.event, }); const defaultBody = JSON.stringify(testPayload, null, 2); const defaultBodyTemplate = `Default: JSON payload. Use {event}, {timestamp}, {details}.`; const templateDataWebhookTest: Record = { event: testPayload.event, eventDisplay: eventDisplayName, timestamp: new Date(testPayload.timestamp).toISOString(), details: typeof testPayload.details === "object" && testPayload.details?.message ? testPayload.details.message : typeof testPayload.details === "string" ? testPayload.details : JSON.stringify(testPayload.details || {}, null, 2), }; const requestBody = this._renderTemplate( config.bodyTemplate || defaultBodyTemplate, templateDataWebhookTest, defaultBody ); const requestConfig: AxiosRequestConfig = { method: config.method || "POST", url: config.url, headers: { "Content-Type": "application/json", ...(config.headers || {}), }, data: requestBody, timeout: 15000, }; try { console.log(`[通知测试 - Webhook] 发送测试 Webhook 到 ${config.url}`); const response = await axios(requestConfig); console.log( `[通知测试 - Webhook] 测试 Webhook 成功发送到 ${config.url}。状态: ${response.status}` ); return { success: true, message: `测试 Webhook 发送成功 (状态码: ${response.status})。`, }; } catch (error: any) { const errorMessage = error.response?.data?.message || error.response?.data || error.message || "未知错误"; console.error( `[通知测试 - Webhook] 发送测试 Webhook 到 ${config.url} 时出错:`, errorMessage ); return { success: false, message: `测试 Webhook 发送失败: ${errorMessage}`, }; } } private async _testTelegramSetting( config: TelegramConfig ): Promise<{ success: boolean; message: string }> { console.log("[通知测试 - Telegram] 开始测试..."); if (!config.botToken || !config.chatId) { console.error("[通知测试 - Telegram] 缺少 botToken 或 chatId。"); return { success: false, message: "测试 Telegram 失败:缺少机器人 Token 或聊天 ID。", }; } let userLang = defaultLng; try { const langSetting = await settingsService.getSetting("language"); if (langSetting && supportedLngs.includes(langSetting)) { userLang = langSetting; } console.log(`[通知测试 - Telegram] 使用语言: ${userLang}`); } catch (error) { console.error( `[通知测试 - Telegram] 获取语言设置时出错,使用默认 (${defaultLng}):`, error ); } const testPayload: NotificationPayload = { event: "SETTINGS_UPDATED", timestamp: Date.now(), details: undefined, }; const detailsOptions = { lng: userLang, defaultValue: "Fallback: This is a test notification from Nexus Terminal (Telegram).", }; const keyWithNamespace = `notifications:${testTelegramDetailsKey}`; const translatedDetailsMessage = i18next.t( keyWithNamespace, detailsOptions ); testPayload.details = { message: translatedDetailsMessage }; const messageFromPayload = typeof testPayload.details === "object" && testPayload.details?.message ? testPayload.details.message : "Details is not an object with message property"; console.log( `[Notification Test - Telegram] Test payload created. Final details.message in payload:`, messageFromPayload ); const templateKeyWithNamespace = `notifications:${testTelegramBodyTemplateKey}`; const defaultMessageTemplateFromI18n = i18next.t(templateKeyWithNamespace, { lng: userLang, defaultValue: `Fallback Template: *Nexus Terminal Test Notification*\nEvent: \`{event}\`\nTimestamp: {timestamp}\nDetails:\n\`\`\`\n{details}\n\`\`\``, }); console.log( `[通知测试 - Telegram] 来自 i18n 的默认模板 (使用语言 '${userLang}', 键 '${templateKeyWithNamespace}'):`, defaultMessageTemplateFromI18n ); const templateToUse = config.messageTemplate || defaultMessageTemplateFromI18n; console.log(`[通知测试 - Telegram] 要渲染的模板:`, templateToUse); const eventDisplayName = i18next.t(`event.${testPayload.event}`, { lng: userLang, defaultValue: testPayload.event, }); const templateDataTelegramTest: Record = { event: this._escapeBasicMarkdown(testPayload.event), eventDisplay: this._escapeBasicMarkdown(eventDisplayName), timestamp: new Date(testPayload.timestamp).toISOString(), details: this._escapeBasicMarkdown(messageFromPayload), }; const messageText = this._renderTemplate( templateToUse, templateDataTelegramTest, defaultMessageTemplateFromI18n ); console.log(`[通知测试 - Telegram] 渲染的消息文本:`, messageText); const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`; try { console.log( `[通知测试 - Telegram] 发送测试 Telegram 消息到聊天 ID ${config.chatId}` ); const response = await axios.post( telegramApiUrl, { chat_id: config.chatId, text: messageText, parse_mode: "Markdown", }, { timeout: 15000 } ); if (response.data?.ok) { console.log(`[通知测试 - Telegram] 测试 Telegram 消息发送成功。`); return { success: true, message: "测试 Telegram 消息发送成功!" }; } else { console.error( `[通知测试 - Telegram] Telegram API 返回错误:`, response.data?.description ); return { success: false, message: `测试 Telegram 发送失败: ${ response.data?.description || "API 返回失败" }`, }; } } catch (error: any) { const errorMessage = error.response?.data?.description || error.response?.data || error.message || "未知错误"; console.error( `[通知测试 - Telegram] 发送测试 Telegram 消息时出错:`, errorMessage ); return { success: false, message: `测试 Telegram 发送失败: ${errorMessage}`, }; } } async sendNotification( event: NotificationEvent, details?: Record | string ): Promise { // console.log(`[通知] 事件触发: ${event}`, details || ""); let userLang = defaultLng; let userTimezone = "UTC"; try { const [langSetting, timezoneSetting] = await Promise.all([ settingsService.getSetting("language"), settingsService.getSetting("timezone"), ]); if (langSetting && supportedLngs.includes(langSetting)) { userLang = langSetting; } if (timezoneSetting) { userTimezone = timezoneSetting; } } catch (error) { console.error(`[通知] 获取事件 ${event} 的语言或时区设置时出错:`, error); } console.log( `[通知] 事件 ${event} 使用语言 '${userLang}', 时区 '${userTimezone}'` ); const payload: NotificationPayload = { event, timestamp: Date.now(), details: details || undefined, }; try { const applicableSettings = await this.repository.getEnabledByEvent(event); console.log( `[通知] 找到 ${applicableSettings.length} 个适用于事件 ${event} 的设置` ); if (applicableSettings.length === 0) { return; // 此事件没有启用的设置 } const sendPromises = applicableSettings.map((setting) => { switch (setting.channel_type) { case "webhook": return this._sendWebhook(setting, payload, userLang, userTimezone); case "email": return this._sendEmail(setting, payload, userLang, userTimezone); case "telegram": return this._sendTelegram(setting, payload, userLang, userTimezone); default: console.warn( `[通知] 未知渠道类型: ${setting.channel_type} (设置 ID: ${setting.id})` ); return Promise.resolve(); } }); await Promise.allSettled(sendPromises); console.log(`[通知] 完成尝试发送事件 ${event} 的通知`); } catch (error) { console.error(`[通知] 获取或处理事件 ${event} 的设置时出错:`, error); } } private _escapeBasicMarkdown(text: string): string { if (typeof text !== "string") return ""; return text.replace(/([*_`\[])/g, "\\$1"); } private _renderTemplate( template: string | undefined, data: Record, defaultText: string ): string { if (!template) return defaultText; let rendered = template; for (const key in data) { rendered = rendered.replace(new RegExp(`\\{${key}\\}`, "g"), data[key]); } return rendered; } private async _sendWebhook( setting: NotificationSetting, payload: NotificationPayload, userLang: string, userTimezone: string ): Promise { const config = setting.config as WebhookConfig; if (!config.url) { console.error(`[通知] Webhook 设置 ID ${setting.id} 缺少 URL。`); return; } const eventDisplayName = i18next.t(`event.${payload.event}`, { lng: userLang, defaultValue: payload.event, }); const translatedDetails = this._translatePayloadDetails( payload.details, userLang ); const translatedPayload = { ...payload, details: translatedDetails }; const defaultBody = JSON.stringify(translatedPayload, null, 2); const defaultBodyTemplate = `Default: JSON payload. Use {event}, {timestamp}, {details}.`; const templateDataWebhook: Record = { event: translatedPayload.event, eventDisplay: eventDisplayName, timestamp: formatInTimeZone( new Date(translatedPayload.timestamp), userTimezone, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX" ), details: typeof translatedPayload.details === "object" && translatedPayload.details?.message ? translatedPayload.details.message : typeof translatedPayload.details === "string" ? translatedPayload.details : JSON.stringify(translatedPayload.details || {}, null, 2), }; let templateToRender = config.bodyTemplate || defaultBodyTemplate; let isCustomTemplate = !!config.bodyTemplate; if (isCustomTemplate) { console.log( `[_sendWebhook] Original custom body template for event ${payload.event}:`, templateToRender ); templateToRender = templateToRender.replace( /\{event\}/g, "{eventDisplay}" ); console.log( `[_sendWebhook] Pre-processed body template (replaced {event} with {eventDisplay}):`, templateToRender ); } else { console.log( `[_sendWebhook] No custom body template found. Using default template for event ${payload.event}` ); } const requestBody = this._renderTemplate( templateToRender, templateDataWebhook, defaultBody ); const requestConfig: AxiosRequestConfig = { method: config.method || "POST", url: config.url, headers: { "Content-Type": "application/json", ...(config.headers || {}), }, data: requestBody, timeout: 10000, }; try { console.log( `[通知] 发送 Webhook 到 ${config.url} (事件: ${payload.event})` ); const response = await axios(requestConfig); console.log( `[通知] Webhook 成功发送到 ${config.url}。状态: ${response.status}` ); } catch (error: any) { const errorMessage = error.response?.data?.message || error.response?.data || error.message; console.error( `[通知] 发送 Webhook 到 ${config.url} (设置 ID: ${setting.id}) 时出错:`, errorMessage ); } } private async _sendEmail( setting: NotificationSetting, payload: NotificationPayload, userLang: string, userTimezone: string ): Promise { const config = setting.config as EmailConfig; if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) { console.error( `[通知] 邮件设置 ID ${setting.id} 缺少必要的 SMTP 配置 (to, smtpHost, smtpPort, from)。` ); return; } const transporterOptions = { host: config.smtpHost, port: config.smtpPort, secure: config.smtpSecure ?? true, auth: config.smtpUser || config.smtpPass ? { user: config.smtpUser, pass: config.smtpPass, } : undefined, }; const transporter = nodemailer.createTransport(transporterOptions); const i18nOptions: Record = { lng: userLang }; if (payload.details && typeof payload.details === "object") { Object.assign(i18nOptions, payload.details); } else if (payload.details !== undefined) { i18nOptions.details = payload.details; } const eventDisplayName = i18next.t(`event.${payload.event}`, { lng: userLang, defaultValue: payload.event, }); const subject = eventDisplayName; console.log( `[_sendEmail] Using fixed subject for event ${payload.event}: ${subject}` ); const formattedTimestampForEmail = formatInTimeZone( new Date(payload.timestamp), userTimezone, "yyyy-MM-dd HH:mm:ss zzz" ); const detailsString = typeof payload.details === "string" ? payload.details : JSON.stringify(payload.details || {}, null, 2); const templateDataEmailBody: Record = { event: payload.event, eventDisplay: eventDisplayName, timestamp: formattedTimestampForEmail, details: detailsString, ...Object.entries(i18nOptions).reduce((acc, [key, value]) => { if (key !== "lng" && typeof value !== "object") { acc[key] = String(value); } return acc; }, {} as Record), }; console.log( `[_sendEmail] Prepared templateDataEmailBody for event ${payload.event}:`, templateDataEmailBody ); let body = ""; const defaultBodyText = `Event: ${eventDisplayName}\nTimestamp: ${formattedTimestampForEmail}\nDetails:\n${detailsString}`; if (config.bodyTemplate) { let templateToRender = config.bodyTemplate; console.log( `[_sendEmail] Original custom body template for event ${payload.event}:`, templateToRender ); templateToRender = templateToRender.replace( /\{event\}/g, "{eventDisplay}" ); console.log( `[_sendEmail] Pre-processed body template (replaced {event} with {eventDisplay}):`, templateToRender ); body = this._renderTemplate( templateToRender, templateDataEmailBody, defaultBodyText ); } else { console.log( `[_sendEmail] No custom body template found. Using default constructed body text for event ${payload.event}` ); body = defaultBodyText; } console.log( `[_sendEmail] Final email body for event ${payload.event}:\n${body}` ); const mailOptions: Mail.Options = { from: config.from, to: config.to, subject: subject, text: body, }; try { console.log( `[通知] 通过 ${config.smtpHost}:${config.smtpPort} 发送邮件至 ${config.to} (事件: ${payload.event}, 主题: ${subject})` ); const info = await transporter.sendMail(mailOptions); console.log( `[通知] 邮件成功发送至 ${config.to} (设置 ID: ${setting.id})。消息 ID: ${info.messageId}` ); } catch (error: any) { console.error( `[通知] 通过 ${config.smtpHost} 发送邮件 (设置 ID: ${setting.id}) 时出错:`, error ); } } private async _sendTelegram( setting: NotificationSetting, payload: NotificationPayload, userLang: string, userTimezone: string ): Promise { console.log( `[_sendTelegram] Initiating for event: ${payload.event}, Setting ID: ${setting.id}, Lang: ${userLang}, Timezone: ${userTimezone}` ); console.log( `[_sendTelegram] Received payload:`, JSON.stringify(payload, null, 2) ); const config = setting.config as TelegramConfig; if (!config.botToken || !config.chatId) { console.error( `[通知] Telegram 设置 ID ${setting.id} 缺少 botToken 或 chatId。` ); return; } let detailsText = ""; if (payload.details) { if ( payload.event === "SETTINGS_UPDATED" && typeof payload.details === "object" && Array.isArray(payload.details.updatedKeys) ) { detailsText = payload.details.updatedKeys.join(", "); } else if (typeof payload.details === "string") { detailsText = payload.details; } else { detailsText = JSON.stringify(payload.details); } } console.log(`[_sendTelegram] Formatted detailsText:`, detailsText); const translatedEventName = i18next.t(`event.${payload.event}`, { lng: userLang, defaultValue: payload.event, }); const templateData: Record = { event: translatedEventName, timestamp: formatInTimeZone( new Date(payload.timestamp), userTimezone, "yyyy-MM-dd HH:mm:ss zzz" ), details: detailsText, }; console.log( `[_sendTelegram] Prepared templateData (NO escaping):`, JSON.stringify(templateData, null, 2) ); let messageText = ""; if (config.messageTemplate) { console.log( `[_sendTelegram] Using custom template:`, config.messageTemplate ); const fallbackForCustom = `Event: ${templateData.event}, Details: ${templateData.details}`; messageText = this._renderTemplate( config.messageTemplate, templateData, fallbackForCustom ); } else { const i18nKey = `eventBody.${payload.event}`; console.log(`[_sendTelegram] Using i18n template key:`, i18nKey); const fallbackBody = `*Fallback Notification*\nEvent: ${templateData.event}\nTime: \`${templateData.timestamp}\`\nDetails: ${templateData.details}`; messageText = i18next.t(i18nKey, { lng: userLang, ...templateData, defaultValue: fallbackBody, }); } console.log(`[_sendTelegram] Final message text to send:`, messageText); const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`; try { console.log( `[通知] 发送 Telegram 消息到聊天 ID ${config.chatId} (事件: ${payload.event})` ); const requestBody = { chat_id: config.chatId, text: messageText, parse_mode: "Markdown", }; console.log( `[_sendTelegram] Sending request to Telegram API:`, JSON.stringify(requestBody, null, 2) ); const response = await axios.post(telegramApiUrl, requestBody, { timeout: 10000, }); console.log(`[通知] Telegram 消息发送成功。响应 OK:`, response.data?.ok); } catch (error: any) { const errorMessage = error.response?.data?.description || error.response?.data || error.message; console.error( `[通知] 发送 Telegram 消息 (设置 ID: ${setting.id}) 时出错:`, errorMessage ); } } private _translatePayloadDetails(details: any, lng: string): any { if (!details || typeof details !== "object") { return details; } if (details.testResult === "success" && details.connectionName) { return { ...details, message: i18next.t("connection.testSuccess", { lng, name: details.connectionName, defaultValue: `Connection test successful for '${details.connectionName}'!`, }), }; } if ( details.testResult === "failed" && details.connectionName && details.error ) { return { ...details, message: i18next.t("connection.testFailed", { lng, name: details.connectionName, error: details.error, defaultValue: `Connection test failed for '${details.connectionName}': ${details.error}`, }), }; } if (details.updatedKeys && Array.isArray(details.updatedKeys)) { if (details.updatedKeys.includes("ipWhitelist")) { return { ...details, message: i18next.t("settings.ipWhitelistUpdated", { lng, defaultValue: "IP Whitelist updated successfully.", }), }; } return { ...details, message: i18next.t("settings.updated", { lng, defaultValue: "Settings updated successfully.", }), }; } return details; } }