feat: v0.1.7 消息推送模块 - 多通道通知(飞书/Server酱/Bark/Telegram/Webhook)+ 转存/清理/Cookie推送事件 + 推送配置页面
This commit is contained in:
@@ -1,87 +1,248 @@
|
||||
// Native fetch available in Node 20+
|
||||
// ============================================================
|
||||
// notification.service.ts — Multi-channel message notification
|
||||
// Channels: Feishu Webhook / Server酱 / Bark / Custom Webhook / Telegram
|
||||
// ============================================================
|
||||
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
|
||||
type NotifyLevel = 'info' | 'warn' | 'error';
|
||||
export type NotifyLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
interface NotifyChannel {
|
||||
export interface NotifyChannel {
|
||||
send(title: string, content: string, level: NotifyLevel): Promise<void>;
|
||||
}
|
||||
|
||||
// ---- Feishu Webhook Channel ----
|
||||
// ======================== Channel Implementations ========================
|
||||
|
||||
/** Feishu/Lark webhook — interactive card message */
|
||||
class FeishuChannel implements NotifyChannel {
|
||||
private webhookUrl: string;
|
||||
|
||||
constructor(webhookUrl: string) {
|
||||
this.webhookUrl = webhookUrl;
|
||||
}
|
||||
constructor(webhookUrl: string) { this.webhookUrl = webhookUrl; }
|
||||
|
||||
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
|
||||
try {
|
||||
const 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' })}` },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const resp = await fetch(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
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}`);
|
||||
}
|
||||
if (!resp.ok) console.error(`[Notify] Feishu send failed: ${resp.status}`);
|
||||
} catch (err: any) {
|
||||
console.error('[Notify] Feishu send error:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Notification Manager ----
|
||||
let _channel: NotifyChannel | null = null;
|
||||
/** Server酱 — push to WeChat via https://sct.ftqq.com */
|
||||
class ServerChanChannel implements NotifyChannel {
|
||||
private sendKey: string;
|
||||
constructor(sendKey: string) { this.sendKey = sendKey; }
|
||||
|
||||
function getChannel(): NotifyChannel | null {
|
||||
const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url');
|
||||
if (!feishuUrl) return null;
|
||||
|
||||
if (!_channel) {
|
||||
_channel = new FeishuChannel(feishuUrl);
|
||||
console.log('[Notify] Feishu webhook configured');
|
||||
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);
|
||||
}
|
||||
}
|
||||
return _channel;
|
||||
}
|
||||
|
||||
/** 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}`);
|
||||
}
|
||||
} 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 {
|
||||
try {
|
||||
const val = getSystemConfig(`notify_on_${eventName}`);
|
||||
return val !== 'false'; // default to true
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== Public API ========================
|
||||
|
||||
/**
|
||||
* Send a notification through configured channels.
|
||||
* Returns immediately — failures are logged silently.
|
||||
* 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 ch = getChannel();
|
||||
if (!ch) return;
|
||||
// Fire-and-forget — don't block the caller
|
||||
ch.send(title, content, level).catch(() => {});
|
||||
const channels = getChannels();
|
||||
if (channels.length === 0) return;
|
||||
for (const ch of channels) {
|
||||
ch.send(title, content, level).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify on critical events:
|
||||
* - Cookie expired / login failed
|
||||
* - Save/transfer failed repeatedly
|
||||
* - Storage below threshold
|
||||
*/
|
||||
/** Notify on critical errors (Cookie expired, save failure, etc.) */
|
||||
export function notifyError(title: string, detail: string): void {
|
||||
notify(`⚠️ ${title}`, detail, 'error');
|
||||
}
|
||||
@@ -93,3 +254,12 @@ export function notifyWarn(title: string, detail: string): void {
|
||||
export function notifyInfo(title: string, detail: string): void {
|
||||
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;
|
||||
notify(title, content, level);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user