From 64b00661a2ceffedfa002e4c084d1a5c13520237 Mon Sep 17 00:00:00 2001 From: admin <362324317@qq.com> Date: Sun, 17 May 2026 05:15:26 +0800 Subject: [PATCH] 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) --- VERSION | 2 +- source_clean/src/cloud/cloud.service.ts | 32 +++ .../src/cloud/notification.service.ts | 256 +++++++++++++----- .../src/cloud/notifiers/bark.notifier.ts | 44 +++ .../src/cloud/notifiers/dingtalk.notifier.ts | 36 +++ .../src/cloud/notifiers/discord.notifier.ts | 38 +++ .../src/cloud/notifiers/gotify.notifier.ts | 35 +++ source_clean/src/cloud/notifiers/index.ts | 63 +++++ .../src/cloud/notifiers/lark.notifier.ts | 39 +++ .../src/cloud/notifiers/notifier.types.ts | 28 ++ .../src/cloud/notifiers/ntfy.notifier.ts | 36 +++ .../src/cloud/notifiers/pushplus.notifier.ts | 33 +++ .../src/cloud/notifiers/qmsg.notifier.ts | 36 +++ .../cloud/notifiers/serverchan.notifier.ts | 30 ++ .../notifiers/serverchanturbo.notifier.ts | 29 ++ .../src/cloud/notifiers/smtp.notifier.ts | 40 +++ .../src/cloud/notifiers/telegram.notifier.ts | 39 +++ .../src/cloud/notifiers/webhook.notifier.ts | 33 +++ .../notifiers/wechat_work_bot.notifier.ts | 34 +++ source_clean/src/cloud/push-user.service.ts | 59 ++++ source_clean/src/database/database.ts | 7 + source_clean/src/routes/admin.routes.ts | 104 +++++++ 22 files changed, 985 insertions(+), 68 deletions(-) create mode 100644 source_clean/src/cloud/notifiers/bark.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/dingtalk.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/discord.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/gotify.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/index.ts create mode 100644 source_clean/src/cloud/notifiers/lark.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/notifier.types.ts create mode 100644 source_clean/src/cloud/notifiers/ntfy.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/pushplus.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/qmsg.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/serverchan.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/serverchanturbo.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/smtp.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/telegram.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/webhook.notifier.ts create mode 100644 source_clean/src/cloud/notifiers/wechat_work_bot.notifier.ts create mode 100644 source_clean/src/cloud/push-user.service.ts diff --git a/VERSION b/VERSION index 42045ac..c2c0004 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.4 +0.3.5 diff --git a/source_clean/src/cloud/cloud.service.ts b/source_clean/src/cloud/cloud.service.ts index 5aa9655..9e02068 100644 --- a/source_clean/src/cloud/cloud.service.ts +++ b/source_clean/src/cloud/cloud.service.ts @@ -5,6 +5,7 @@ import { QuarkDriver } from './drivers/quark.driver'; import { BaiduDriver } from './drivers/baidu.driver'; import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service'; import { lookupIpLocation } from './ip-lookup'; +import { notifyConfigEvent } from './notification.service'; /** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */ const inFlightSaves = new Map>(); @@ -174,12 +175,43 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle? db.prepare( `UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?` ).run(config.id); + const nickname = config.nickname || cloudType; + notifyConfigEvent(config.id, 'save_success', `✅ 转存成功`, `**${cloudType}** · ${nickname} +文件: ${driverResult.folderName || sourceTitle || shareUrl} +耗时: ${((Date.now() - startTime) / 1000).toFixed(1)}s`, 'info', { + file_name: driverResult.folderName || sourceTitle || shareUrl || '', + file_size: '', + cloud_type: cloudType, + nickname: nickname || '', + duration: ((Date.now() - startTime) / 1000).toFixed(1), + share_url: shareUrl, + }); } else if ((driverResult as any).cookieExpired) { // Cookie expired — don't count as failure, user needs to re-login + notifyConfigEvent(config.id, 'cookie_expire', `⚠️ Cookie过期`, `**${cloudType}** · ${config.nickname || '未知'} +链接: ${shareUrl} +请重新登录`, 'error', { + cloud_type: cloudType, + nickname: config.nickname || '', + share_url: shareUrl, + }); } else { db.prepare( `UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?` ).run(config.id); + const failCount = (db.prepare(`SELECT consecutive_failures FROM cloud_configs WHERE id = ?`).get(config.id) as any)?.consecutive_failures || 0; + if (failCount >= 3) { + notifyConfigEvent(config.id, 'save_fail', `❌ 转存连续失败 ${failCount} 次`, `**${cloudType}** · ${config.nickname || '未知'} +链接: ${shareUrl} +错误: ${driverResult.message}`, 'warn', { + file_name: sourceTitle || shareUrl || '', + fail_count: String(failCount), + cloud_type: cloudType, + nickname: config.nickname || '', + error: driverResult.message || '', + share_url: shareUrl, + }); + } } db.prepare( diff --git a/source_clean/src/cloud/notification.service.ts b/source_clean/src/cloud/notification.service.ts index f60114c..2c0638c 100644 --- a/source_clean/src/cloud/notification.service.ts +++ b/source_clean/src/cloud/notification.service.ts @@ -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; + name: string; + params: Record; } -// ---- 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 { - 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 { + 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 { + return template.replace(/\{([^}]+)\}/g, (_, key) => vars[key] || '{' + key + '}'); +} + +function getEventTemplates(): Record { + 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): 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 +): 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 +): Promise<{ success: boolean; message: string }> { + let params: Record = {}; + + 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); } diff --git a/source_clean/src/cloud/notifiers/bark.notifier.ts b/source_clean/src/cloud/notifiers/bark.notifier.ts new file mode 100644 index 0000000..00280c2 --- /dev/null +++ b/source_clean/src/cloud/notifiers/bark.notifier.ts @@ -0,0 +1,44 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'key', label: 'Bark Key', type: 'text', required: true, placeholder: 'xxxxxxxxxxxxxxxxx' }, + { key: 'server', label: '服务器', type: 'url', default: 'https://api.day.app', required: false, placeholder: 'https://api.day.app' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch 通知', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const barkNotifier: Notifier = { +name: 'bark', + label: 'Bark', + params, + async notify(params) { + try { + const key = params.key; + const server = (params.server || 'https://api.day.app').replace(/\/+$/, ''); + const title = params.title || 'CloudSearch'; + const content = params.content || ''; + const level = params.level || 'info'; + const icon = level === 'error' ? '⚠️' : level === 'warn' ? '🔔' : 'ℹ️'; + const resp = await fetch(`${server}/${key}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: `${icon} ${title}`, + body: content, + group: 'CloudSearch', + level: level === 'error' ? 'timeSensitive' : 'active', + icon: '', + }), + }); + if (!resp.ok) { + const text = await resp.text(); + return { success: false, message: `HTTP ${resp.status}: ${text.slice(0, 100)}` }; + } + return { success: true, message: 'Bark 推送成功' }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/dingtalk.notifier.ts b/source_clean/src/cloud/notifiers/dingtalk.notifier.ts new file mode 100644 index 0000000..771cda6 --- /dev/null +++ b/source_clean/src/cloud/notifiers/dingtalk.notifier.ts @@ -0,0 +1,36 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=xxx' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const dingtalkNotifier: Notifier = { +name: 'dingtalk', + label: '钉钉机器人', + params, + async notify(params) { + try { + const level = params.level || 'info'; + const text = `## ${params.title || 'CloudSearch'}\n${params.content || ''}`; + const resp = await fetch(params.webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + msgtype: 'markdown', + markdown: { title: params.title || 'CloudSearch', text }, + at: { isAtAll: false }, + }), + }); + const data: any = await resp.json(); + if (data.errcode === 0) + return { success: true, message: '钉钉推送成功' }; + return { success: false, message: data.errmsg || `HTTP ${resp.status}` }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/discord.notifier.ts b/source_clean/src/cloud/notifiers/discord.notifier.ts new file mode 100644 index 0000000..ba3f94e --- /dev/null +++ b/source_clean/src/cloud/notifiers/discord.notifier.ts @@ -0,0 +1,38 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://discord.com/api/webhooks/...' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const discordNotifier: Notifier = { +name: 'discord', + label: 'Discord', + params, + async notify(params) { + try { + const level = params.level || 'info'; + const color = level === 'error' ? 0xff0000 : level === 'warn' ? 0xffa500 : 0x3498db; + const resp = await fetch(params.webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + embeds: [{ + title: params.title || 'CloudSearch', + description: params.content || '', + color, + footer: { text: 'CloudSearch \u00b7 ' + new Date().toLocaleString('zh-CN') }, + }], + }), + }); + if (resp.ok) + return { success: true, message: 'Discord 推送成功' }; + return { success: false, message: `HTTP ${resp.status}` }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/gotify.notifier.ts b/source_clean/src/cloud/notifiers/gotify.notifier.ts new file mode 100644 index 0000000..fd50ea1 --- /dev/null +++ b/source_clean/src/cloud/notifiers/gotify.notifier.ts @@ -0,0 +1,35 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'server', label: '服务器地址', type: 'url', required: true, placeholder: 'https://gotify.example.com' }, + { key: 'token', label: 'App Token', type: 'password', required: true, placeholder: 'Gotify App Token' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'priority', label: '优先级', type: 'text', default: 5, required: false }, +]; + +export const gotifyNotifier: Notifier = { +name: 'gotify', + label: 'Gotify', + params, + async notify(params) { + try { + const server = params.server.replace(/\/+$/, ''); + const resp = await fetch(`${server}/message?token=${params.token}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: params.title || 'CloudSearch', + message: params.content || '', + priority: params.priority || 5, + }), + }); + if (resp.ok) + return { success: true, message: 'Gotify 推送成功' }; + return { success: false, message: `HTTP ${resp.status}` }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/index.ts b/source_clean/src/cloud/notifiers/index.ts new file mode 100644 index 0000000..56bc56f --- /dev/null +++ b/source_clean/src/cloud/notifiers/index.ts @@ -0,0 +1,63 @@ +/** + * notifiers/index.ts — Registry + * Unified management of all notification channels + */ +import { Notifier, NotifyParams, NotifyResult } from './notifier.types'; +import { barkNotifier } from './bark.notifier'; +import { serverchanNotifier } from './serverchan.notifier'; +import { serverchanturboNotifier } from './serverchanturbo.notifier'; +import { telegramNotifier } from './telegram.notifier'; +import { larkNotifier } from './lark.notifier'; +import { webhookNotifier } from './webhook.notifier'; +import { wechatWorkBotNotifier } from './wechat_work_bot.notifier'; +import { pushplusNotifier } from './pushplus.notifier'; +import { dingtalkNotifier } from './dingtalk.notifier'; +import { gotifyNotifier } from './gotify.notifier'; +import { ntfyNotifier } from './ntfy.notifier'; +import { discordNotifier } from './discord.notifier'; +import { smtpNotifier } from './smtp.notifier'; +import { qmsgNotifier } from './qmsg.notifier'; + +const registry = new Map(); + +function register(n: Notifier): void { + registry.set(n.name, n); +} + +// Register all built-in notifiers +register(barkNotifier); +register(serverchanNotifier); +register(serverchanturboNotifier); +register(telegramNotifier); +register(larkNotifier); +register(webhookNotifier); +register(wechatWorkBotNotifier); +register(pushplusNotifier); +register(dingtalkNotifier); +register(gotifyNotifier); +register(ntfyNotifier); +register(discordNotifier); +register(smtpNotifier); +register(qmsgNotifier); + +export function getNotifier(name: string): Notifier | undefined { + return registry.get(name); +} + +export function getAllNotifiers(): Notifier[] { + return Array.from(registry.values()); +} + +export function getAllNotifierParams(): Record { + const result: Record = {}; + for (const [name, n] of registry) { + result[name] = { name: n.name, label: n.label, params: n.params }; + } + return result; +} + +export async function notifyWith(name: string, params: NotifyParams): Promise { + const n = getNotifier(name); + if (!n) return { success: false, message: `Unknown channel: ${name}` }; + return n.notify(params); +} diff --git a/source_clean/src/cloud/notifiers/lark.notifier.ts b/source_clean/src/cloud/notifiers/lark.notifier.ts new file mode 100644 index 0000000..b410a7d --- /dev/null +++ b/source_clean/src/cloud/notifiers/lark.notifier.ts @@ -0,0 +1,39 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx' }, + { key: 'title', label: '\u6807\u9898', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '\u5185\u5bb9', type: 'text', required: true }, + { key: 'level', label: '\u7ea7\u522b', type: 'text', default: 'info', required: false }, +]; + +export const larkNotifier: Notifier = { + name: 'lark', + label: '\u98de\u4e66/Lark', + params, + async notify(p: NotifyParams): Promise { + try { + const level = p.level || 'info'; + const template = level === 'error' ? 'red' : level === 'warn' ? 'orange' : 'blue'; + const resp = await fetch(p.webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: p.title || 'CloudSearch' }, template }, + elements: [ + { tag: 'div', text: { tag: 'lark_md', content: p.content || '' } }, + { tag: 'note', elements: [{ tag: 'plain_text', content: 'CloudSearch \u00b7 ' + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) }] }, + ], + }, + }), + }); + if (resp.ok) return { success: true, message: '\u98de\u4e66\u63a8\u9001\u6210\u529f' }; + const text = await resp.text(); + return { success: false, message: text.slice(0, 150) }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/notifier.types.ts b/source_clean/src/cloud/notifiers/notifier.types.ts new file mode 100644 index 0000000..c2a21ec --- /dev/null +++ b/source_clean/src/cloud/notifiers/notifier.types.ts @@ -0,0 +1,28 @@ +export interface NotifierParam { + key: string; + label: string; + type: 'text' | 'url' | 'number' | 'password' | 'select'; + required: boolean; + default?: any; + placeholder?: string; + options?: { label: string; value: string }[]; +} + +export interface NotifyParams { + [key: string]: any; + title?: string; + content?: string; + level?: 'info' | 'warn' | 'error'; +} + +export interface NotifyResult { + success: boolean; + message: string; +} + +export interface Notifier { + name: string; + label: string; + params: NotifierParam[]; + notify(params: NotifyParams): Promise; +} diff --git a/source_clean/src/cloud/notifiers/ntfy.notifier.ts b/source_clean/src/cloud/notifiers/ntfy.notifier.ts new file mode 100644 index 0000000..f9c764e --- /dev/null +++ b/source_clean/src/cloud/notifiers/ntfy.notifier.ts @@ -0,0 +1,36 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'topic', label: 'Topic', type: 'text', required: true, placeholder: 'my-notification-topic' }, + { key: 'server', label: '服务器', type: 'url', default: 'https://ntfy.sh', required: false, placeholder: 'https://ntfy.sh' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'priority', label: '优先级(1-5)', type: 'text', default: 3, required: false }, +]; + +export const ntfyNotifier: Notifier = { +name: 'ntfy', + label: 'ntfy', + params, + async notify(params) { + try { + const server = (params.server || 'https://ntfy.sh').replace(/\/+$/, ''); + const resp = await fetch(`${server}/${params.topic}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: params.title || 'CloudSearch', + message: params.content || '', + priority: params.priority || 3, + tags: ['cloudsearch'], + }), + }); + if (resp.ok) + return { success: true, message: 'ntfy 推送成功' }; + return { success: false, message: `HTTP ${resp.status}` }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/pushplus.notifier.ts b/source_clean/src/cloud/notifiers/pushplus.notifier.ts new file mode 100644 index 0000000..5b2fb83 --- /dev/null +++ b/source_clean/src/cloud/notifiers/pushplus.notifier.ts @@ -0,0 +1,33 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'token', label: 'Token', type: 'password', required: true, placeholder: 'pushplus token' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, +]; + +export const pushplusNotifier: Notifier = { +name: 'pushplus', + label: 'PushPlus (微信)', + params, + async notify(params) { + try { + const resp = await fetch('https://www.pushplus.plus/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: params.token, + title: params.title || 'CloudSearch', + content: params.content || '', + }), + }); + const data: any = await resp.json(); + if (data.code === 200) + return { success: true, message: 'PushPlus 推送成功' }; + return { success: false, message: data.msg || `HTTP ${resp.status}` }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/qmsg.notifier.ts b/source_clean/src/cloud/notifiers/qmsg.notifier.ts new file mode 100644 index 0000000..0a357f5 --- /dev/null +++ b/source_clean/src/cloud/notifiers/qmsg.notifier.ts @@ -0,0 +1,36 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'key', label: 'API Key', type: 'password', required: true, placeholder: 'Qmsg API Key' }, + { key: 'qq', label: 'QQ 号', type: 'text', required: false, placeholder: '留空则推送到所有绑定QQ' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, +]; + +export const qmsgNotifier: Notifier = { +name: 'qmsg', + label: 'Qmsg (QQ)', + params, + async notify(params) { + try { + const body: any = { + msg: `[${params.title || 'CloudSearch'}]\n${params.content || ''}`, + type: 'send', + }; + if (params.qq) + body.qq = params.qq; + const resp = await fetch(`https://qmsg.zendee.cn/api/${params.key}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body).toString(), + }); + const data: any = await resp.json(); + if (data.code === 0) + return { success: true, message: 'Qmsg 推送成功' }; + return { success: false, message: data.reason || `HTTP ${resp.status}` }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/serverchan.notifier.ts b/source_clean/src/cloud/notifiers/serverchan.notifier.ts new file mode 100644 index 0000000..4e0c914 --- /dev/null +++ b/source_clean/src/cloud/notifiers/serverchan.notifier.ts @@ -0,0 +1,30 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'sendkey', label: 'SendKey', type: 'password', required: true, placeholder: 'Server酱 SendKey' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, +]; + +export const serverchanNotifier: Notifier = { +name: 'serverchan', + label: 'Server酱', + params, + async notify(params) { + try { + const sendkey = params.sendkey; + const resp = await fetch(`https://sctapi.ftqq.com/${sendkey}.send`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ title: params.title || 'CloudSearch', desp: params.content || '' }).toString(), + }); + const data: any = await resp.json(); + if (data.code === 0) + return { success: true, message: 'Server酱 推送成功' }; + return { success: false, message: data.message || `HTTP ${resp.status}` }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/serverchanturbo.notifier.ts b/source_clean/src/cloud/notifiers/serverchanturbo.notifier.ts new file mode 100644 index 0000000..5566663 --- /dev/null +++ b/source_clean/src/cloud/notifiers/serverchanturbo.notifier.ts @@ -0,0 +1,29 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'sendkey', label: 'SendKey', type: 'password', required: true, placeholder: 'Server酱 Turbo SendKey' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, +]; + +export const serverchanturboNotifier: Notifier = { +name: 'serverchanturbo', + label: 'Server酱 Turbo', + params, + async notify(params) { + try { + const resp = await fetch(`https://sctapi.ftqq.com/${params.sendkey}.send`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ title: params.title || 'CloudSearch', desp: params.content || '' }).toString(), + }); + const data: any = await resp.json(); + if (data.code === 0) + return { success: true, message: 'Server酱 Turbo 推送成功' }; + return { success: false, message: data.message || `HTTP ${resp.status}` }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/smtp.notifier.ts b/source_clean/src/cloud/notifiers/smtp.notifier.ts new file mode 100644 index 0000000..1ed61b9 --- /dev/null +++ b/source_clean/src/cloud/notifiers/smtp.notifier.ts @@ -0,0 +1,40 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'host', label: 'SMTP 服务器', type: 'text', required: true, placeholder: 'smtp.qq.com' }, + { key: 'port', label: '端口', type: 'text', default: 465, required: false }, + { key: 'secure', label: 'SSL', type: 'text', default: 'true', required: false }, + { key: 'user', label: '用户名', type: 'text', required: true, placeholder: 'user@example.com' }, + { key: 'pass', label: '密码/授权码', type: 'password', required: true, placeholder: 'SMTP 授权码' }, + { key: 'from', label: '发件人', type: 'text', required: true, placeholder: 'sender@example.com' }, + { key: 'to', label: '收件人', type: 'text', required: true, placeholder: 'receiver@example.com' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, +]; + +export const smtpNotifier: Notifier = { +name: 'smtp', + label: 'SMTP 邮件', + params, + async notify(params) { + try { + const nodemailer = require('nodemailer'); + const transporter = nodemailer.createTransport({ + host: params.host, + port: params.port || 465, + secure: params.secure !== false, + auth: { user: params.user, pass: params.pass }, + }); + await transporter.sendMail({ + from: params.from, + to: params.to, + subject: `[CloudSearch] ${params.title || ''}`, + text: params.content || '', + }); + return { success: true, message: 'SMTP 邮件发送成功' }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/telegram.notifier.ts b/source_clean/src/cloud/notifiers/telegram.notifier.ts new file mode 100644 index 0000000..9fcd2fb --- /dev/null +++ b/source_clean/src/cloud/notifiers/telegram.notifier.ts @@ -0,0 +1,39 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'token', label: 'Bot Token', type: 'password', required: true, placeholder: '123456:ABC-def' }, + { key: 'chat_id', label: 'Chat ID', type: 'text', required: true, placeholder: '@频道 或 -1001234567890' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const telegramNotifier: Notifier = { +name: 'telegram', + label: 'Telegram', + params, + async notify(params) { + try { + const iconMap = { error: '\ud83d\udea8', warn: '\u26a0\ufe0f', info: '\u2139\ufe0f' }; + const level = params.level || 'info'; + const text = `${iconMap[level] || iconMap.info} *${params.title || 'CloudSearch'}*\n\n${params.content || ''}`; + const resp = await fetch(`https://api.telegram.org/bot${params.token}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: params.chat_id, + text, + parse_mode: 'Markdown', + disable_web_page_preview: true, + }), + }); + const data: any = await resp.json(); + if (data.ok) + return { success: true, message: 'Telegram 推送成功' }; + return { success: false, message: data.description || `HTTP ${resp.status}` }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/webhook.notifier.ts b/source_clean/src/cloud/notifiers/webhook.notifier.ts new file mode 100644 index 0000000..5cbeca6 --- /dev/null +++ b/source_clean/src/cloud/notifiers/webhook.notifier.ts @@ -0,0 +1,33 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ + { key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://example.com/webhook' }, + { key: 'title', label: '\u6807\u9898', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '\u5185\u5bb9', type: 'text', required: true }, + { key: 'level', label: '\u7ea7\u522b', type: 'text', default: 'info', required: false }, +]; + +export const webhookNotifier: Notifier = { + name: 'webhook', + label: '\u81ea\u5b9a\u4e49 Webhook', + params, + async notify(p: NotifyParams): Promise { + try { + const resp = await fetch(p.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: p.title || 'CloudSearch', + content: p.content || '', + level: p.level || 'info', + source: 'CloudSearch', + timestamp: new Date().toISOString(), + }), + }); + if (resp.ok) return { success: true, message: 'Webhook \u63a8\u9001\u6210\u529f' }; + return { success: false, message: `HTTP ${resp.status}` }; + } catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/notifiers/wechat_work_bot.notifier.ts b/source_clean/src/cloud/notifiers/wechat_work_bot.notifier.ts new file mode 100644 index 0000000..aac2ef5 --- /dev/null +++ b/source_clean/src/cloud/notifiers/wechat_work_bot.notifier.ts @@ -0,0 +1,34 @@ +import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types'; + +const params: NotifierParam[] = [ +{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx' }, + { key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false }, + { key: 'content', label: '内容', type: 'text', required: true }, + { key: 'level', label: '级别', type: 'text', default: 'info', required: false }, +]; + +export const wechatWorkBotNotifier: Notifier = { +name: 'wechat_work_bot', + label: '企业微信机器人', + params, + async notify(params) { + try { + const content = `## ${params.title || 'CloudSearch'}\n${params.content || ''}`; + const resp = await fetch(params.webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + msgtype: 'markdown', + markdown: { content }, + }), + }); + const data: any = await resp.json(); + if (data.errcode === 0) + return { success: true, message: '企业微信推送成功' }; + return { success: false, message: data.errmsg || `HTTP ${resp.status}` }; + } + catch (err: any) { + return { success: false, message: err.message }; + } + }, +}; diff --git a/source_clean/src/cloud/push-user.service.ts b/source_clean/src/cloud/push-user.service.ts new file mode 100644 index 0000000..0bf7176 --- /dev/null +++ b/source_clean/src/cloud/push-user.service.ts @@ -0,0 +1,59 @@ +import { getDb } from '../database/database'; + +interface PushUser { + id: number; + account: string; + notify_config: string; + created_at: string; + updated_at: string; +} + +export function getAllPushUsers(): PushUser[] { + const db = getDb(); + return db.prepare('SELECT * FROM push_users ORDER BY account ASC').all() as PushUser[]; +} + +export function getPushUserById(id: number): PushUser | undefined { + const db = getDb(); + return db.prepare('SELECT * FROM push_users WHERE id = ?').get(id) as PushUser | undefined; +} + +export function getPushUserByAccount(account: string): PushUser | undefined { + const db = getDb(); + return db.prepare('SELECT * FROM push_users WHERE account = ?').get(account) as PushUser | undefined; +} + +export function upsertPushUser(account: string, notifyConfig: string): PushUser | undefined { + const db = getDb(); + const existing = getPushUserByAccount(account); + if (existing) { + db.prepare("UPDATE push_users SET notify_config = ?, updated_at = datetime('now', 'localtime') WHERE id = ?") + .run(notifyConfig, existing.id); + return getPushUserById(existing.id); + } else { + const result = db.prepare("INSERT INTO push_users (account, notify_config) VALUES (?, ?)") + .run(account, notifyConfig); + return getPushUserById(result.lastInsertRowid as number); + } +} + +export function updatePushUser(id: number, account: string, notifyConfig: string): PushUser | undefined { + const db = getDb(); + db.prepare("UPDATE push_users SET account = ?, notify_config = ?, updated_at = datetime('now', 'localtime') WHERE id = ?") + .run(account, notifyConfig, id); + return getPushUserById(id); +} + +export function deletePushUser(id: number): boolean { + const db = getDb(); + const result = db.prepare('DELETE FROM push_users WHERE id = ?').run(id); + return result.changes > 0; +} + +export function findPushUserForConfig(configId: number): PushUser | undefined { + if (!configId) return undefined; + const db = getDb(); + const config = db.prepare('SELECT promotion_account FROM cloud_configs WHERE id = ?').get(configId) as any; + if (!config || !config.promotion_account) return undefined; + return getPushUserByAccount(config.promotion_account); +} diff --git a/source_clean/src/database/database.ts b/source_clean/src/database/database.ts index 45cc5d3..ab1ef78 100755 --- a/source_clean/src/database/database.ts +++ b/source_clean/src/database/database.ts @@ -243,6 +243,13 @@ function migrateCloudConfigs(db: Database.Database): void { if (!hasPromotionAccount) { db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT NULL"); console.log('[DB] cloud_configs migration: promotion_account column added'); + + // v0.3.5: notify_config for per-cloud push notification settings + const hasNotifyConfig = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%notify_config%'").get(); + if (!hasNotifyConfig) { + db.exec("ALTER TABLE cloud_configs ADD COLUMN notify_config TEXT DEFAULT NULL"); + console.log('[DB] cloud_configs migration: notify_config column added'); + } } } diff --git a/source_clean/src/routes/admin.routes.ts b/source_clean/src/routes/admin.routes.ts index 81d987a..cc51cd4 100644 --- a/source_clean/src/routes/admin.routes.ts +++ b/source_clean/src/routes/admin.routes.ts @@ -8,6 +8,8 @@ import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig import { dailyCheckIn, skipCheckin, getCheckinSummary, getDrivesForCheckin } from '../cloud/checkin.service'; import { getAllCloudTypes } from '../cloud/cloud-types.service'; import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service'; +import { getAllPushUsers, upsertPushUser, updatePushUser, deletePushUser } from '../cloud/push-user.service'; +import { getAllNotifierParams, testChannel, saveConfigNotifySettings, getConfigNotifySettingsJSON } from '../cloud/notification.service'; import { getStats } from '../admin/stats.service'; import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service'; import { getDb } from '../database/database'; @@ -672,4 +674,106 @@ router.post('/admin/update-pansou', async (_req: Request, res: Response) => { } }); + +// ======================== Notification / Push Users ======================== + +/** GET /api/admin/cloud-configs/:id/notify */ +router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const settings = getConfigNotifySettingsJSON(id); + res.json(settings); + } catch (err: any) { + res.status(400).json({ error: err.message || 'Failed to get notification settings' }); + } +}); + +/** PUT /api/admin/cloud-configs/:id/notify */ +router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const settings = req.body; + saveConfigNotifySettings(id, settings); + res.json({ success: true, message: 'Push config saved' }); + } catch (err: any) { + res.status(400).json({ error: err.message || 'Failed to save notification settings' }); + } +}); + +/** POST /api/admin/notify/test */ +router.post('/admin/notify/test', async (req: Request, res: Response) => { + try { + const { channelType, account, configId, params } = req.body; + const ctx = account || (configId ? String(configId) : undefined); + const result = await testChannel(channelType, ctx, params); + res.json(result); + } catch (err: any) { + res.json({ success: false, message: err.message || 'Test send failed' }); + } +}); + +/** GET /api/admin/notify/providers */ +router.get('/admin/notify/providers', (_req: Request, res: Response) => { + try { + const providers = getAllNotifierParams(); + res.json(providers); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get providers' }); + } +}); + +/** GET /api/admin/push-users */ +router.get('/admin/push-users', (_req: Request, res: Response) => { + try { + const users = getAllPushUsers(); + const parsed = users.map(u => ({ + ...u, + notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(), + })); + res.json(parsed); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to list push users' }); + } +}); + +/** POST /api/admin/push-users */ +router.post('/admin/push-users', (req: Request, res: Response) => { + try { + const { account, notify_config } = req.body; + if (!account) return res.status(400).json({ error: 'account is required' }); + const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {}); + const user = upsertPushUser(account, configStr); + res.json({ ...user, notify_config: JSON.parse(user!.notify_config) }); + } catch (err: any) { + res.status(400).json({ error: err.message || 'Failed to save push user' }); + } +}); + +/** PUT /api/admin/push-users/:id */ +router.put('/admin/push-users/:id', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const { account, notify_config } = req.body; + if (!account) return res.status(400).json({ error: 'account is required' }); + const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {}); + const user = updatePushUser(id, account, configStr); + res.json({ ...user, notify_config: JSON.parse(user!.notify_config) }); + } catch (err: any) { + res.status(400).json({ error: err.message || 'Failed to update push user' }); + } +}); + +/** DELETE /api/admin/push-users/:id */ +router.delete('/admin/push-users/:id', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const ok = deletePushUser(id); + if (ok) res.json({ success: true }); + else res.status(404).json({ error: 'Push user not found' }); + } catch (err: any) { + res.status(400).json({ error: err.message || 'Failed to delete push user' }); + } +}); + export default router; +