/** * 通知服务 - 支持 Webhook、Telegram 和 Email 通知 * 后续可扩展支持其他通知方式(钉钉等) */ import { readFile } from 'fs/promises' import { existsSync } from 'fs' import db from './db.js' import TelegramBot from 'node-telegram-bot-api' import nodemailer from 'nodemailer' import { getImagePath } from './upload.js' // 通知类型枚举 export const NOTIFICATION_TYPES = { LOGIN: 'login', // 登录通知 UPLOAD: 'upload', // 图片上传通知 NSFW_DETECTED: 'nsfw', // 鉴黄检测结果通知 // 后续可扩展 // DELETE: 'delete', // 图片删除通知 // API_KEY: 'apikey', // API Key 操作通知 } // 通知方式枚举 export const NOTIFICATION_METHODS = { WEBHOOK: 'webhook', TELEGRAM: 'telegram', EMAIL: 'email', SERVERCHAN: 'serverchan', // 后续可扩展 // DINGTALK: 'dingtalk', } // 默认通知配置 export function getDefaultNotificationConfig() { return { enabled: false, method: NOTIFICATION_METHODS.TELEGRAM, // 各类型通知开关(默认全部开启) types: { [NOTIFICATION_TYPES.LOGIN]: true, [NOTIFICATION_TYPES.UPLOAD]: true, [NOTIFICATION_TYPES.NSFW_DETECTED]: true, }, // Webhook 配置 webhook: { url: '', method: 'POST', contentType: 'application/json', headers: {}, // 自定义请求头,如 { "Authorization": "Bearer xxx" } // 请求体模板,支持变量替换 // 可用变量: {{type}}, {{title}}, {{message}}, {{timestamp}}, {{data}} bodyTemplate: JSON.stringify({ type: '{{type}}', title: '{{title}}', message: '{{message}}', timestamp: '{{timestamp}}', data: '{{data}}' }, null, 2) }, // Telegram 配置 telegram: { token: '', // Bot Token chatId: '' // Chat ID }, // Email 配置 email: { service: '', // 邮件服务商,如 'gmail', 'qq', '163' 等 user: '', // 发件人邮箱 pass: '', // 邮箱授权码/密码 to: '' // 收件人邮箱(可选,默认发送给自己) }, // Server酱 配置 serverchan: { sendKey: '' // Server酱 SendKey } } } /** * 获取通知配置 */ export async function getNotificationConfig() { try { const configDoc = await db.settings.findOne({ key: 'notificationConfig' }) if (configDoc?.value) { // 合并默认配置,确保新增字段有默认值 const defaultConfig = getDefaultNotificationConfig() return { ...defaultConfig, ...configDoc.value, types: { ...defaultConfig.types, ...configDoc.value.types }, webhook: { ...defaultConfig.webhook, ...configDoc.value.webhook }, telegram: { ...defaultConfig.telegram, ...configDoc.value.telegram }, email: { ...defaultConfig.email, ...configDoc.value.email }, serverchan: { ...defaultConfig.serverchan, ...configDoc.value.serverchan } } } return getDefaultNotificationConfig() } catch (error) { console.error('[Notification] 获取配置失败:', error) return getDefaultNotificationConfig() } } /** * 保存通知配置 */ export async function saveNotificationConfig(config) { try { await db.settings.update( { key: 'notificationConfig' }, { $set: { value: config, updatedAt: new Date().toISOString() } }, { upsert: true } ) return { success: true } } catch (error) { console.error('[Notification] 保存配置失败:', error) return { success: false, error: error.message } } } /** * 替换模板变量 */ function replaceTemplateVariables(template, variables) { let result = template for (const [key, value] of Object.entries(variables)) { const placeholder = `{{${key}}}` // 如果值是对象,转换为 JSON 字符串 const replacement = typeof value === 'object' ? JSON.stringify(value) : String(value) result = result.split(placeholder).join(replacement) } return result } /** * 发送 Webhook 通知 */ async function sendWebhookNotification(config, payload) { const { webhook } = config if (!webhook.url) { console.warn('[Notification] Webhook URL 未配置') return { success: false, error: 'Webhook URL 未配置' } } try { // 准备模板变量 const variables = { type: payload.type, title: payload.title, message: payload.message, timestamp: payload.timestamp || new Date().toISOString(), data: payload.data || {} } // 替换请求体模板中的变量 let body = webhook.bodyTemplate body = replaceTemplateVariables(body, variables) // 构建请求头 const headers = { 'Content-Type': webhook.contentType || 'application/json', ...webhook.headers } // 发送请求 const response = await fetch(webhook.url, { method: webhook.method || 'POST', headers, body }) if (!response.ok) { const errorText = await response.text() console.error('[Notification] Webhook 请求失败:', response.status, errorText) return { success: false, error: `HTTP ${response.status}: ${errorText.substring(0, 200)}` } } console.log('[Notification] Webhook 通知发送成功:', payload.type) return { success: true } } catch (error) { console.error('[Notification] Webhook 发送失败:', error) return { success: false, error: error.message } } } /** * 检查URL是否为有效的完整URL(包含协议和主机) */ function isValidImageUrl(url) { if (!url || typeof url !== 'string') return false try { const parsed = new URL(url) return parsed.protocol === 'http:' || parsed.protocol === 'https:' } catch { return false } } /** * 从本地文件系统读取图片 * @param {string} filename - 图片文件名 * @returns {Promise} 图片Buffer,失败返回null */ async function readLocalImage(filename) { try { const filepath = getImagePath(filename) if (!existsSync(filepath)) { console.warn(`[Notification] 本地图片文件不存在: ${filepath}`) return null } const buffer = await readFile(filepath) return buffer } catch (error) { console.warn('[Notification] 读取本地图片出错:', error.message) return null } } /** * 根据content-type获取文件扩展名 */ function getExtensionFromContentType(contentType) { const map = { 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/bmp': 'bmp', 'image/svg+xml': 'svg' } return map[contentType] || 'jpg' } /** * 构建 Telegram 消息文本(用于 caption 或普通消息) */ function buildTelegramMessage(payload, includeImageUrl = false) { let message = `*${escapeMarkdown(payload.title)}*\n${escapeMarkdown(payload.message)}` // 如果有额外数据,添加到消息中 if (payload.data && Object.keys(payload.data).length > 0) { message += '\n\n*详细信息:*' for (const [key, value] of Object.entries(payload.data)) { // 跳过图片URL(除非明确要求包含) if (!includeImageUrl && (key === 'url' || key === 'imageUrl')) continue const displayValue = typeof value === 'object' ? JSON.stringify(value) : value message += `\n• ${key}: \`${escapeMarkdown(String(displayValue))}\`` } } return message } /** * 发送 Telegram 通知 */ async function sendTelegramNotification(config, payload) { const { telegram } = config if (!telegram.token || !telegram.chatId) { console.warn('[Notification] Telegram Token 或 Chat ID 未配置') return { success: false, error: 'Telegram Token 或 Chat ID 未配置' } } try { console.log('[Notification] Telegram 通知预发送:', payload.title) // 创建 Bot 实例,从环境变量读取 API 地址(用于反代) const botOptions = {} const apiUrl = process.env.TELEGRAM_API_URL if (apiUrl) { botOptions.baseApiUrl = apiUrl console.log('[Notification] 使用自定义 Telegram API 地址:', apiUrl) } const bot = new TelegramBot(telegram.token, botOptions) const chatId = Number(telegram.chatId) // 检查是否有有效的图片URL需要发送 const imageUrl = payload.data?.url || payload.data?.imageUrl const hasValidImageUrl = isValidImageUrl(imageUrl) // 优先尝试从本地读取图片文件 const localFilename = payload.data?.filename let imageBuffer = null if (localFilename) { console.log('[Notification] 尝试读取本地图片:', localFilename) imageBuffer = await readLocalImage(localFilename) } if (imageBuffer) { // 成功读取本地图片,使用Buffer发送 const caption = buildTelegramMessage(payload, false) const format = payload.data?.format || getExtensionFromContentType('image/jpeg') const contentType = `image/${format === 'jpg' ? 'jpeg' : format}` try { // 使用 sendPhoto API 发送图片(使用Buffer) await bot.sendPhoto(chatId, imageBuffer, { caption: caption, parse_mode: 'Markdown' }, { filename: localFilename, contentType: contentType }) console.log('[Notification] Telegram 图片通知发送成功:', payload.title) } catch (photoError) { // 如果 sendPhoto 失败,回退到普通消息 console.warn('[Notification] Telegram sendPhoto 失败,回退到普通消息:', photoError.message) let fallbackMessage = buildTelegramMessage(payload, true) if (hasValidImageUrl) { fallbackMessage += `\n\n🔗 [查看图片](${imageUrl})` } await bot.sendMessage(chatId, fallbackMessage, { parse_mode: 'Markdown', disable_web_page_preview: false }) console.log('[Notification] Telegram 回退消息发送成功:', payload.title) } } else if (hasValidImageUrl) { // 本地读取失败但有URL,发送带链接的文本消息 console.warn('[Notification] 本地图片读取失败,发送带链接的文本消息') let fallbackMessage = buildTelegramMessage(payload, true) fallbackMessage += `\n\n🔗 [查看图片](${imageUrl})` await bot.sendMessage(chatId, fallbackMessage, { parse_mode: 'Markdown', disable_web_page_preview: false }) } else { // 没有有效图片URL时,发送普通文本消息 const message = buildTelegramMessage(payload, true) await bot.sendMessage(chatId, message, { parse_mode: 'Markdown' }) } console.log('[Notification] Telegram 通知发送成功:', payload.title) return { success: true } } catch (error) { console.error('[Notification] Telegram 发送失败:', error) return { success: false, error: error.message } } } /** * 转义 Telegram Markdown 特殊字符 * 注意:Telegram 的 Markdown 模式只需要转义少量字符 * 参考:https://core.telegram.org/bots/api#markdown-style */ function escapeMarkdown(text) { if (!text) return '' // 在 Telegram Markdown 模式下,只需要转义 _ * ` [ 这几个字符 return String(text).replace(/([_*`\[])/g, '\\$1') } /** * 生成邮件 HTML 模板 */ function generateEmailTemplate(content) { return `
${content}
` } /** * 发送 Email 通知 */ async function sendEmailNotification(config, payload) { const { email } = config if (!email.service || !email.user || !email.pass) { console.warn('[Notification] Email 配置不完整') return { success: false, error: 'Email 配置不完整(需要 service、user、pass)' } } try { console.log('[Notification] 邮件通知预发送:', payload.title) // 创建邮件传输器 const transporter = nodemailer.createTransport({ service: email.service, auth: { user: email.user, pass: email.pass } }) // 检查是否有有效的图片URL const imageUrl = payload.data?.url || payload.data?.imageUrl const hasValidImageUrl = isValidImageUrl(imageUrl) // 构建邮件内容 let htmlContent = `

${payload.title}

${payload.message}

` // 如果有有效的图片URL,在邮件中显示图片 if (hasValidImageUrl) { htmlContent += `
上传的图片

点击查看原图

` } // 如果有额外数据,添加到邮件中 if (payload.data && Object.keys(payload.data).length > 0) { htmlContent += '

详细信息

' for (const [key, value] of Object.entries(payload.data)) { // 跳过图片URL,因为已经显示了图片 if (key === 'url' || key === 'imageUrl') continue const displayValue = typeof value === 'object' ? JSON.stringify(value) : value htmlContent += `
${key}: ${displayValue}
` } htmlContent += '
' } htmlContent += '
' // 收件人地址,默认发送给自己 const toAddress = email.to || email.user // 发送邮件 await transporter.sendMail({ from: email.user, to: toAddress, subject: `[EasyImg] ${payload.title}`, html: generateEmailTemplate(htmlContent) }) console.log('[Notification] 邮件通知发送成功:', payload.title) return { success: true } } catch (error) { console.error('[Notification] 邮件发送失败:', error) return { success: false, error: error.message } } } /** * 发送 Server酱 通知 */ async function sendServerChanNotification(config, payload) { const { serverchan } = config if (!serverchan.sendKey) { console.warn('[Notification] Server酱 SendKey 未配置') return { success: false, error: 'Server酱 SendKey 未配置' } } try { console.log('[Notification] Server酱通知预发送:', payload.title) const url = `https://sctapi.ftqq.com/${serverchan.sendKey}.send` // 检查是否有有效的图片URL const imageUrl = payload.data?.url || payload.data?.imageUrl const hasValidImageUrl = isValidImageUrl(imageUrl) // 构建消息内容(Markdown 格式) let content = payload.message // 如果有有效的图片URL,使用 Markdown 语法显示图片 if (hasValidImageUrl) { content += `\n\n![图片预览](${imageUrl})` } // 如果有额外数据,添加到消息中 if (payload.data && Object.keys(payload.data).length > 0) { content += '\n\n### 详细信息\n' for (const [key, value] of Object.entries(payload.data)) { // 跳过图片URL,因为已经显示了图片 if (key === 'url' || key === 'imageUrl') continue const displayValue = typeof value === 'object' ? JSON.stringify(value) : value content += `\n- **${key}**: ${displayValue}` } } // 发送请求 const params = new URLSearchParams({ text: payload.title, desp: content }) const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params }) const data = await response.json() if (data.code !== 0) { console.error('[Notification] Server酱发送失败:', data) return { success: false, error: data.message || 'Server酱发送失败' } } console.log('[Notification] Server酱通知发送成功:', payload.title) return { success: true } } catch (error) { console.error('[Notification] Server酱发送失败:', error) return { success: false, error: error.message } } } /** * 发送通知 * @param {string} type - 通知类型 * @param {object} payload - 通知内容 */ export async function sendNotification(type, payload) { try { const config = await getNotificationConfig() // 检查通知是否启用 if (!config.enabled) { return { success: true, skipped: true, reason: '通知功能未启用' } } // 检查该类型通知是否启用 if (!config.types[type]) { return { success: true, skipped: true, reason: `${type} 类型通知未启用` } } // 根据通知方式发送 switch (config.method) { case NOTIFICATION_METHODS.WEBHOOK: return await sendWebhookNotification(config, { type, ...payload, timestamp: new Date().toISOString() }) case NOTIFICATION_METHODS.TELEGRAM: return await sendTelegramNotification(config, { type, ...payload, timestamp: new Date().toISOString() }) case NOTIFICATION_METHODS.EMAIL: return await sendEmailNotification(config, { type, ...payload, timestamp: new Date().toISOString() }) case NOTIFICATION_METHODS.SERVERCHAN: return await sendServerChanNotification(config, { type, ...payload, timestamp: new Date().toISOString() }) default: return { success: false, error: `不支持的通知方式: ${config.method}` } } } catch (error) { console.error('[Notification] 发送通知失败:', error) return { success: false, error: error.message } } } /** * 发送登录通知 */ export async function sendLoginNotification(username, ip, userAgent) { return sendNotification(NOTIFICATION_TYPES.LOGIN, { title: '登录通知', message: `用户 ${username} 已登录`, data: { username, ip, userAgent, loginTime: new Date().toISOString() } }) } /** * 发送上传通知 */ export async function sendUploadNotification(imageInfo, uploaderInfo) { return sendNotification(NOTIFICATION_TYPES.UPLOAD, { title: '图片上传通知', message: `新图片已上传: ${imageInfo.filename}`, data: { imageId: imageInfo.id, filename: imageInfo.filename, format: imageInfo.format, size: imageInfo.size, url: imageInfo.url, uploader: uploaderInfo.name, uploaderType: uploaderInfo.type, ip: uploaderInfo.ip, uploadTime: new Date().toISOString() } }) } /** * 发送鉴黄检测结果通知 */ export async function sendNsfwNotification(imageInfo, moderationResult) { const isNsfw = moderationResult.isNsfw return sendNotification(NOTIFICATION_TYPES.NSFW_DETECTED, { title: isNsfw ? '⚠️ 检测到违规图片' : '图片审核通过', message: isNsfw ? `图片 ${imageInfo.filename} 被检测为违规内容` : `图片 ${imageInfo.filename} 审核通过`, data: { imageId: imageInfo.id, filename: imageInfo.filename, url: imageInfo.url, isNsfw, score: moderationResult.score, provider: moderationResult.provider, checkTime: new Date().toISOString() } }) } /** * 测试 Webhook 连接 */ export async function testWebhook(webhookConfig) { try { const testPayload = { type: 'test', title: '测试通知', message: '这是一条测试通知,用于验证 Webhook 配置是否正确。', timestamp: new Date().toISOString(), data: { test: true } } // 替换模板变量 let body = webhookConfig.bodyTemplate body = replaceTemplateVariables(body, testPayload) const headers = { 'Content-Type': webhookConfig.contentType || 'application/json', ...webhookConfig.headers } const response = await fetch(webhookConfig.url, { method: webhookConfig.method || 'POST', headers, body }) if (!response.ok) { const errorText = await response.text() return { success: false, error: `HTTP ${response.status}: ${errorText.substring(0, 200)}` } } return { success: true, message: '测试通知发送成功' } } catch (error) { return { success: false, error: error.message } } } /** * 测试 Telegram 连接 */ export async function testTelegram(telegramConfig) { try { if (!telegramConfig.token || !telegramConfig.chatId) { return { success: false, error: '请提供 Token 和 Chat ID' } } console.log('[Notification] Telegram 测试通知预发送') const message = `*测试通知*\n这是一条测试通知,用于验证 Telegram 配置是否正确。\n\n_发送时间: ${new Date().toISOString()}_` // 创建 Bot 实例,从环境变量读取 API 地址(用于反代) const botOptions = {} const apiUrl = process.env.TELEGRAM_API_URL if (apiUrl) { botOptions.baseApiUrl = apiUrl console.log('[Notification] 使用自定义 Telegram API 地址:', apiUrl) } const bot = new TelegramBot(telegramConfig.token, botOptions) await bot.sendMessage(Number(telegramConfig.chatId), message, { parse_mode: 'Markdown' }) console.log('[Notification] Telegram 测试通知发送成功') return { success: true, message: '测试通知发送成功' } } catch (error) { console.error('[Notification] Telegram 测试失败:', error) return { success: false, error: error.message } } } /** * 测试 Email 连接 */ export async function testEmail(emailConfig) { try { if (!emailConfig.service || !emailConfig.user || !emailConfig.pass) { return { success: false, error: '请提供完整的邮件配置(service、user、pass)' } } console.log('[Notification] Email 测试通知预发送') // 创建邮件传输器 const transporter = nodemailer.createTransport({ service: emailConfig.service, auth: { user: emailConfig.user, pass: emailConfig.pass } }) // 收件人地址,默认发送给自己 const toAddress = emailConfig.to || emailConfig.user const htmlContent = `

测试通知

这是一条测试通知,用于验证邮件配置是否正确。

发送时间: ${new Date().toISOString()}
` await transporter.sendMail({ from: emailConfig.user, to: toAddress, subject: '[EasyImg] 测试通知', html: generateEmailTemplate(htmlContent) }) console.log('[Notification] Email 测试通知发送成功') return { success: true, message: '测试通知发送成功' } } catch (error) { console.error('[Notification] Email 测试失败:', error) return { success: false, error: error.message } } } /** * 测试 Server酱 连接 */ export async function testServerChan(serverchanConfig) { try { if (!serverchanConfig.sendKey) { return { success: false, error: '请提供 Server酱 SendKey' } } console.log('[Notification] Server酱测试通知预发送') const url = `https://sctapi.ftqq.com/${serverchanConfig.sendKey}.send` const content = `这是一条测试通知,用于验证 Server酱 配置是否正确。\n\n发送时间: ${new Date().toISOString()}` const params = new URLSearchParams({ text: '测试通知', desp: content }) const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params }) const data = await response.json() if (data.code !== 0) { console.error('[Notification] Server酱测试失败:', data) return { success: false, error: data.message || 'Server酱发送失败' } } console.log('[Notification] Server酱测试通知发送成功') return { success: true, message: '测试通知发送成功' } } catch (error) { console.error('[Notification] Server酱测试失败:', error) return { success: false, error: error.message } } } export default { NOTIFICATION_TYPES, NOTIFICATION_METHODS, getDefaultNotificationConfig, getNotificationConfig, saveNotificationConfig, sendNotification, sendLoginNotification, sendUploadNotification, sendNsfwNotification, testWebhook, testTelegram, testEmail, testServerChan }