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:
@@ -5,6 +5,7 @@ import { QuarkDriver } from './drivers/quark.driver';
|
|||||||
import { BaiduDriver } from './drivers/baidu.driver';
|
import { BaiduDriver } from './drivers/baidu.driver';
|
||||||
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
|
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
|
||||||
import { lookupIpLocation } from './ip-lookup';
|
import { lookupIpLocation } from './ip-lookup';
|
||||||
|
import { notifyConfigEvent } from './notification.service';
|
||||||
|
|
||||||
/** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */
|
/** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */
|
||||||
const inFlightSaves = new Map<string, Promise<SaveResult>>();
|
const inFlightSaves = new Map<string, Promise<SaveResult>>();
|
||||||
@@ -174,12 +175,43 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?`
|
`UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?`
|
||||||
).run(config.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) {
|
} else if ((driverResult as any).cookieExpired) {
|
||||||
// Cookie expired — don't count as failure, user needs to re-login
|
// 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 {
|
} else {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
||||||
).run(config.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(
|
db.prepare(
|
||||||
|
|||||||
@@ -1,95 +1,217 @@
|
|||||||
// Native fetch available in Node 20+
|
|
||||||
import { getSystemConfig } from '../admin/system-config.service';
|
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';
|
type NotifyLevel = 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
interface NotifyChannel {
|
interface NotifyChannel {
|
||||||
send(title: string, content: string, level: NotifyLevel): Promise<void>;
|
name: string;
|
||||||
|
params: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Feishu Webhook Channel ----
|
// Re-export for API routes
|
||||||
class FeishuChannel implements NotifyChannel {
|
export { getAllNotifiers, getNotifier, getAllNotifierParams };
|
||||||
private webhookUrl: string;
|
|
||||||
|
|
||||||
constructor(webhookUrl: string) {
|
// ======================== Global channel management ========================
|
||||||
this.webhookUrl = webhookUrl;
|
|
||||||
|
let _globalChannelsCache: NotifyChannel[] | null = null;
|
||||||
|
let _configHash = '';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
|
function checkEventEnabled(eventName: string): boolean {
|
||||||
try {
|
try {
|
||||||
const body = JSON.stringify({
|
const raw = getSystemConfig('global_notify_config') || '{}';
|
||||||
msg_type: 'interactive',
|
let globalConfig: any = {};
|
||||||
card: {
|
try { globalConfig = JSON.parse(raw); } catch {}
|
||||||
header: {
|
if (globalConfig.events && globalConfig.events[`on_${eventName}`] !== undefined) {
|
||||||
title: { tag: 'plain_text', content: title },
|
return globalConfig.events[`on_${eventName}`] !== false;
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
console.error(`[Notify] Feishu send failed: ${resp.status}`);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
} catch (err: any) {
|
||||||
console.error('[Notify] Feishu send error:', err.message);
|
console.error(`[Notify] ${ch.name} error:`, err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Notification Manager ----
|
// ======================== Export API ========================
|
||||||
let _channel: NotifyChannel | null = null;
|
|
||||||
|
|
||||||
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 {
|
export function notify(title: string, content: string, level: NotifyLevel = 'info'): void {
|
||||||
const ch = getChannel();
|
const channels = getGlobalNotifyConfigs();
|
||||||
if (!ch) return;
|
if (channels.length === 0) return;
|
||||||
// Fire-and-forget — don't block the caller
|
sendToChannels(channels, title, content, level).catch(() => {});
|
||||||
ch.send(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 {
|
export function notifyError(title: string, detail: string): void {
|
||||||
notify(`⚠️ ${title}`, detail, 'error');
|
notify(title, detail, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyWarn(title: string, detail: string): void {
|
export function notifyWarn(title: string, detail: string): void {
|
||||||
notify(`🔔 ${title}`, detail, 'warn');
|
notify(title, detail, 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyInfo(title: string, detail: string): void {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
44
source_clean/src/cloud/notifiers/bark.notifier.ts
Normal file
44
source_clean/src/cloud/notifiers/bark.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
36
source_clean/src/cloud/notifiers/dingtalk.notifier.ts
Normal file
36
source_clean/src/cloud/notifiers/dingtalk.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
38
source_clean/src/cloud/notifiers/discord.notifier.ts
Normal file
38
source_clean/src/cloud/notifiers/discord.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
35
source_clean/src/cloud/notifiers/gotify.notifier.ts
Normal file
35
source_clean/src/cloud/notifiers/gotify.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
63
source_clean/src/cloud/notifiers/index.ts
Normal file
63
source_clean/src/cloud/notifiers/index.ts
Normal file
@@ -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<string, Notifier>();
|
||||||
|
|
||||||
|
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<string, { name: string; label: string; params: any[] }> {
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
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<NotifyResult> {
|
||||||
|
const n = getNotifier(name);
|
||||||
|
if (!n) return { success: false, message: `Unknown channel: ${name}` };
|
||||||
|
return n.notify(params);
|
||||||
|
}
|
||||||
39
source_clean/src/cloud/notifiers/lark.notifier.ts
Normal file
39
source_clean/src/cloud/notifiers/lark.notifier.ts
Normal file
@@ -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<NotifyResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
28
source_clean/src/cloud/notifiers/notifier.types.ts
Normal file
28
source_clean/src/cloud/notifiers/notifier.types.ts
Normal file
@@ -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<NotifyResult>;
|
||||||
|
}
|
||||||
36
source_clean/src/cloud/notifiers/ntfy.notifier.ts
Normal file
36
source_clean/src/cloud/notifiers/ntfy.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
33
source_clean/src/cloud/notifiers/pushplus.notifier.ts
Normal file
33
source_clean/src/cloud/notifiers/pushplus.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
36
source_clean/src/cloud/notifiers/qmsg.notifier.ts
Normal file
36
source_clean/src/cloud/notifiers/qmsg.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
30
source_clean/src/cloud/notifiers/serverchan.notifier.ts
Normal file
30
source_clean/src/cloud/notifiers/serverchan.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
29
source_clean/src/cloud/notifiers/serverchanturbo.notifier.ts
Normal file
29
source_clean/src/cloud/notifiers/serverchanturbo.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
40
source_clean/src/cloud/notifiers/smtp.notifier.ts
Normal file
40
source_clean/src/cloud/notifiers/smtp.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
39
source_clean/src/cloud/notifiers/telegram.notifier.ts
Normal file
39
source_clean/src/cloud/notifiers/telegram.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
33
source_clean/src/cloud/notifiers/webhook.notifier.ts
Normal file
33
source_clean/src/cloud/notifiers/webhook.notifier.ts
Normal file
@@ -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<NotifyResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
34
source_clean/src/cloud/notifiers/wechat_work_bot.notifier.ts
Normal file
34
source_clean/src/cloud/notifiers/wechat_work_bot.notifier.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
59
source_clean/src/cloud/push-user.service.ts
Normal file
59
source_clean/src/cloud/push-user.service.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -243,6 +243,13 @@ function migrateCloudConfigs(db: Database.Database): void {
|
|||||||
if (!hasPromotionAccount) {
|
if (!hasPromotionAccount) {
|
||||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT NULL");
|
db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT NULL");
|
||||||
console.log('[DB] cloud_configs migration: promotion_account column added');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig
|
|||||||
import { dailyCheckIn, skipCheckin, getCheckinSummary, getDrivesForCheckin } from '../cloud/checkin.service';
|
import { dailyCheckIn, skipCheckin, getCheckinSummary, getDrivesForCheckin } from '../cloud/checkin.service';
|
||||||
import { getAllCloudTypes } from '../cloud/cloud-types.service';
|
import { getAllCloudTypes } from '../cloud/cloud-types.service';
|
||||||
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.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 { getStats } from '../admin/stats.service';
|
||||||
import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service';
|
import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service';
|
||||||
import { getDb } from '../database/database';
|
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;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user