0.2.2: 消息推送插件化重构 + 14通道 + 多用户推送 + 全局推送 + 事件模板自定义 + 每日报告 + 变量说明

This commit is contained in:
2026-05-16 17:25:29 +08:00
parent e38adee8ff
commit ab52ca2e69
30 changed files with 1638 additions and 326 deletions

View File

@@ -1,265 +1,269 @@
// ============================================================
// notification.service.ts — Multi-channel message notification
// Channels: Feishu Webhook / Server酱 / Bark / Custom Webhook / Telegram
// notification.service.ts — 插件化消息推送
// 基于 notifiers/ 注册器,支持 14+ 个推送通道
// 支持:全局推送(系统配置)和按网盘配置推送
// ============================================================
import { getSystemConfig } from '../admin/system-config.service';
import { findPushUserForConfig } from './push-user.service';
import { getAllNotifiers, getNotifier, notifyWith } from './notifiers';
export type NotifyLevel = 'info' | 'warn' | 'error';
export { getAllNotifiers, getNotifier, getAllNotifierParams } from './notifiers';
export type { NotifyLevel, Notifier, NotifierParam, NotifyParams, NotifyResult } from './notifiers/notifier.types';
export interface NotifyChannel {
send(title: string, content: string, level: NotifyLevel): Promise<void>;
/** 用户级推送配置(存储在 cloud_configs.notify_config */
export interface PerConfigNotify {
// 每个通道的配置 = { channelName: { paramKey: value, ... } }
channels?: Record<string, Record<string, string>>;
events?: {
on_save_success?: boolean;
on_save_fail?: boolean;
on_cookie_expire?: boolean;
on_cleanup?: boolean;
};
}
// ======================== Channel Implementations ========================
// ======================== 全局(系统级)通道管理 ========================
/** Feishu/Lark webhook — interactive card message */
class FeishuChannel implements NotifyChannel {
private webhookUrl: string;
constructor(webhookUrl: string) { this.webhookUrl = webhookUrl; }
let _globalChannelsCache: { name: string; params: Record<string, string> }[] | null = null;
let _configHash: string = '';
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
try {
const resp = await fetch(this.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
msg_type: 'interactive',
card: {
header: {
title: { tag: 'plain_text', content: title },
template: _level === 'error' ? 'red' : _level === 'warn' ? 'orange' : 'blue',
},
elements: [
{ tag: 'div', text: { tag: 'lark_md', content } },
{
tag: 'note',
elements: [
{ tag: 'plain_text', content: `CloudSearch · ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` },
],
},
],
},
}),
});
if (!resp.ok) console.error(`[Notify] Feishu send failed: ${resp.status}`);
} catch (err: any) {
console.error('[Notify] Feishu send error:', err.message);
}
}
}
/** Server酱 — push to WeChat via https://sct.ftqq.com */
class ServerChanChannel implements NotifyChannel {
private sendKey: string;
constructor(sendKey: string) { this.sendKey = sendKey; }
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
try {
const resp = await fetch(`https://sctapi.ftqq.com/${this.sendKey}.send`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ title, desp: content }).toString(),
});
if (!resp.ok) console.error(`[Notify] ServerChan send failed: ${resp.status}`);
} catch (err: any) {
console.error('[Notify] ServerChan send error:', err.message);
}
}
}
/** Bark — push to iOS devices via https://api.day.app */
class BarkChannel implements NotifyChannel {
private key: string;
private server: string;
constructor(key: string, server: string = 'https://api.day.app') {
this.key = key;
this.server = server.replace(/\/+$/, '');
}
async send(title: string, content: string, level: NotifyLevel): Promise<void> {
try {
const iconMap: Record<NotifyLevel, string> = {
error: '⚠️', warn: '🔔', info: '',
};
const body = JSON.stringify({
title: `${iconMap[level]} ${title}`,
body: content,
group: 'CloudSearch',
level: level === 'error' ? 'timeSensitive' : 'active',
icon: level === 'error' ? 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/26a0.png' : undefined,
});
const resp = await fetch(`${this.server}/${this.key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
if (!resp.ok) console.error(`[Notify] Bark send failed: ${resp.status}`);
} catch (err: any) {
console.error('[Notify] Bark send error:', err.message);
}
}
}
/** Custom Webhook — generic HTTP POST */
class WebhookChannel implements NotifyChannel {
private url: string;
constructor(url: string) { this.url = url; }
async send(title: string, content: string, level: NotifyLevel): Promise<void> {
try {
const resp = await fetch(this.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, level, source: 'CloudSearch', timestamp: new Date().toISOString() }),
});
if (!resp.ok) console.error(`[Notify] Webhook send failed: ${resp.status}`);
} catch (err: any) {
console.error('[Notify] Webhook send error:', err.message);
}
}
}
/** Telegram Bot — send via Bot API */
class TelegramChannel implements NotifyChannel {
private botToken: string;
private chatId: string;
constructor(botToken: string, chatId: string) {
this.botToken = botToken;
this.chatId = chatId;
}
async send(title: string, content: string, level: NotifyLevel): Promise<void> {
try {
const iconMap: Record<NotifyLevel, string> = { error: '🚨', warn: '⚠️', info: '' };
const text = `${iconMap[level]} *${title}*\n\n${content}`;
const resp = await fetch(`https://api.telegram.org/bot${this.botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: this.chatId,
text,
parse_mode: 'Markdown',
disable_web_page_preview: true,
}),
});
if (!resp.ok) {
const err = await resp.text();
console.error(`[Notify] Telegram send failed: ${resp.status}${err}`);
function getGlobalNotifyConfigs(): { name: string; params: Record<string, string> }[] {
// 从 global_notify_config JSON 读取(格式同 push user 的 notify_config
const raw = getSystemConfig('global_notify_config') || '{}';
let globalConfig: any = {};
try { globalConfig = JSON.parse(raw); } catch {}
const channels: { name: string; params: Record<string, string> }[] = [];
if (globalConfig.channels) {
for (const [name, params] of Object.entries(globalConfig.channels)) {
if (params && typeof params === 'object') {
channels.push({ name, params: { ...(params as Record<string, string>), title: 'CloudSearch' } });
}
} catch (err: any) {
console.error('[Notify] Telegram send error:', err.message);
}
}
}
// ======================== Notification Manager ========================
interface ChannelConfig {
id: string;
create: () => NotifyChannel | null;
}
let _channels: NotifyChannel[] | null = null;
let _channelConfigs: ChannelConfig[] | null = null;
let _lastConfigHash: string = '';
let _debugLogged: boolean = false;
function buildConfigHash(): string {
const keys = ['feishu_webhook_url', 'serverchan_key', 'bark_key', 'bark_server', 'webhook_url', 'telegram_bot_token', 'telegram_chat_id'];
let hash = '';
for (const key of keys) {
try { hash += String(getSystemConfig(key) || ''); } catch { hash += ''; }
}
return hash;
}
function buildChannels(): NotifyChannel[] {
const channels: NotifyChannel[] = [];
// 1. Feishu
const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url') || '';
if (feishuUrl) channels.push(new FeishuChannel(feishuUrl));
// 2. Server酱
const serverchanKey = getSystemConfig('serverchan_key') || '';
if (serverchanKey) channels.push(new ServerChanChannel(serverchanKey));
// 3. Bark
const barkKey = getSystemConfig('bark_key') || '';
if (barkKey) {
const barkServer = getSystemConfig('bark_server') || 'https://api.day.app';
channels.push(new BarkChannel(barkKey, barkServer));
}
// 4. Custom Webhook
const webhookUrl = getSystemConfig('webhook_url') || '';
if (webhookUrl) channels.push(new WebhookChannel(webhookUrl));
// 5. Telegram
const tgToken = getSystemConfig('telegram_bot_token') || '';
const tgChatId = getSystemConfig('telegram_chat_id') || '';
if (tgToken && tgChatId) channels.push(new TelegramChannel(tgToken, tgChatId));
if (!_debugLogged && channels.length > 0) {
_debugLogged = true;
console.log(`[Notify] ${channels.length} channel(s) configured: ${channels.map(c => c.constructor.name.replace('Channel','')).join(', ')}`);
}
return channels;
}
function getChannels(): NotifyChannel[] {
const hash = buildConfigHash();
if (hash !== _lastConfigHash) {
_channels = buildChannels();
_lastConfigHash = hash;
}
return _channels || [];
}
// ======================== Event Trigger Checks ========================
function shouldNotify(eventName: string): boolean {
function checkEventEnabled(eventName: string): boolean {
try {
// 优先读 global_notify_config.events
const raw = getSystemConfig('global_notify_config') || '{}';
let globalConfig: any = {};
try { globalConfig = JSON.parse(raw); } catch {}
if (globalConfig.events && globalConfig.events[`on_${eventName}`] !== undefined) {
return globalConfig.events[`on_${eventName}`] !== false;
}
// 降级到旧字段
const val = getSystemConfig(`notify_on_${eventName}`);
return val !== 'false'; // default to true
return val !== 'false' && val !== '0';
} catch {
return true;
}
}
// ======================== Public API ========================
// ======================== 核心发送函数 ========================
/**
* Send a notification through all configured channels.
* Fire-and-forget — failures are logged silently.
*/
export function notify(title: string, content: string, level: NotifyLevel = 'info'): void {
const channels = getChannels();
if (channels.length === 0) return;
async function sendToChannels(
channels: { name: string; params: Record<string, string> }[],
title: string,
content: string,
level: string
): Promise<void> {
for (const ch of channels) {
ch.send(title, content, level).catch(() => {});
try {
await notifyWith(ch.name, {
...ch.params,
title,
content,
level,
});
} catch (err: any) {
console.error(`[Notify] ${ch.name} error:`, err.message);
}
}
}
/** Notify on critical errors (Cookie expired, save failure, etc.) */
// ======================== 导出 API ========================
/** 通过全局通道发送通知(不检查事件开关) */
export function notify(title: string, content: string, level: 'info' | 'warn' | 'error' = 'info'): void {
const channels = getGlobalNotifyConfigs();
if (channels.length === 0) return;
sendToChannels(channels, title, content, level).catch(() => {});
}
export function notifyError(title: string, detail: string): void {
notify(`⚠️ ${title}`, detail, 'error');
notify(title, detail, 'error');
}
export function notifyWarn(title: string, detail: string): void {
notify(`🔔 ${title}`, detail, 'warn');
notify(title, detail, 'warn');
}
export function notifyInfo(title: string, detail: string): void {
notify(` ${title}`, detail, 'info');
notify(title, detail, 'info');
}
/**
* Event-aware notification: checks if this event type is enabled,
* then sends the notification.
*/
export function notifyEvent(eventName: string, title: string, content: string, level: NotifyLevel = 'info'): void {
if (!shouldNotify(eventName)) return;
/** 事件通知(检查全局事件开关) */
export function notifyEvent(
eventName: string,
title: string,
content: string,
level: 'info' | 'warn' | 'error' = 'info',
templateVars?: Record<string, string>
): void {
if (!checkEventEnabled(eventName)) return;
// Apply global template if available
const eventKey = 'on_' + eventName;
const templates = getEventTemplates();
const tmpl = templates[eventKey];
if (tmpl && tmpl.content) {
const vars: Record<string, string> = { ...(templateVars || {}) };
if (!vars.content && content) vars.content = content;
if (!vars.title && title) vars.title = title;
title = applyTemplate(tmpl.title || title, vars);
content = applyTemplate(tmpl.content, vars);
}
notify(title, content, level);
}
/** 按网盘配置发送通知(检查用户级推送配置,无配置则降级到全局) */
const DB_PATH = '/app/dist/database/database';
function getConfigNotifySettings(configId: number): PerConfigNotify {
try {
const { getDb } = require(DB_PATH);
const db = getDb();
const row = db.prepare('SELECT notify_config FROM cloud_configs WHERE id = ?').get(configId) as any;
if (row && row.notify_config) {
return JSON.parse(row.notify_config);
}
} catch {}
return {};
}
function applyTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{([^}]+)\}/g, (_, key) => vars[key] || '{' + key + '}');
}
function getEventTemplates(): Record<string, { title: string; content: string }> {
try {
const raw = getSystemConfig('global_notify_config') || '{}';
const cfg = JSON.parse(raw);
return cfg.eventTemplates || {};
} catch { return {}; }
}
export function notifyConfigEvent(
configId: number,
eventName: string,
title: string,
content: string,
level: 'info' | 'warn' | 'error' = 'info',
templateVars?: Record<string, string>
): void {
// Apply global template if available
const eventKey = 'on_' + eventName;
const templates = getEventTemplates();
const tmpl = templates[eventKey];
if (tmpl && tmpl.content) {
const vars: Record<string, string> = { ...(templateVars || {}) };
if (!vars.content && content) vars.content = content;
if (!vars.title && title) vars.title = title;
const appliedTitle = applyTemplate(tmpl.title || title, vars);
const appliedContent = applyTemplate(tmpl.content, vars);
title = appliedTitle;
content = appliedContent;
}
// Find matching push user by cloud_configs.promotion_account
const pushUser = findPushUserForConfig(configId);
if (!pushUser) {
// No matching push user, fallback to global
notifyEvent(eventName, title, content, level);
return;
}
let notifyConfig: any = {};
try { notifyConfig = JSON.parse(pushUser.notify_config); } catch {}
// Check event switch
if (notifyConfig.events && notifyConfig.events[eventKey] === false) return;
// Build channels from push user config
const userChannels: { name: string; params: Record<string, string> }[] = [];
if (notifyConfig.channels) {
for (const [name, params] of Object.entries(notifyConfig.channels)) {
userChannels.push({ name, params: { ...(params as Record<string, string>), title } });
}
}
if (userChannels.length > 0) {
sendToChannels(userChannels, title, content, level).catch(() => {});
} else {
// Fallback to global
notifyEvent(eventName, title, content, level);
}
}
/** 测试某个通道 */
export async function testChannel(
channelName: string,
account?: string,
directParams?: Record<string, string>
): Promise<{ success: boolean; message: string }> {
let params: Record<string, string> = {};
// If direct params provided, use them directly (for global panel test without saving first)
if (directParams) {
params = directParams;
} else if (account) {
const pushUser = findPushUserForConfig(undefined);
// Use pushUser lookup by account instead
const { getPushUserByAccount } = require('./push-user.service');
const user = getPushUserByAccount(account);
if (user) {
let notifyConfig: any = {};
try { notifyConfig = JSON.parse(user.notify_config); } catch {}
const chParams = notifyConfig.channels?.[channelName];
if (!chParams) return { success: false, message: '该用户未配置此渠道' };
params = chParams;
} else {
return { success: false, message: '未找到该推送用户' };
}
} else {
const channels = getGlobalNotifyConfigs();
const ch = channels.find(c => c.name === channelName);
if (!ch) return { success: false, message: '全局未配置此渠道' };
params = ch.params;
}
const result = await notifyWith(channelName, {
...params,
title: '\ud83d\udd14 CloudSearch \u6d4b\u8bd5',
content: `\u8fd9\u662f\u4e00\u6761\u6d4b\u8bd5\u6d88\u606f\n\u901a\u9053: ${channelName}\n\u65f6\u95f4: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
level: 'info',
});
return result;
}
/** 保存用户级推送配置到数据库 */
export function saveConfigNotifySettings(configId: number, notify: PerConfigNotify): void {
try {
const { getDb } = require(DB_PATH);
const db = getDb();
db.prepare('UPDATE cloud_configs SET notify_config = ? WHERE id = ?').run(
JSON.stringify(notify),
configId
);
} catch (err: any) {
console.error('[Notify] Failed to save config notify settings:', err.message);
}
}
/** 获取用户级推送配置 */
export function getConfigNotifySettingsJSON(configId: number): PerConfigNotify {
return getConfigNotifySettings(configId);
}