v0.3.5: restore full push notification system (14 channels)
Restored from v0.2.4: - notifiers/: 14 push channels (bark/serverchan/telegram/lark/webhook/wechat/discord/smtp/...) - push-user.service.ts: multi-user push config linked to cloud_configs.promotion_account - notification.service.ts: full dispatcher with per-config + global fallback New integrations: - cloud.service.ts: notifyConfigEvent on save_success/cookie_expire/save_fail - admin.routes.ts: 7 new API endpoints for push users, notify providers, channel test - database.ts: migration for cloud_configs.notify_config column How it works: - Configure push channels in /admin/system-configs (global_notify_config JSON) - Or per-cloud: link push_users.account = cloud_configs.promotion_account - Notify events: save_success (green), cookie_expire (red), save_fail >=3 consecutive (yellow)
This commit is contained in:
@@ -1,95 +1,217 @@
|
||||
// Native fetch available in Node 20+
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
import { getAllNotifiers, getNotifier, getAllNotifierParams, notifyWith } from './notifiers';
|
||||
import { findPushUserForConfig, getPushUserByAccount } from './push-user.service';
|
||||
import { getDb } from '../database/database';
|
||||
|
||||
type NotifyLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
interface NotifyChannel {
|
||||
send(title: string, content: string, level: NotifyLevel): Promise<void>;
|
||||
name: string;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
// ---- Feishu Webhook Channel ----
|
||||
class FeishuChannel implements NotifyChannel {
|
||||
private webhookUrl: string;
|
||||
// Re-export for API routes
|
||||
export { getAllNotifiers, getNotifier, getAllNotifierParams };
|
||||
|
||||
constructor(webhookUrl: string) {
|
||||
this.webhookUrl = webhookUrl;
|
||||
}
|
||||
// ======================== Global channel management ========================
|
||||
|
||||
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' })}` },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
let _globalChannelsCache: NotifyChannel[] | null = null;
|
||||
let _configHash = '';
|
||||
|
||||
const resp = await fetch(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
console.error(`[Notify] Feishu send failed: ${resp.status}`);
|
||||
function getGlobalNotifyConfigs(): NotifyChannel[] {
|
||||
const raw = getSystemConfig('global_notify_config') || '{}';
|
||||
let globalConfig: any = {};
|
||||
try { globalConfig = JSON.parse(raw); } catch {}
|
||||
const channels: NotifyChannel[] = [];
|
||||
if (globalConfig.channels) {
|
||||
for (const [name, params] of Object.entries(globalConfig.channels)) {
|
||||
if (params && typeof params === 'object') {
|
||||
channels.push({ name, params: { ...(params as any), title: 'CloudSearch' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
function checkEventEnabled(eventName: string): boolean {
|
||||
try {
|
||||
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' && val !== '0';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== Core send ========================
|
||||
|
||||
async function sendToChannels(channels: NotifyChannel[], title: string, content: string, level: NotifyLevel): Promise<void> {
|
||||
for (const ch of channels) {
|
||||
try {
|
||||
await notifyWith(ch.name, {
|
||||
...ch.params,
|
||||
title,
|
||||
content,
|
||||
level,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[Notify] Feishu send error:', err.message);
|
||||
console.error(`[Notify] ${ch.name} error:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Notification Manager ----
|
||||
let _channel: NotifyChannel | null = null;
|
||||
// ======================== Export API ========================
|
||||
|
||||
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');
|
||||
}
|
||||
return _channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification through configured channels.
|
||||
* Returns immediately — 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 = getGlobalNotifyConfigs();
|
||||
if (channels.length === 0) return;
|
||||
sendToChannels(channels, title, content, level).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify on critical events:
|
||||
* - Cookie expired / login failed
|
||||
* - Save/transfer failed repeatedly
|
||||
* - Storage below threshold
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
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 notifyEvent(eventName: string, title: string, content: string, level: NotifyLevel = 'info', templateVars?: Record<string, string>): void {
|
||||
if (!checkEventEnabled(eventName)) return;
|
||||
const eventKey = 'on_' + eventName;
|
||||
const templates = getEventTemplates();
|
||||
const tmpl = templates[eventKey];
|
||||
if (tmpl && tmpl.content) {
|
||||
const vars = { ...(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);
|
||||
}
|
||||
|
||||
export function notifyConfigEvent(
|
||||
configId: number,
|
||||
eventName: string,
|
||||
title: string,
|
||||
content: string,
|
||||
level: NotifyLevel = '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 = { ...(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);
|
||||
}
|
||||
|
||||
// Find matching push user by cloud_configs.promotion_account
|
||||
const pushUser = findPushUserForConfig(configId);
|
||||
if (!pushUser) {
|
||||
notifyEvent(eventName, title, content, level);
|
||||
return;
|
||||
}
|
||||
|
||||
let notifyConfig: any = {};
|
||||
try { notifyConfig = JSON.parse(pushUser.notify_config); } catch {}
|
||||
|
||||
if (notifyConfig.events && notifyConfig.events[eventKey] === false) return;
|
||||
|
||||
const userChannels: NotifyChannel[] = [];
|
||||
if (notifyConfig.channels) {
|
||||
for (const [name, params] of Object.entries(notifyConfig.channels)) {
|
||||
userChannels.push({ name, params: { ...(params as any), title } });
|
||||
}
|
||||
}
|
||||
|
||||
if (userChannels.length > 0) {
|
||||
sendToChannels(userChannels, title, content, level).catch(() => {});
|
||||
} else {
|
||||
notifyEvent(eventName, title, content, level);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testChannel(
|
||||
channelName: string,
|
||||
account?: string,
|
||||
directParams?: Record<string, any>
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
let params: Record<string, any> = {};
|
||||
|
||||
if (directParams) {
|
||||
params = directParams;
|
||||
} else if (account) {
|
||||
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: 'User has not configured this channel' };
|
||||
params = chParams;
|
||||
} else {
|
||||
return { success: false, message: 'Push user not found' };
|
||||
}
|
||||
} else {
|
||||
const channels = getGlobalNotifyConfigs();
|
||||
const ch = channels.find(c => c.name === channelName);
|
||||
if (!ch) return { success: false, message: 'Channel not configured globally' };
|
||||
params = ch.params;
|
||||
}
|
||||
|
||||
const result = await notifyWith(channelName, {
|
||||
...params,
|
||||
title: '\u{1F514} CloudSearch Test',
|
||||
content: `Test message\\nChannel: ${channelName}\\nTime: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
|
||||
level: 'info',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function saveConfigNotifySettings(configId: number, notify: any): void {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigNotifySettings(configId: number): any {
|
||||
try {
|
||||
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 {};
|
||||
}
|
||||
|
||||
export function getConfigNotifySettingsJSON(configId: number): any {
|
||||
return getConfigNotifySettings(configId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user