diff --git a/back/api/system.ts b/back/api/system.ts index e58592e5..ddb4af52 100644 --- a/back/api/system.ts +++ b/back/api/system.ts @@ -383,6 +383,19 @@ export default (app: Router) => { }, ); + route.get( + '/notify-log', + async (req: Request, res: Response, next: NextFunction) => { + try { + const systemService = Container.get(SystemService); + const data = await systemService.getNotifyLog(); + res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + route.delete( '/log', async (req: Request, res: Response, next: NextFunction) => { diff --git a/back/data/system.ts b/back/data/system.ts index af20e8ba..974aed0c 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -28,8 +28,12 @@ export enum AuthDataType { 'removeLogFrequency' = 'removeLogFrequency', 'systemConfig' = 'systemConfig', 'authConfig' = 'authConfig', + 'notifyLog' = 'notifyLog', } +export const NotifyStatus = LoginStatus; +export type NotifyStatus = LoginStatus; + export interface SystemConfigInfo { lang?: string; panelTitle?: string; @@ -53,6 +57,17 @@ export interface LoginLogInfo { status?: LoginStatus; } +export interface NotifyLogInfo { + timestamp?: number; + title?: string; + content?: string; + status?: NotifyStatus; + notifyType?: string; + source?: 'system' | 'script'; + scriptName?: string; + taskId?: string; +} + export interface TokenInfo { value: string; timestamp: number; @@ -88,6 +103,7 @@ export interface AuthInfo { export type SystemModelInfo = SystemConfigInfo & Partial & LoginLogInfo & + Partial & Partial; export interface SystemInstance diff --git a/back/protos/api.proto b/back/protos/api.proto index 4c3269eb..b35406d4 100644 --- a/back/protos/api.proto +++ b/back/protos/api.proto @@ -260,6 +260,15 @@ message SystemNotifyRequest { optional NotificationInfo notificationInfo = 3; } +message RecordNotifyLogRequest { + string title = 1; + string content = 2; + bool success = 3; + string notifyType = 4; + string scriptName = 5; + string taskId = 6; +} + service Api { rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {} rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {} @@ -271,6 +280,7 @@ service Api { rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {} rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {} rpc SystemNotify(SystemNotifyRequest) returns (Response) {} + rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {} rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {} rpc CreateCron(CreateCronRequest) returns (CronResponse) {} rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {} diff --git a/back/protos/api.ts b/back/protos/api.ts index babc07b1..11e759bc 100644 --- a/back/protos/api.ts +++ b/back/protos/api.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v2.6.1 -// protoc v3.17.3 +// protoc v6.31.1 // source: back/protos/api.proto /* eslint-disable */ @@ -417,6 +417,15 @@ export interface SystemNotifyRequest { notificationInfo?: NotificationInfo | undefined; } +export interface RecordNotifyLogRequest { + title: string; + content: string; + success: boolean; + notifyType: string; + scriptName: string; + taskId: string; +} + function createBaseEnvItem(): EnvItem { return { id: undefined, @@ -4296,6 +4305,146 @@ export const SystemNotifyRequest: MessageFns = { }, }; +function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest { + return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" }; +} + +export const RecordNotifyLogRequest: MessageFns = { + encode(message: RecordNotifyLogRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.title !== "") { + writer.uint32(10).string(message.title); + } + if (message.content !== "") { + writer.uint32(18).string(message.content); + } + if (message.success !== false) { + writer.uint32(24).bool(message.success); + } + if (message.notifyType !== "") { + writer.uint32(34).string(message.notifyType); + } + if (message.scriptName !== "") { + writer.uint32(42).string(message.scriptName); + } + if (message.taskId !== "") { + writer.uint32(50).string(message.taskId); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): RecordNotifyLogRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRecordNotifyLogRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.title = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.content = reader.string(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.success = reader.bool(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.notifyType = reader.string(); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.scriptName = reader.string(); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.taskId = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): RecordNotifyLogRequest { + return { + title: isSet(object.title) ? globalThis.String(object.title) : "", + content: isSet(object.content) ? globalThis.String(object.content) : "", + success: isSet(object.success) ? globalThis.Boolean(object.success) : false, + notifyType: isSet(object.notifyType) ? globalThis.String(object.notifyType) : "", + scriptName: isSet(object.scriptName) ? globalThis.String(object.scriptName) : "", + taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "", + }; + }, + + toJSON(message: RecordNotifyLogRequest): unknown { + const obj: any = {}; + if (message.title !== "") { + obj.title = message.title; + } + if (message.content !== "") { + obj.content = message.content; + } + if (message.success !== false) { + obj.success = message.success; + } + if (message.notifyType !== "") { + obj.notifyType = message.notifyType; + } + if (message.scriptName !== "") { + obj.scriptName = message.scriptName; + } + if (message.taskId !== "") { + obj.taskId = message.taskId; + } + return obj; + }, + + create, I>>(base?: I): RecordNotifyLogRequest { + return RecordNotifyLogRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RecordNotifyLogRequest { + const message = createBaseRecordNotifyLogRequest(); + message.title = object.title ?? ""; + message.content = object.content ?? ""; + message.success = object.success ?? false; + message.notifyType = object.notifyType ?? ""; + message.scriptName = object.scriptName ?? ""; + message.taskId = object.taskId ?? ""; + return message; + }, +}; + export type ApiService = typeof ApiService; export const ApiService = { getEnvs: { @@ -4388,6 +4537,15 @@ export const ApiService = { responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseDeserialize: (value: Buffer) => Response.decode(value), }, + recordNotifyLog: { + path: "/com.ql.api.Api/RecordNotifyLog", + requestStream: false, + responseStream: false, + requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()), + requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value), + responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), + responseDeserialize: (value: Buffer) => Response.decode(value), + }, getCronDetail: { path: "/com.ql.api.Api/GetCronDetail", requestStream: false, @@ -4482,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation { updateEnvNames: handleUnaryCall; getEnvById: handleUnaryCall; systemNotify: handleUnaryCall; + recordNotifyLog: handleUnaryCall; getCronDetail: handleUnaryCall; createCron: handleUnaryCall; updateCron: handleUnaryCall; @@ -4644,6 +4803,21 @@ export interface ApiClient extends Client { options: Partial, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; + recordNotifyLog( + request: RecordNotifyLogRequest, + callback: (error: ServiceError | null, response: Response) => void, + ): ClientUnaryCall; + recordNotifyLog( + request: RecordNotifyLogRequest, + metadata: Metadata, + callback: (error: ServiceError | null, response: Response) => void, + ): ClientUnaryCall; + recordNotifyLog( + request: RecordNotifyLogRequest, + metadata: Metadata, + options: Partial, + callback: (error: ServiceError | null, response: Response) => void, + ): ClientUnaryCall; getCronDetail( request: CronDetailRequest, callback: (error: ServiceError | null, response: CronDetailResponse) => void, diff --git a/back/schedule/api.ts b/back/schedule/api.ts index 08f838ee..bfbfbbaf 100644 --- a/back/schedule/api.ts +++ b/back/schedule/api.ts @@ -16,6 +16,7 @@ import { MoveEnvRequest, Response, SystemNotifyRequest, + RecordNotifyLogRequest, UpdateEnvNamesRequest, UpdateEnvRequest, } from '../protos/api'; @@ -238,7 +239,8 @@ export const systemNotify = async ( const data = await systemService.notify({ title: call.request.title, content: call.request.content, - notificationInfo: call.request.notificationInfo as unknown as NotificationInfo, + notificationInfo: call.request + .notificationInfo as unknown as NotificationInfo, }); callback(null, data); } catch (e: any) { @@ -246,10 +248,31 @@ export const systemNotify = async ( } }; +export const recordNotifyLog = async ( + call: ServerUnaryCall, + callback: sendUnaryData, +) => { + try { + await Container.get(SystemService).recordNotifyLog({ + title: call.request.title, + content: call.request.content, + status: call.request.success ? 0 : 1, + notifyType: call.request.notifyType, + source: 'script', + scriptName: call.request.scriptName, + taskId: call.request.taskId, + }); + callback(null, { code: 200 }); + } catch (error: any) { + callback(error); + } +}; + const normalizeCronData = (data: CronItem | null): CronItem | undefined => { if (!data) return undefined; // create() returns a Sequelize instance; spreading it omits attribute getters. - const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data; + const cron = + data instanceof Model ? (data.get({ plain: true }) as CronItem) : data; return { ...cron, labels: cron.labels ?? [], diff --git a/back/services/notify.ts b/back/services/notify.ts index e92269a5..2e9426b9 100644 --- a/back/services/notify.ts +++ b/back/services/notify.ts @@ -1,3 +1,4 @@ +import { persistNotificationAudit } from '../shared/notificationAudit'; import crypto from 'crypto'; import nodemailer from 'nodemailer'; import { Inject, Service } from 'typedi'; @@ -55,27 +56,33 @@ export default class NotificationService { content: string, notificationInfo?: NotificationInfo, ): Promise { - let { type, ...rest } = await this.userService.getNotificationMode(); - if (notificationInfo?.type) { - type = notificationInfo?.type; - } - if (type) { - this.title = title; - this.content = content; - let params = rest; - if (notificationInfo) { - const { type: _, ...others } = notificationInfo; - params = { ...rest, ...others }; - } - this.params = params; - const notificationModeAction = this.modeMap.get(type); - try { - return await notificationModeAction?.call(this); - } catch (error: any) { - console.error(error); + let type: string | undefined; + let success = false; + try { + const saved = await this.userService.getNotificationMode(); + type = notificationInfo?.type || saved.type; + if (type) { + this.title = title; + this.content = content; + const { type: _, ...overrides } = notificationInfo || {}; + const { type: _saved, ...rest } = saved; + this.params = { ...rest, ...overrides }; + try { + success = !!(await this.modeMap.get(type)?.call(this)); + } catch (error) { + console.error(error); + } } + return success; + } finally { + await persistNotificationAudit({ + title, + content, + notifyType: type, + source: 'system', + status: success ? 0 : 1, + }); } - return false; } public async testNotify( @@ -84,14 +91,23 @@ export default class NotificationService { content: string, ) { const { type, ...rest } = info; - if (type) { + if (!type) return true; + let success = false; + try { this.title = title; this.content = content; this.params = rest; - const notificationModeAction = this.modeMap.get(type); - return await notificationModeAction?.call(this); + success = !!(await this.modeMap.get(type)?.call(this)); + return success; + } finally { + await persistNotificationAudit({ + title, + content, + notifyType: type, + source: 'system', + status: success ? 0 : 1, + }); } - return true; } private parseMailRecipients(value?: string) { @@ -921,7 +937,8 @@ export default class NotificationService { private async openiLink() { const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } = this.params; - const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com'; + const baseUrl = + openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com'; const url = `${baseUrl}/bot/v1/message/send`; const body: Record = { type: 'text', @@ -974,5 +991,4 @@ export default class NotificationService { throw new Error(error.response ? error.response.body : error); } } - } diff --git a/back/services/system.ts b/back/services/system.ts index 7567a3ee..b8ac8e64 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -1,3 +1,4 @@ +import { persistNotificationAudit } from '../shared/notificationAudit'; import { spawn } from 'cross-spawn'; import { execFile } from 'child_process'; import { promisify } from 'util'; @@ -32,6 +33,8 @@ import { SystemInstance, SystemModel, SystemModelInfo, + NotifyStatus, + NotifyLogInfo, } from '../data/system'; import taskLimit from '../shared/pLimit'; import cronClient from '../schedule/client'; @@ -53,7 +56,7 @@ export default class SystemService { @Inject('logger') private logger: winston.Logger, private scheduleService: ScheduleService, private sockService: SockService, - ) { } + ) {} public async getSystemConfig() { const doc = await this.getDb({ type: AuthDataType.systemConfig }); @@ -158,7 +161,9 @@ export default class SystemService { if (info.dependenceProxy) { await fs.promises.writeFile( config.dependenceProxyFile, - `export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`, + `export http_proxy=${shellQuote( + info.dependenceProxy, + )}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`, ); } else { await fs.promises.rm(config.dependenceProxyFile); @@ -293,7 +298,7 @@ export default class SystemService { ); const text = await body.text(); lastVersionContent = parseContentVersion(text); - } catch (error) { } + } catch (error) {} if (!lastVersionContent) { lastVersionContent = currentVersionContent; @@ -407,11 +412,13 @@ export default class SystemService { if (notificationInfo && typeString) { notificationInfo.type = typeString; } + const isSuccess = await this.notificationService.notify( title, content, notificationInfo, ); + if (isSuccess) { return { code: 200, message: t('通知发送成功') }; } else { @@ -419,16 +426,36 @@ export default class SystemService { } } - public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) { + public async recordNotifyLog(info: NotifyLogInfo): Promise { + await persistNotificationAudit(info); + } + + public async getNotifyLog(): Promise> { + const docs = await SystemModel.findAll({ + where: { type: AuthDataType.notifyLog }, + order: [['id', 'DESC']], + limit: 200, + }); + return docs.map((x) => ({ ...x.info, id: x.id })); + } + + public async run( + { command, logPath }: { command: string; logPath?: string }, + callback: TaskCallbacks, + ) { if (!command.startsWith(TASK_COMMAND)) { command = `${TASK_COMMAND} ${command}`; } - const logPathPrefix = logPath ? `real_log_path=${logPath}` : '' - this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, { - command, - id: command.replace(/ /g, '-'), - runOrigin: 'system', - }); + const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''; + this.scheduleService.runTask( + `${logPathPrefix} real_time=true ${command}`, + callback, + { + command, + id: command.replace(/ /g, '-'), + runOrigin: 'system', + }, + ); } public async stop({ command, pid }: { command: string; pid: number }) { @@ -459,8 +486,20 @@ export default class SystemService { if (type && type.length) { dataDirs = dataDirs.concat(type.filter((x) => x !== 'base')); } - const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps', - 'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']); + const allowed = new Set([ + 'db', + 'upload', + 'config', + 'scripts', + 'log', + 'deps', + 'syslog', + 'dep_cache', + 'raw', + 'repo', + 'ssh.d', + 'script-history', + ]); if (dataDirs.some((dir) => !allowed.has(dir))) { return res.status(400).send({ code: 400, message: t('参数错误') }); } @@ -470,9 +509,13 @@ export default class SystemService { dataDirs = dataDirs.filter((dir) => dir !== 'script-history'); } const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`); - await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], { - cwd: path.dirname(config.dataPath), - }); + await promisify(execFile)( + 'tar', + ['-zcf', config.dataTgzFile, '--', ...dataPaths], + { + cwd: path.dirname(config.dataPath), + }, + ); res.download(config.dataTgzFile); } catch (error: any) { return res.send({ code: 400, message: error.message }); diff --git a/back/shared/notificationAudit.ts b/back/shared/notificationAudit.ts new file mode 100644 index 00000000..ec2575e2 --- /dev/null +++ b/back/shared/notificationAudit.ts @@ -0,0 +1,48 @@ +import { Op } from 'sequelize'; +import { + SystemModel, + AuthDataType, + NotifyStatus, + NotifyLogInfo, +} from '../data/system'; + +// Both backend sends and script reports use the same bounded, best-effort sink. +export async function persistNotificationAudit( + info: NotifyLogInfo, +): Promise { + try { + await SystemModel.create({ + type: AuthDataType.notifyLog, + info: { + timestamp: Date.now(), + title: String(info.title || '').slice(0, 512), + content: String(info.content || '').slice(0, 8192), + status: + info.status === NotifyStatus.success + ? NotifyStatus.success + : NotifyStatus.fail, + notifyType: String(info.notifyType || '').slice(0, 64), + source: info.source === 'script' ? 'script' : 'system', + scriptName: String(info.scriptName || '').slice(0, 512), + taskId: /^\d+$/.test(info.taskId || '') ? info.taskId : undefined, + }, + }); + const oldest = await SystemModel.findAll({ + where: { type: AuthDataType.notifyLog }, + attributes: ['id'], + order: [['id', 'DESC']], + offset: 200, + limit: 1, + }); + if (oldest[0]?.id) { + await SystemModel.destroy({ + where: { + type: AuthDataType.notifyLog, + id: { [Op.lte]: oldest[0].id }, + }, + }); + } + } catch (error) { + console.error('Failed to persist notification audit log'); + } +} diff --git a/cli/src/remote/api/openOperations.ts b/cli/src/remote/api/openOperations.ts index 56f5fad9..a7e5821c 100644 --- a/cli/src/remote/api/openOperations.ts +++ b/cli/src/remote/api/openOperations.ts @@ -12,6 +12,12 @@ export interface OpenOperation { anonymous?: boolean; } export const openOperations: readonly OpenOperation[] = [ + { + name: 'system notification-logs', + method: 'GET', + path: 'system/notify-log', + }, + { name: 'task view-list', method: 'GET', path: 'crons/views' }, { name: 'task view-create', @@ -478,7 +484,12 @@ export const openOperations: readonly OpenOperation[] = [ path: 'system/config/dependence-clean', body: 'json', }, - { name: 'dashboard record', method: 'POST', path: 'dashboard/record', body: 'json' }, + { + name: 'dashboard record', + method: 'POST', + path: 'dashboard/record', + body: 'json', + }, { name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' }, { name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' }, { name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' }, diff --git a/docs/development/notification-audit.md b/docs/development/notification-audit.md new file mode 100644 index 00000000..84d1350a --- /dev/null +++ b/docs/development/notification-audit.md @@ -0,0 +1,11 @@ +# 通知日志 + +通知日志记录系统统一通知入口(包括登录通知、配置测试和系统通知 API),以及任务使用内置 `notify.js`、`notify.py` 或 `QLAPI.notify` 发出的通知。脚本通知按实际配置的渠道各记录一条结果,未配置渠道和跳过的标题不记账。 + +日志包含时间、标题、正文、渠道、结果和来源;脚本来源另包含文件名与任务 ID。成功表示通知渠道返回成功或接受请求,不表示最终收件人已经收到。多目标渠道只在所有目标成功时记为成功。 + +所有入口共用数据库中的最近 200 条日志。标题最多 512 字符、正文最多 8192 字符;上报字段不包含通知密码、令牌或渠道配置。日志页受系统设置的管理员权限保护。 + +脚本通过现有的本机 mTLS gRPC 服务上报,独立客户端最多等待 1.5 秒。旧面板、服务不可用或数据库写入失败不会改变通知发送结果。Python 使用 Node 上报子进程,并限制在 3 秒内。 + +自行保存的旧版通知库需要更新为面板内置版本才能上报。脚本自行调用 HTTP、SMTP 或第三方 SDK 时不经过这些入口,无法自动纳入通知日志。 diff --git a/sample/notify.js b/sample/notify.js index 35dcce1d..1b1a630f 100644 --- a/sample/notify.js +++ b/sample/notify.js @@ -153,7 +153,7 @@ const push_config = { WXPUSHER_APP_TOKEN: '', // wxpusher 的 appToken WXPUSHER_TOPIC_IDS: '', // wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 WXPUSHER_UIDS: '', // wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 - // 方式二:极简发送(最简单,简单好用,一键配置,更推荐) + // 方式二:极简发送(最简单,简单好用,一键配置,更推荐) // wxPusher 的 SPT(极简推送),扫码获取 https://wxpusher.zjiecode.com/docs/#/?id=spt // 多个用英文逗号,分隔,最多10个;与上面的 appToken 方式二选一即可 WXPUSHER_SPT_LIST: '', // wxpusher 的 SPT(极简推送),多个用英文逗号,分隔,最多10个 @@ -218,6 +218,8 @@ async function one() { } function gotifyNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { GOTIFY_URL, GOTIFY_TOKEN, GOTIFY_PRIORITY } = push_config; if (GOTIFY_URL && GOTIFY_TOKEN) { @@ -233,18 +235,22 @@ function gotifyNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Gotify 发送通知调用API失败😞\n', err); } else { if (data.id) { + auditSucceeded = true; console.log('Gotify 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`Gotify 发送通知调用API失败😞 ${data.message}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -254,6 +260,8 @@ function gotifyNotify(text, desp) { } function gobotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { GOBOT_URL, GOBOT_TOKEN, GOBOT_QQ } = push_config; if (GOBOT_URL) { @@ -268,20 +276,25 @@ function gobotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Go-cqhttp 通知调用API失败😞\n', err); } else { if (data.retcode === 0) { + auditSucceeded = true; console.log('Go-cqhttp 发送通知消息成功🎉\n'); } else if (data.retcode === 100) { + auditFailed = true; console.log(`Go-cqhttp 发送通知消息异常 ${data.errmsg}\n`); } else { + auditFailed = true; console.log(`Go-cqhttp 发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -291,6 +304,8 @@ function gobotNotify(text, desp) { } function serverNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { PUSH_KEY } = push_config; if (PUSH_KEY) { @@ -314,22 +329,27 @@ function serverNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Server 酱发送通知调用API失败😞\n', err); } else { // server酱和Server酱·Turbo版的返回json格式不太一样 if (data.errno === 0 || data.code === 0) { + auditSucceeded = true; console.log('Server 酱发送通知消息成功🎉\n'); } else if (data.errno === 1024) { // 一分钟内发送相同的内容会触发 + auditFailed = true; console.log(`Server 酱发送通知消息异常 ${data.errmsg}\n`); } else { + auditFailed = true; console.log(`Server 酱发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -339,6 +359,8 @@ function serverNotify(text, desp) { } function pushDeerNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { DEER_KEY, DEER_URL } = push_config; if (DEER_KEY) { @@ -355,6 +377,7 @@ function pushDeerNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('PushDeer 通知调用API失败😞\n', err); } else { // 通过返回的result的长度来判断是否成功 @@ -362,17 +385,20 @@ function pushDeerNotify(text, desp) { data.content.result.length !== undefined && data.content.result.length > 0 ) { + auditSucceeded = true; console.log('PushDeer 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log( `PushDeer 发送通知消息异常😞 ${JSON.stringify(data)}`, ); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -382,6 +408,8 @@ function pushDeerNotify(text, desp) { } function chatNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { CHAT_URL, CHAT_TOKEN } = push_config; if (CHAT_URL && CHAT_TOKEN) { @@ -397,18 +425,22 @@ function chatNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Chat 发送通知调用API失败😞\n', err); } else { if (data.success) { + auditSucceeded = true; console.log('Chat 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`Chat 发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { + auditFailed = true; $.logErr(e); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -418,6 +450,8 @@ function chatNotify(text, desp) { } function barkNotify(text, desp, params = {}) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { let { BARK_PUSH, @@ -454,18 +488,22 @@ function barkNotify(text, desp, params = {}) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Bark APP 发送通知调用API失败😞\n', err); } else { if (data.code === 200) { + auditSucceeded = true; console.log('Bark APP 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`Bark APP 发送通知消息异常 ${data.message}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -475,6 +513,8 @@ function barkNotify(text, desp, params = {}) { } function tgBotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { TG_BOT_TOKEN, @@ -511,9 +551,11 @@ function tgBotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Telegram 发送通知消息失败😞\n', err); } else { if (data.ok) { + auditSucceeded = true; console.log('Telegram 发送通知消息成功🎉。\n'); } else if (data.error_code === 400) { console.log( @@ -524,9 +566,10 @@ function tgBotNotify(text, desp) { } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -535,6 +578,8 @@ function tgBotNotify(text, desp) { }); } function ddBotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { DD_BOT_TOKEN, DD_BOT_SECRET } = push_config; const options = { @@ -560,36 +605,44 @@ function ddBotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('钉钉发送通知消息失败😞\n', err); } else { if (data.errcode === 0) { + auditSucceeded = true; console.log('钉钉发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`钉钉发送通知消息异常 ${data.errmsg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else if (DD_BOT_TOKEN) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('钉钉发送通知消息失败😞\n', err); } else { if (data.errcode === 0) { + auditSucceeded = true; console.log('钉钉发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`钉钉发送通知消息异常 ${data.errmsg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -599,6 +652,8 @@ function ddBotNotify(text, desp) { } function qywxBotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { QYWX_ORIGIN, QYWX_KEY } = push_config; const options = { @@ -618,18 +673,22 @@ function qywxBotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('企业微信发送通知消息失败😞\n', err); } else { if (data.errcode === 0) { + auditSucceeded = true; console.log('企业微信发送通知消息成功🎉。\n'); } else { + auditFailed = true; console.log(`企业微信发送通知消息异常 ${data.errmsg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -659,17 +718,22 @@ function ChangeUserId(desp) { } async function qywxamNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; const MAX_LENGTH = 900; if (desp.length > MAX_LENGTH) { let d = desp.substr(0, MAX_LENGTH) + '\n==More=='; - await do_qywxamNotify(text, d); - await qywxamNotify(text, desp.substr(MAX_LENGTH)); + const first = await do_qywxamNotify(text, d); + const rest = await qywxamNotify(text, desp.substr(MAX_LENGTH)); + return first === undefined ? undefined : first && rest; } else { return await do_qywxamNotify(text, desp); } } function do_qywxamNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { QYWX_AM, QYWX_ORIGIN } = push_config; if (QYWX_AM) { @@ -754,6 +818,7 @@ function do_qywxamNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log( '成员ID:' + ChangeUserId(desp) + @@ -762,21 +827,24 @@ function do_qywxamNotify(text, desp) { ); } else { if (data.errcode === 0) { + auditSucceeded = true; console.log( '成员ID:' + ChangeUserId(desp) + '企业微信应用消息发送通知消息成功🎉。\n', ); } else { + auditFailed = true; console.log( `企业微信应用消息发送通知消息异常 ${data.errmsg}\n`, ); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); }); @@ -787,6 +855,8 @@ function do_qywxamNotify(text, desp) { } function iGotNotify(text, desp, params = {}) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { IGOT_PUSH_KEY } = push_config; if (IGOT_PUSH_KEY) { @@ -808,18 +878,22 @@ function iGotNotify(text, desp, params = {}) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('IGot 发送通知调用API失败😞\n', err); } else { if (data.ret === 0) { + auditSucceeded = true; console.log('IGot 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`IGot 发送通知消息异常 ${data.errMsg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -829,6 +903,8 @@ function iGotNotify(text, desp, params = {}) { } function pushPlusNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { PUSH_PLUS_TOKEN, @@ -863,6 +939,7 @@ function pushPlusNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log( `pushplus 发送${ PUSH_PLUS_USER ? '一对多' : '一对一' @@ -871,6 +948,7 @@ function pushPlusNotify(text, desp) { ); } else { if (data.code === 200) { + auditSucceeded = true; console.log( `pushplus 发送${ PUSH_PLUS_USER ? '一对多' : '一对一' @@ -879,6 +957,7 @@ function pushPlusNotify(text, desp) { }\n注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果`, ); } else { + auditFailed = true; console.log( `pushplus 发送${ PUSH_PLUS_USER ? '一对多' : '一对一' @@ -887,9 +966,10 @@ function pushPlusNotify(text, desp) { } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -899,6 +979,8 @@ function pushPlusNotify(text, desp) { } function wePlusBotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { WE_PLUS_BOT_TOKEN, WE_PLUS_BOT_RECEIVER, WE_PLUS_BOT_VERSION } = push_config; @@ -927,18 +1009,22 @@ function wePlusBotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log(`微加机器人发送通知消息失败😞\n`, err); } else { if (data.code === 200) { + auditSucceeded = true; console.log(`微加机器人发送通知消息完成🎉\n`); } else { + auditFailed = true; console.log(`微加机器人发送通知消息异常 ${data.msg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -948,6 +1034,8 @@ function wePlusBotNotify(text, desp) { } function aibotkNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { AIBOTK_KEY, AIBOTK_TYPE, AIBOTK_NAME } = push_config; if (AIBOTK_KEY && AIBOTK_TYPE && AIBOTK_NAME) { @@ -988,18 +1076,22 @@ function aibotkNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('智能微秘书发送通知消息失败😞\n', err); } else { if (data.code === 0) { + auditSucceeded = true; console.log('智能微秘书发送通知消息成功🎉。\n'); } else { + auditFailed = true; console.log(`智能微秘书发送通知消息异常 ${data.error}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1009,6 +1101,8 @@ function aibotkNotify(text, desp) { } function fsBotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { FSKEY, FSSECRET } = push_config; if (FSKEY) { @@ -1041,18 +1135,22 @@ function fsBotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('飞书发送通知调用API失败😞\n', err); } else { if (data.StatusCode === 0 || data.code === 0) { + auditSucceeded = true; console.log('飞书发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`飞书发送通知消息异常 ${data.msg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1113,9 +1211,12 @@ async function smtpNotify(text, desp) { } catch (e) { console.log('SMTP 发送通知消息出现异常😞\n', e); } + return false; } function pushMeNotify(text, desp, params = {}) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { PUSHME_KEY, PUSHME_URL } = push_config; if (PUSHME_KEY) { @@ -1130,18 +1231,22 @@ function pushMeNotify(text, desp, params = {}) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('PushMe 发送通知调用API失败😞\n', err); } else { if (data === 'success') { + auditSucceeded = true; console.log('PushMe 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`PushMe 发送通知消息异常 ${data}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1150,78 +1255,55 @@ function pushMeNotify(text, desp, params = {}) { }); } -function chronocatNotify(title, desp) { - return new Promise((resolve) => { - const { CHRONOCAT_TOKEN, CHRONOCAT_QQ, CHRONOCAT_URL } = push_config; - if (!CHRONOCAT_TOKEN || !CHRONOCAT_QQ || !CHRONOCAT_URL) { - resolve(); - return; +async function chronocatNotify(text, desp) { + const { CHRONOCAT_TOKEN, CHRONOCAT_QQ, CHRONOCAT_URL } = push_config; + if (!CHRONOCAT_TOKEN || !CHRONOCAT_QQ || !CHRONOCAT_URL) return; + const targets = []; + for (const [chatType, prefix] of [ + [1, 'user_id'], + [2, 'group_id'], + ]) { + for (const match of CHRONOCAT_QQ.matchAll( + new RegExp(prefix + '=(\\d+)', 'g'), + )) { + targets.push({ chatType, peerUin: match[1] }); } - - const user_ids = CHRONOCAT_QQ.match(/user_id=(\d+)/g)?.map( - (match) => match.split('=')[1], - ); - const group_ids = CHRONOCAT_QQ.match(/group_id=(\d+)/g)?.map( - (match) => match.split('=')[1], - ); - - const url = `${CHRONOCAT_URL}/api/message/send`; - const headers = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${CHRONOCAT_TOKEN}`, - }; - - for (const [chat_type, ids] of [ - [1, user_ids], - [2, group_ids], - ]) { - if (!ids) { - continue; - } - for (const chat_id of ids) { - const data = { - peer: { - chatType: chat_type, - peerUin: chat_id, - }, - elements: [ - { - elementType: 1, - textElement: { - content: `${title}\n\n${desp}`, - }, + } + const results = await Promise.all( + targets.map(async (peer) => { + try { + const response = await httpClient.post( + `${CHRONOCAT_URL}/api/message/send`, + { + json: { + peer, + elements: [ + { + elementType: 1, + textElement: { content: `${text}\n\n${desp}` }, + }, + ], }, - ], - }; - const options = { - url: url, - json: data, - headers, - timeout, - }; - $.post(options, (err, resp, data) => { - try { - if (err) { - console.log('Chronocat 发送QQ通知消息失败😞\n', err); - } else { - if (chat_type === 1) { - console.log(`Chronocat 个人消息 ${ids}推送成功🎉`); - } else { - console.log(`Chronocat 群消息 ${ids}推送成功🎉`); - } - } - } catch (e) { - $.logErr(e, resp); - } finally { - resolve(data); - } - }); + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${CHRONOCAT_TOKEN}`, + }, + timeout, + }, + ); + await response.body.text(); + return response.statusCode >= 200 && response.statusCode < 300; + } catch (_) { + return false; } - } - }); + }), + ); + return results.length > 0 && results.every(Boolean); } function qmsgNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { QMSG_KEY, QMSG_TYPE } = push_config; if (QMSG_KEY && QMSG_TYPE) { @@ -1236,18 +1318,22 @@ function qmsgNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Qmsg 发送通知调用API失败😞\n', err); } else { if (data.code === 0) { + auditSucceeded = true; console.log('Qmsg 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`Qmsg 发送通知消息异常 ${data}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1257,6 +1343,8 @@ function qmsgNotify(text, desp) { } function webhookNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { WEBHOOK_URL, @@ -1294,24 +1382,35 @@ function webhookNotify(text, desp) { '$title', encodeURIComponent(text), ).replaceAll('$content', encodeURIComponent(desp)); - httpClient.request(formatUrl, options).then(async (resp) => { - const body = await resp.body.text(); - try { - if (resp.statusCode !== 200) { - console.log(`自定义发送通知消息失败😞 ${body}\n`); - } else { - console.log(`自定义发送通知消息成功🎉 ${body}\n`); + httpClient + .request(formatUrl, options) + .then(async (resp) => { + const body = await resp.body.text(); + try { + if (resp.statusCode !== 200) { + auditFailed = true; + console.log(`自定义发送通知消息失败😞 ${body}\n`); + } else { + auditSucceeded = true; + console.log(`自定义发送通知消息成功🎉 ${body}\n`); + } + } catch (e) { + auditFailed = true; + $.logErr(e, resp); + } finally { + resolve(auditSucceeded && !auditFailed); } - } catch (e) { - $.logErr(e, resp); - } finally { - resolve(body); - } - }); + }) + .catch(() => { + console.log('自定义发送通知消息失败'); + resolve(false); + }); }); } function ntfyNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; function encodeRFC2047(text) { const encodedBase64 = Buffer.from(text).toString('base64'); return `=?utf-8?B?${encodedBase64}?=`; @@ -1341,8 +1440,9 @@ function ntfyNotify(text, desp) { if (NTFY_TOKEN) { options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`; } else if (NTFY_USERNAME && NTFY_PASSWORD) { - options.headers['Authorization'] = - `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`; + options.headers['Authorization'] = `Basic ${Buffer.from( + `${NTFY_USERNAME}:${NTFY_PASSWORD}`, + ).toString('base64')}`; } if (NTFY_ACTIONS) { options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS); @@ -1351,18 +1451,22 @@ function ntfyNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Ntfy 通知调用API失败😞\n', err); } else { if (data.id) { + auditSucceeded = true; console.log('Ntfy 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`Ntfy 发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1372,6 +1476,8 @@ function ntfyNotify(text, desp) { } function wxPusherNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { WXPUSHER_APP_TOKEN, WXPUSHER_TOPIC_IDS, WXPUSHER_UIDS } = push_config; @@ -1421,18 +1527,22 @@ function wxPusherNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('wxpusher发送通知消息失败!\n', err); } else { if (data.code === 1000) { + auditSucceeded = true; console.log('wxpusher发送通知消息完成!'); } else { + auditFailed = true; console.log(`wxpusher发送通知消息异常:${data.msg}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1442,6 +1552,8 @@ function wxPusherNotify(text, desp) { } function wxPusherSptNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { WXPUSHER_SPT_LIST } = push_config; if (WXPUSHER_SPT_LIST) { @@ -1479,18 +1591,22 @@ function wxPusherSptNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('wxpusher SPT 发送通知消息失败!\n', err); } else { if (data.code === 1000) { + auditSucceeded = true; console.log('wxpusher SPT 发送通知消息完成!'); } else { + auditFailed = true; console.log(`wxpusher SPT 发送通知消息异常:${data.msg}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1499,9 +1615,9 @@ function wxPusherSptNotify(text, desp) { }); } - - function wpushNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { WPUSH_APIKEY, WPUSH_CHANNEL, WPUSH_TOPIC_CODE } = push_config; if (WPUSH_APIKEY) { @@ -1525,20 +1641,26 @@ function wpushNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('WPUSH 发送通知消息失败!\n', err); } else { if (data.code === 0) { + auditSucceeded = true; console.log('WPUSH 发送通知消息成功!'); } else { + auditFailed = true; console.log( - `WPUSH 发送通知消息异常:${data.message || JSON.stringify(data)}`, + `WPUSH 发送通知消息异常:${ + data.message || JSON.stringify(data) + }`, ); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1548,6 +1670,8 @@ function wpushNotify(text, desp) { } function openiLinkNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { OPENILINK_APP_TOKEN, OPENILINK_HUB_URL, OPENILINK_CONTEXT_TOKEN } = push_config; @@ -1575,18 +1699,22 @@ function openiLinkNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('OpeniLink 发送通知消息失败!\n', err); } else { if (data.ok) { + auditSucceeded = true; console.log('OpeniLink 发送通知消息成功!'); } else { + auditFailed = true; console.log(`OpeniLink 发送通知消息异常:${data.error}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1681,6 +1809,41 @@ function formatBodyFun(contentType, body) { return {}; } +async function auditNotification(channel, title, content, send) { + let result; + try { + result = await send(); + } catch (error) { + await reportNotification(channel, title, content, false); + throw error; + } + // Unconfigured channels do not create audit entries. + if (typeof result === 'boolean') + await reportNotification(channel, title, content, result); + return result; +} + +async function reportNotification(channel, title, content, success) { + if (!process.env.QL_DIR) return; + try { + const path = require('node:path'); + const report = require(path.join( + process.env.QL_DIR, + 'shell/preload/notificationAudit.js', + )); + await report({ + title: String(title || '').slice(0, 512), + content: String(content || '').slice(0, 8192), + success, + notifyType: channel, + scriptName: path.basename(process.argv[1] || ''), + taskId: process.env.ID || '', + }); + } catch (_) { + // Older/offline panels must not prevent script notification delivery. + } +} + /** * sendNotify 推送通知功能 * @param text 通知头 @@ -1703,31 +1866,41 @@ async function sendNotify(text, desp, params = {}) { } await Promise.all([ - serverNotify(text, desp), // 微信server酱 - pushPlusNotify(text, desp), // pushplus - wePlusBotNotify(text, desp), // 微加机器人 - barkNotify(text, desp, params), // iOS Bark APP - tgBotNotify(text, desp), // telegram 机器人 - ddBotNotify(text, desp), // 钉钉机器人 - qywxBotNotify(text, desp), // 企业微信机器人 - qywxamNotify(text, desp), // 企业微信应用消息推送 - iGotNotify(text, desp, params), // iGot - gobotNotify(text, desp), // go-cqhttp - gotifyNotify(text, desp), // gotify - chatNotify(text, desp), // synology chat - pushDeerNotify(text, desp), // PushDeer - aibotkNotify(text, desp), // 智能微秘书 - fsBotNotify(text, desp), // 飞书机器人 - smtpNotify(text, desp), // SMTP 邮件 - pushMeNotify(text, desp, params), // PushMe - chronocatNotify(text, desp), // Chronocat - webhookNotify(text, desp), // 自定义通知 - qmsgNotify(text, desp), // 自定义通知 - ntfyNotify(text, desp), // Ntfy - wxPusherNotify(text, desp), // wxpusher - wxPusherSptNotify(text, desp), // wxpusher SPT - openiLinkNotify(text, desp), // OpeniLink - wpushNotify(text, desp), // WPUSH + auditNotification('server', text, desp, () => serverNotify(text, desp)), // 微信server酱 + auditNotification('pushPlus', text, desp, () => pushPlusNotify(text, desp)), // pushplus + auditNotification('wePlusBot', text, desp, () => + wePlusBotNotify(text, desp), + ), // 微加机器人 + auditNotification('bark', text, desp, () => barkNotify(text, desp, params)), // iOS Bark APP + auditNotification('tgBot', text, desp, () => tgBotNotify(text, desp)), // telegram 机器人 + auditNotification('ddBot', text, desp, () => ddBotNotify(text, desp)), // 钉钉机器人 + auditNotification('qywxBot', text, desp, () => qywxBotNotify(text, desp)), // 企业微信机器人 + auditNotification('qywxam', text, desp, () => qywxamNotify(text, desp)), // 企业微信应用消息推送 + auditNotification('iGot', text, desp, () => iGotNotify(text, desp, params)), // iGot + auditNotification('gobot', text, desp, () => gobotNotify(text, desp)), // go-cqhttp + auditNotification('gotify', text, desp, () => gotifyNotify(text, desp)), // gotify + auditNotification('chat', text, desp, () => chatNotify(text, desp)), // synology chat + auditNotification('pushDeer', text, desp, () => pushDeerNotify(text, desp)), // PushDeer + auditNotification('aibotk', text, desp, () => aibotkNotify(text, desp)), // 智能微秘书 + auditNotification('fsBot', text, desp, () => fsBotNotify(text, desp)), // 飞书机器人 + auditNotification('smtp', text, desp, () => smtpNotify(text, desp)), // SMTP 邮件 + auditNotification('pushMe', text, desp, () => + pushMeNotify(text, desp, params), + ), // PushMe + auditNotification('chronocat', text, desp, () => + chronocatNotify(text, desp), + ), // Chronocat + auditNotification('webhook', text, desp, () => webhookNotify(text, desp)), // 自定义通知 + auditNotification('qmsg', text, desp, () => qmsgNotify(text, desp)), // 自定义通知 + auditNotification('ntfy', text, desp, () => ntfyNotify(text, desp)), // Ntfy + auditNotification('wxPusher', text, desp, () => wxPusherNotify(text, desp)), // wxpusher + auditNotification('wxPusherSpt', text, desp, () => + wxPusherSptNotify(text, desp), + ), // wxpusher SPT + auditNotification('openiLink', text, desp, () => + openiLinkNotify(text, desp), + ), // OpeniLink + auditNotification('wpush', text, desp, () => wpushNotify(text, desp)), // WPUSH ]); } diff --git a/sample/notify.py b/sample/notify.py index eb2ebb0b..a19f1b2f 100644 --- a/sample/notify.py +++ b/sample/notify.py @@ -10,6 +10,8 @@ import threading import time import urllib.parse import smtplib +import subprocess +from typing import Optional from email.mime.text import MIMEText from email.header import Header from email.utils import formataddr @@ -161,10 +163,11 @@ for k in push_config: push_config[k] = v -def bark(title: str, content: str) -> None: +def bark(title: str, content: str) -> Optional[bool]: """ 使用 bark 推送消息。 """ + audit_results = [] if not push_config.get("BARK_PUSH"): return print("bark 服务启动") @@ -200,22 +203,28 @@ def bark(title: str, content: str) -> None: ).json() if response["code"] == 200: + audit_results.append(True) print("bark 推送成功!") else: + audit_results.append(False) print("bark 推送失败!") + return all(audit_results) if audit_results else None -def console(title: str, content: str) -> None: +def console(title: str, content: str) -> Optional[bool]: """ 使用 控制台 推送消息。 """ + audit_results = [] print(f"{title}\n\n{content}") + return True -def dingding_bot(title: str, content: str) -> None: +def dingding_bot(title: str, content: str) -> Optional[bool]: """ 使用 钉钉机器人 推送消息。 """ + audit_results = [] if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"): return print("钉钉机器人 服务启动") @@ -236,15 +245,19 @@ def dingding_bot(title: str, content: str) -> None: ).json() if not response["errcode"]: + audit_results.append(True) print("钉钉机器人 推送成功!") else: + audit_results.append(False) print("钉钉机器人 推送失败!") + return all(audit_results) if audit_results else None -def feishu_bot(title: str, content: str) -> None: +def feishu_bot(title: str, content: str) -> Optional[bool]: """ 使用 飞书机器人 推送消息。 """ + audit_results = [] if not push_config.get("FSKEY"): return print("飞书 服务启动") @@ -268,15 +281,19 @@ def feishu_bot(title: str, content: str) -> None: response = requests.post(url, data=json.dumps(data)).json() if response.get("StatusCode") == 0 or response.get("code") == 0: + audit_results.append(True) print("飞书 推送成功!") else: + audit_results.append(False) print("飞书 推送失败!错误信息如下:\n", response) + return all(audit_results) if audit_results else None -def go_cqhttp(title: str, content: str) -> None: +def go_cqhttp(title: str, content: str) -> Optional[bool]: """ 使用 go_cqhttp 推送消息。 """ + audit_results = [] if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"): return print("go-cqhttp 服务启动") @@ -285,15 +302,19 @@ def go_cqhttp(title: str, content: str) -> None: response = requests.get(url).json() if response["status"] == "ok": + audit_results.append(True) print("go-cqhttp 推送成功!") else: + audit_results.append(False) print("go-cqhttp 推送失败!") + return all(audit_results) if audit_results else None -def gotify(title: str, content: str) -> None: +def gotify(title: str, content: str) -> Optional[bool]: """ 使用 gotify 推送消息。 """ + audit_results = [] if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"): return print("gotify 服务启动") @@ -307,15 +328,19 @@ def gotify(title: str, content: str) -> None: response = requests.post(url, data=data).json() if response.get("id"): + audit_results.append(True) print("gotify 推送成功!") else: + audit_results.append(False) print("gotify 推送失败!") + return all(audit_results) if audit_results else None -def iGot(title: str, content: str) -> None: +def iGot(title: str, content: str) -> Optional[bool]: """ 使用 iGot 推送消息。 """ + audit_results = [] if not push_config.get("IGOT_PUSH_KEY"): return print("iGot 服务启动") @@ -326,15 +351,19 @@ def iGot(title: str, content: str) -> None: response = requests.post(url, data=data, headers=headers).json() if response["ret"] == 0: + audit_results.append(True) print("iGot 推送成功!") else: + audit_results.append(False) print(f'iGot 推送失败!{response["errMsg"]}') + return all(audit_results) if audit_results else None -def serverJ(title: str, content: str) -> None: +def serverJ(title: str, content: str) -> Optional[bool]: """ 通过 serverJ 推送消息。 """ + audit_results = [] if not push_config.get("PUSH_KEY"): return print("serverJ 服务启动") @@ -351,15 +380,19 @@ def serverJ(title: str, content: str) -> None: response = requests.post(url, data=data).json() if response.get("errno") == 0 or response.get("code") == 0: + audit_results.append(True) print("serverJ 推送成功!") else: + audit_results.append(False) print(f'serverJ 推送失败!错误码:{response["message"]}') + return all(audit_results) if audit_results else None -def pushdeer(title: str, content: str) -> None: +def pushdeer(title: str, content: str) -> Optional[bool]: """ 通过PushDeer 推送消息 """ + audit_results = [] if not push_config.get("DEER_KEY"): return print("PushDeer 服务启动") @@ -376,15 +409,19 @@ def pushdeer(title: str, content: str) -> None: response = requests.post(url, data=data).json() if len(response.get("content").get("result")) > 0: + audit_results.append(True) print("PushDeer 推送成功!") else: + audit_results.append(False) print("PushDeer 推送失败!错误信息:", response) + return all(audit_results) if audit_results else None -def chat(title: str, content: str) -> None: +def chat(title: str, content: str) -> Optional[bool]: """ 通过Chat 推送消息 """ + audit_results = [] if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"): return print("chat 服务启动") @@ -393,15 +430,19 @@ def chat(title: str, content: str) -> None: response = requests.post(url, data=data) if response.status_code == 200: + audit_results.append(True) print("Chat 推送成功!") else: + audit_results.append(False) print("Chat 推送失败!错误信息:", response) + return all(audit_results) if audit_results else None -def pushplus_bot(title: str, content: str) -> None: +def pushplus_bot(title: str, content: str) -> Optional[bool]: """ 通过 pushplus 推送消息。 """ + audit_results = [] if not push_config.get("PUSH_PLUS_TOKEN"): return print("PUSHPLUS 服务启动") @@ -424,7 +465,9 @@ def pushplus_bot(title: str, content: str) -> None: code = response["code"] if code == 200: + audit_results.append(True) print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"]) + audit_results.append(True) print( "注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果" ) @@ -437,16 +480,20 @@ def pushplus_bot(title: str, content: str) -> None: response = requests.post(url=url_old, data=body, headers=headers).json() if response["code"] == 200: + audit_results.append(True) print("PUSHPLUS(hxtrip) 推送成功!") else: + audit_results.append(False) print("PUSHPLUS 推送失败!") + return all(audit_results) if audit_results else None -def weplus_bot(title: str, content: str) -> None: +def weplus_bot(title: str, content: str) -> Optional[bool]: """ 通过 微加机器人 推送消息。 """ + audit_results = [] if not push_config.get("WE_PLUS_BOT_TOKEN"): return print("微加机器人 服务启动") @@ -469,15 +516,19 @@ def weplus_bot(title: str, content: str) -> None: response = requests.post(url=url, data=body, headers=headers).json() if response["code"] == 200: + audit_results.append(True) print("微加机器人 推送成功!") else: + audit_results.append(False) print("微加机器人 推送失败!") + return all(audit_results) if audit_results else None -def qmsg_bot(title: str, content: str) -> None: +def qmsg_bot(title: str, content: str) -> Optional[bool]: """ 使用 qmsg 推送消息。 """ + audit_results = [] if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"): return print("qmsg 服务启动") @@ -487,15 +538,19 @@ def qmsg_bot(title: str, content: str) -> None: response = requests.post(url=url, params=payload).json() if response["code"] == 0: + audit_results.append(True) print("qmsg 推送成功!") else: + audit_results.append(False) print(f'qmsg 推送失败!{response["reason"]}') + return all(audit_results) if audit_results else None -def wecom_app(title: str, content: str) -> None: +def wecom_app(title: str, content: str) -> Optional[bool]: """ 通过 企业微信 APP 推送消息。 """ + audit_results = [] if not push_config.get("QYWX_AM"): return QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM")) @@ -521,9 +576,12 @@ def wecom_app(title: str, content: str) -> None: response = wx.send_mpnews(title, content, media_id, touser) if response == "ok": + audit_results.append(True) print("企业微信推送成功!") else: + audit_results.append(False) print("企业微信推送失败!错误信息如下:\n", response) + return all(audit_results) if audit_results else None class WeCom: @@ -588,10 +646,11 @@ class WeCom: return respone["errmsg"] -def wecom_bot(title: str, content: str) -> None: +def wecom_bot(title: str, content: str) -> Optional[bool]: """ 通过 企业微信机器人 推送消息。 """ + audit_results = [] if not push_config.get("QYWX_KEY"): return print("企业微信机器人服务启动") @@ -608,15 +667,19 @@ def wecom_bot(title: str, content: str) -> None: ).json() if response["errcode"] == 0: + audit_results.append(True) print("企业微信机器人推送成功!") else: + audit_results.append(False) print("企业微信机器人推送失败!") + return all(audit_results) if audit_results else None -def telegram_bot(title: str, content: str) -> None: +def telegram_bot(title: str, content: str) -> Optional[bool]: """ 使用 telegram 机器人 推送消息。 """ + audit_results = [] if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"): return print("tg 服务启动") @@ -652,15 +715,19 @@ def telegram_bot(title: str, content: str) -> None: ).json() if response["ok"]: + audit_results.append(True) print("tg 推送成功!") else: + audit_results.append(False) print("tg 推送失败!") + return all(audit_results) if audit_results else None -def aibotk(title: str, content: str) -> None: +def aibotk(title: str, content: str) -> Optional[bool]: """ 使用 智能微秘书 推送消息。 """ + audit_results = [] if ( not push_config.get("AIBOTK_KEY") or not push_config.get("AIBOTK_TYPE") @@ -688,15 +755,19 @@ def aibotk(title: str, content: str) -> None: response = requests.post(url=url, data=body, headers=headers).json() print(response) if response["code"] == 0: + audit_results.append(True) print("智能微秘书 推送成功!") else: + audit_results.append(False) print(f'智能微秘书 推送失败!{response["error"]}') + return all(audit_results) if audit_results else None -def smtp(title: str, content: str) -> None: +def smtp(title: str, content: str) -> Optional[bool]: """ 使用 SMTP 邮件 推送消息。 """ + audit_results = [] if ( not push_config.get("SMTP_SERVER") or not push_config.get("SMTP_SSL") @@ -736,15 +807,19 @@ def smtp(title: str, content: str) -> None: message.as_bytes(), ) smtp_server.close() + audit_results.append(True) print("SMTP 邮件 推送成功!") except Exception as e: + audit_results.append(False) print(f"SMTP 邮件 推送失败!{e}") + return all(audit_results) if audit_results else None -def pushme(title: str, content: str) -> None: +def pushme(title: str, content: str) -> Optional[bool]: """ 使用 PushMe 推送消息。 """ + audit_results = [] if not push_config.get("PUSHME_KEY"): return print("PushMe 服务启动") @@ -764,15 +839,19 @@ def pushme(title: str, content: str) -> None: response = requests.post(url, data=data) if response.status_code == 200 and response.text == "success": + audit_results.append(True) print("PushMe 推送成功!") else: + audit_results.append(False) print(f"PushMe 推送失败!{response.status_code} {response.text}") + return all(audit_results) if audit_results else None -def chronocat(title: str, content: str) -> None: +def chronocat(title: str, content: str) -> Optional[bool]: """ 使用 CHRONOCAT 推送消息。 """ + audit_results = [] if ( not push_config.get("CHRONOCAT_URL") or not push_config.get("CHRONOCAT_QQ") @@ -807,21 +886,27 @@ def chronocat(title: str, content: str) -> None: response = requests.post(url, headers=headers, data=json.dumps(data)) if response.status_code == 200: if chat_type == 1: + audit_results.append(True) print(f"QQ个人消息:{ids}推送成功!") else: + audit_results.append(True) print(f"QQ群消息:{ids}推送成功!") else: if chat_type == 1: + audit_results.append(False) print(f"QQ个人消息:{ids}推送失败!") else: + audit_results.append(False) print(f"QQ群消息:{ids}推送失败!") + return all(audit_results) if audit_results else None -def ntfy(title: str, content: str) -> None: +def ntfy(title: str, content: str) -> Optional[bool]: """ 通过 Ntfy 推送消息 """ + audit_results = [] def encode_rfc2047(text: str) -> str: """将文本编码为符合 RFC 2047 标准的格式""" encoded_bytes = base64.b64encode(text.encode("utf-8")) @@ -853,12 +938,15 @@ def ntfy(title: str, content: str) -> None: url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC") response = requests.post(url, data=data, headers=headers) if response.status_code == 200: # 使用 response.status_code 进行检查 + audit_results.append(True) print("Ntfy 推送成功!") else: + audit_results.append(False) print("Ntfy 推送失败!错误信息:", response.text) + return all(audit_results) if audit_results else None -def wxpusher_bot(title: str, content: str) -> None: +def wxpusher_bot(title: str, content: str) -> Optional[bool]: """ 通过 wxpusher 推送消息。 支持的环境变量: @@ -866,6 +954,7 @@ def wxpusher_bot(title: str, content: str) -> None: - WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔 - WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔 """ + audit_results = [] if not push_config.get("WXPUSHER_APP_TOKEN"): return @@ -909,17 +998,21 @@ def wxpusher_bot(title: str, content: str) -> None: response = requests.post(url=url, json=data, headers=headers).json() if response.get("code") == 1000: + audit_results.append(True) print("wxpusher 推送成功!") else: + audit_results.append(False) print(f"wxpusher 推送失败!错误信息:{response.get('msg')}") + return all(audit_results) if audit_results else None -def wxpusher_spt(title: str, content: str) -> None: +def wxpusher_spt(title: str, content: str) -> Optional[bool]: """ 通过 wxpusher 极简推送(SPT)推送消息。 支持的环境变量: - WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个 """ + audit_results = [] if not push_config.get("WXPUSHER_SPT_LIST"): return @@ -956,17 +1049,21 @@ def wxpusher_spt(title: str, content: str) -> None: response = requests.post(url=url, json=data, headers=headers).json() if response.get("code") == 1000: + audit_results.append(True) print("wxpusher SPT 推送成功!") else: + audit_results.append(False) print(f"wxpusher SPT 推送失败!错误信息:{response.get('msg')}") + return all(audit_results) if audit_results else None -def wpush(title: str, content: str) -> None: +def wpush(title: str, content: str) -> Optional[bool]: """ 通过 WPUSH 推送消息。 官方文档: https://wpush.cn/docs """ + audit_results = [] if not push_config.get("WPUSH_APIKEY"): return @@ -986,12 +1083,15 @@ def wpush(title: str, content: str) -> None: response = requests.post(url=url, json=data, headers=headers, timeout=15).json() if response.get("code") == 0: + audit_results.append(True) print("WPUSH 推送成功!") else: + audit_results.append(False) print(f'WPUSH 推送失败!错误信息:{response.get("message") or response}') + return all(audit_results) if audit_results else None -def openilink(title: str, content: str) -> None: +def openilink(title: str, content: str) -> Optional[bool]: """ 通过 OpeniLink 推送消息。 支持的环境变量: @@ -999,6 +1099,7 @@ def openilink(title: str, content: str) -> None: - OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com - OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取 """ + audit_results = [] if not push_config.get("OPENILINK_APP_TOKEN"): return @@ -1023,9 +1124,12 @@ def openilink(title: str, content: str) -> None: response = requests.post(url=url, json=data, headers=headers).json() if response.get("ok"): + audit_results.append(True) print("OpeniLink 推送成功!") else: + audit_results.append(False) print(f'OpeniLink 推送失败!错误信息:{response.get("error")}') + return all(audit_results) if audit_results else None def parse_headers(headers): @@ -1079,10 +1183,11 @@ def parse_body(body, content_type, value_format_fn=None): return parsed -def custom_notify(title: str, content: str) -> None: +def custom_notify(title: str, content: str) -> Optional[bool]: """ 通过 自定义通知 推送消息。 """ + audit_results = [] if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"): return @@ -1114,9 +1219,12 @@ def custom_notify(title: str, content: str) -> None: ) if response.status_code == 200: + audit_results.append(True) print("自定义通知推送成功!") else: + audit_results.append(False) print(f"自定义通知推送失败!{response.status_code} {response.text}") + return all(audit_results) if audit_results else None def one() -> str: @@ -1204,6 +1312,33 @@ def add_notify_function(): return notify_function +def report_notification(channel, title, content, success): + ql_dir = os.getenv("QL_DIR") + if not ql_dir: + return + payload = {"title": str(title or "")[:512], "content": str(content or "")[:8192], + "success": success, "notifyType": channel, + "scriptName": os.path.basename(__import__("sys").argv[0]), "taskId": os.getenv("ID", "")} + code = """const fs=require('node:fs'),path=require('node:path'); +const report=require(path.join(process.env.QL_DIR,'shell/preload/notificationAudit.js')); +report(JSON.parse(fs.readFileSync(0,'utf8'))).catch(()=>{});""" + try: + subprocess.run(["node", "-e", code], input=json.dumps(payload), text=True, + capture_output=True, timeout=3) + except Exception: + pass # Audit availability never changes notification delivery. + + +def audited_notification(mode, title, content): + result = None + try: + result = mode(title, content) + except Exception: + result = False + print(f"{mode.__name__} 推送失败") + report_notification(mode.__name__, title, content, result is True) + + def send(title: str, content: str, ignore_default_config: bool = False, **kwargs): if kwargs: global push_config @@ -1228,7 +1363,7 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs notify_function = add_notify_function() ts = [ - threading.Thread(target=mode, args=(title, content), name=mode.__name__) + threading.Thread(target=audited_notification, args=(mode, title, content), name=mode.__name__) for mode in notify_function ] [t.start() for t in ts] diff --git a/shell/preload/client.js b/shell/preload/client.js index 7b0420f8..86f4720d 100644 --- a/shell/preload/client.js +++ b/shell/preload/client.js @@ -47,6 +47,7 @@ class GrpcClient { 'updateEnvNames', 'getEnvById', 'systemNotify', + 'recordNotifyLog', 'getCronDetail', 'createCron', 'updateCron', diff --git a/shell/preload/notificationAudit.js b/shell/preload/notificationAudit.js new file mode 100644 index 00000000..0afddc1b --- /dev/null +++ b/shell/preload/notificationAudit.js @@ -0,0 +1,43 @@ +const path = require('node:path'); +const fs = require('node:fs'); + +// A reporting failure must never terminate a script or change its send result. +module.exports = async function reportNotificationAudit(payload) { + let client; + try { + if (!process.env.QL_DIR) return; + const grpc = require('@grpc/grpc-js'); + const loader = require('@grpc/proto-loader'); + const certDir = path.join( + process.env.QL_DATA_DIR || path.join(process.env.QL_DIR, 'data'), + 'config/grpc', + ); + const credentials = grpc.credentials.createSsl( + fs.readFileSync(path.join(certDir, 'ca.crt')), + fs.readFileSync(path.join(certDir, 'client.key')), + fs.readFileSync(path.join(certDir, 'client.crt')), + ); + const definition = loader.loadSync( + path.join(process.env.QL_DIR, 'back/protos/api.proto'), + { keepCase: true, defaults: true }, + ); + const Api = grpc.loadPackageDefinition(definition).com.ql.api.Api; + client = new Api( + `localhost:${process.env.GRPC_PORT || '5500'}`, + credentials, + { 'grpc.enable_http_proxy': 0 }, + ); + await new Promise((resolve) => { + client.RecordNotifyLog( + payload, + new grpc.Metadata(), + { deadline: new Date(Date.now() + 1500) }, + () => resolve(), + ); + }); + } catch (_) { + // Missing certificates, old panels and offline services are best effort. + } finally { + if (client) client.close(); + } +}; diff --git a/src/locales/en-US.json b/src/locales/en-US.json index f639ce7d..9b2382a1 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -699,6 +699,13 @@ "已保存,本次未记录历史版本": "Saved without a history snapshot", "已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.", "保存失败": "Unable to save", + "通知日志": "Notification log", + "发送时间": "Sent at", + "推送渠道": "Channel", + "发送状态": "Delivery status", + "通知来源": "Notification source", + "脚本通知": "Script notification", + "系统通知": "System notification", "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python supports package names, Git URLs, and existing requirements.txt or pyproject.toml files under the scripts or repository directory.", "支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index ce25af22..ae20332b 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -699,6 +699,13 @@ "已保存,本次未记录历史版本": "已保存,本次未记录历史版本", "已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间", "保存失败": "保存失败", + "通知日志": "通知日志", + "发送时间": "发送时间", + "推送渠道": "推送渠道", + "发送状态": "发送状态", + "通知来源": "通知来源", + "脚本通知": "脚本通知", + "系统通知": "系统通知", "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径", "支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径" } diff --git a/src/pages/setting/index.tsx b/src/pages/setting/index.tsx index 00048011..4b211bb2 100644 --- a/src/pages/setting/index.tsx +++ b/src/pages/setting/index.tsx @@ -26,6 +26,7 @@ import { } from '@ant-design/icons'; import SecuritySettings from './security'; import LoginLog from './loginLog'; +import NotifyLog from './notifyLog'; import NotificationSetting from './notification'; import Other from './other'; import About from './about'; @@ -126,6 +127,7 @@ const Setting = () => { const [editedApp, setEditedApp] = useState(); const [tabActiveKey, setTabActiveKey] = useState('security'); const [loginLogData, setLoginLogData] = useState([]); + const [notifyLogData, setNotifyLogData] = useState([]); const [notificationInfo, setNotificationInfo] = useState(); const containergRef = useRef(null); const [height, setHeight] = useState(0); @@ -254,6 +256,8 @@ const Setting = () => { getApps(); } else if (activeKey === 'login') { getLoginLog(); + } else if (activeKey === 'notifylog') { + getNotifyLog(); } else if (activeKey === 'notification') { getNotification(); } @@ -272,6 +276,19 @@ const Setting = () => { }); }; + const getNotifyLog = () => { + request + .get(`${config.apiPrefix}system/notify-log`) + .then(({ code, data }) => { + if (code === 200) { + setNotifyLogData(data); + } + }) + .catch((error: any) => { + console.log(error); + }); + }; + useEffect(() => { if (isDemoEnv) { getApps(); @@ -345,6 +362,11 @@ const Setting = () => { label: intl.get('登录日志'), children: , }, + { + key: 'notifylog', + label: intl.get('通知日志'), + children: , + }, { key: 'client-ip', label: intl.get('客户端 IP'), diff --git a/src/pages/setting/notifyLog.tsx b/src/pages/setting/notifyLog.tsx new file mode 100644 index 00000000..491fe2ba --- /dev/null +++ b/src/pages/setting/notifyLog.tsx @@ -0,0 +1,115 @@ +import intl from 'react-intl-universal'; +import React from 'react'; +import { Table, Tag } from 'antd'; +import dayjs from 'dayjs'; + +interface NotifyLogItem { + id?: number; + timestamp?: number; + title?: string; + content?: string; + status?: number; + notifyType?: string; + source?: 'system' | 'script'; + scriptName?: string; + taskId?: string; +} + +const NotifyStatusLabel: Record = { + 0: '成功', + 1: '失败', +}; + +const NotifyStatusColor: Record = { + 0: 'success', + 1: 'error', +}; + +const columns = [ + { + title: intl.get('通知来源'), + key: 'source', + width: 220, + render: (_: unknown, record: NotifyLogItem) => ( + + {intl.get(record.source === 'script' ? '脚本通知' : '系统通知')} + {record.scriptName ? ` · ${record.scriptName}` : ''} + {record.taskId ? ` (#${record.taskId})` : ''} + + ), + }, + { + title: intl.get('序号'), + width: 50, + render: (text: string, record: any, index: number) => { + return index + 1; + }, + }, + { + title: intl.get('发送时间'), + dataIndex: 'timestamp', + key: 'timestamp', + width: 160, + render: (text: string, record: any) => { + return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'); + }, + }, + { + title: intl.get('标题'), + dataIndex: 'title', + key: 'title', + width: 200, + }, + { + title: intl.get('内容'), + dataIndex: 'content', + key: 'content', + render: (text: string) => { + if (!text) return ''; + return text.length > 100 ? text.slice(0, 100) + '...' : text; + }, + }, + { + title: intl.get('推送渠道'), + dataIndex: 'notifyType', + key: 'notifyType', + width: 120, + }, + { + title: intl.get('发送状态'), + dataIndex: 'status', + key: 'status', + width: 90, + render: (text: string, record: NotifyLogItem) => { + const statusKey = record.status ?? 1; + return ( + + {intl.get(NotifyStatusLabel[statusKey])} + + ); + }, + }, +]; + +const NotifyLog = ({ + data, + height, +}: { + data: Array; + height: number; +}) => { + return ( + <> + + + ); +}; + +export default NotifyLog; diff --git a/test/back/notification-audit.test.cjs b/test/back/notification-audit.test.cjs new file mode 100644 index 00000000..d88cdae1 --- /dev/null +++ b/test/back/notification-audit.test.cjs @@ -0,0 +1,123 @@ +const test = require('node:test'), + assert = require('node:assert/strict'), + { Sequelize, DataTypes } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); +const decorators = { Service: () => (x) => x, Inject: () => () => {} }; +async function fixture(t) { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const rows = db.define('System', { + type: DataTypes.STRING, + info: DataTypes.JSON, + }); + await db.sync(); + const logger = { error() {} }; + const S = load('back/services/system.ts', { + typedi: decorators, + '../config': {}, + '../config/const': { NotificationModeStringMap: { 0: 'gotify' } }, + '../config/util': {}, + '../data/dependence': {}, + '../data/system': { + SystemModel: rows, + AuthDataType: { notifyLog: 'notifyLog', notification: 'notification' }, + NotifyStatus: { success: 0, fail: 1 }, + }, + './schedule': class {}, + './sock': class {}, + './notify': class {}, + '../shared/pLimit': {}, + '../schedule/client': {}, + '../config/index': {}, + '../shared/store': {}, + '../shared/i18n': { t: (s) => s }, + }).default; + const service = Object.assign(Object.create(S.prototype), { + logger, + notificationService: new (load('back/services/notify.ts', { + typedi: decorators, + '../config/util': {}, + '../data/system': { + SystemModel: rows, + AuthDataType: { notifyLog: 'notifyLog' }, + NotifyStatus: { success: 0, fail: 1 }, + }, + '../shared/i18n': { t: (s) => s }, + './user': {}, + }).default)(), + }); + service.notificationService.userService = { + getNotificationMode: async () => ({ type: 'email' }), + }; + service.notificationService.modeMap.set('email', async () => true); + service.getDb = async () => ({ info: { type: 'email' } }); + return { rows, service }; +} +test('writes cap logs without reading the page and preserve other system data', async (t) => { + const { rows, service } = await fixture(t); + await rows.create({ type: 'authConfig', info: { keep: true } }); + for (let i = 0; i < 205; i++) + await service.notify({ title: String(i), content: 'body' }); + assert.equal(await rows.count({ where: { type: 'notifyLog' } }), 200); + const logs = await service.getNotifyLog(); + assert.equal(logs[0].title, '204'); + assert.equal(logs.at(-1).title, '5'); + assert.equal(logs[0].notifyType, 'email'); + assert.equal(await rows.count({ where: { type: 'authConfig' } }), 1); +}); +test('audit DB failure does not turn a successful notification into failure', async (t) => { + const { rows, service } = await fixture(t); + const create = rows.create; + rows.create = async () => { + throw Error('disk full'); + }; + try { + assert.equal( + (await service.notify({ title: 'ok', content: 'ok' })).code, + 200, + ); + } finally { + rows.create = create; + } +}); +test('failed sends are recorded and message size is bounded', async (t) => { + const { service } = await fixture(t); + service.notificationService.modeMap.set('gotify', async () => false); + assert.equal( + ( + await service.notify({ + title: 'x'.repeat(1000), + content: 'y'.repeat(20000), + notificationInfo: { type: 0 }, + }) + ).code, + 400, + ); + const [row] = await service.getNotifyLog(); + assert.equal(row.status, 1); + assert.equal(row.title.length, 512); + assert.equal(row.content.length, 8192); + assert.equal(row.notifyType, 'gotify'); +}); + +test('script reports share retention and preserve source without sending twice', async (t) => { + const { service } = await fixture(t); + await service.recordNotifyLog({ + title: 'task result', + content: 'body', + status: 1, + notifyType: 'smtp', + source: 'script', + scriptName: 'job.py', + taskId: '42', + }); + const [row] = await service.getNotifyLog(); + assert.equal(row.source, 'script'); + assert.equal(row.scriptName, 'job.py'); + assert.equal(row.taskId, '42'); + assert.equal(row.status, 1); +}); diff --git a/test/back/script-notification-audit.test.cjs b/test/back/script-notification-audit.test.cjs new file mode 100644 index 00000000..eb9a15f6 --- /dev/null +++ b/test/back/script-notification-audit.test.cjs @@ -0,0 +1,152 @@ +const test = require('node:test'), + assert = require('node:assert/strict'), + fs = require('node:fs'), + path = require('node:path'), + vm = require('node:vm'); +const load = require('../helpers/load-security-module.cjs'); + +function script(config = {}, response = {}, auditError = false) { + const logs = [], + source = fs.readFileSync('sample/notify.js', 'utf8'); + const transport = async () => ({ + statusCode: response.httpStatus || 200, + body: { text: async () => JSON.stringify(response) }, + }); + const context = { + module: { exports: {} }, + console: { log() {}, info() {} }, + process: { + env: { QL_DIR: '/fixture', HITOKOTO: 'false', ...config }, + argv: ['node', '/fixture/scripts/test-job.js'], + }, + Buffer, + URL, + setTimeout, + clearTimeout, + }; + context.require = (name) => { + if (name === 'undici') + return { request: transport, ProxyAgent: class {}, FormData }; + if (name === '/fixture/shell/preload/notificationAudit.js') + return async (x) => { + if (auditError) throw Error('offline'); + logs.push(x); + }; + if (name === 'nodemailer') + return { + createTransport: () => ({ + sendMail: async () => { + if (response.smtpFailure) throw Error('auth rejected'); + return { messageId: 'fixture' }; + }, + close() {}, + }), + }; + return require(name); + }; + vm.runInNewContext(source, context, { + filename: path.resolve('sample/notify.js'), + }); + return { send: context.module.exports.sendNotify, logs }; +} + +test('JS helper logs only configured channels, success/failure and script/task provenance', async () => { + for (const fail of [false, true]) { + const s = script( + { + SMTP_EMAIL: 'sender@example.test', + SMTP_PASSWORD: 'private-password', + SMTP_SERVICE: 'fixture', + ID: '17', + }, + { smtpFailure: fail }, + ); + await s.send('标题', '正文'); + assert.equal(s.logs.length, 1); + assert.equal(s.logs[0].success, !fail); + assert.equal(s.logs[0].notifyType, 'smtp'); + assert.equal(s.logs[0].scriptName, 'test-job.js'); + assert.equal(s.logs[0].taskId, '17'); + assert.ok(!JSON.stringify(s.logs).includes('private-password')); + } + const s = script(); + await s.send('none', 'none'); + assert.equal(s.logs.length, 0); +}); + +test('HTTP business rejection is audited as failure and logging errors do not change sends', async () => { + for (const [code, success] of [ + [200, true], + [500, false], + ]) { + const s = script({ BARK_PUSH: 'https://fixture.invalid' }, { code }); + await s.send('title', 'body'); + assert.equal(s.logs.length, 1); + assert.equal(s.logs[0].success, success); + } + const offline = script( + { + SMTP_EMAIL: 'sender@example.test', + SMTP_PASSWORD: 'fixture', + SMTP_SERVICE: 'fixture', + }, + {}, + true, + ); + await offline.send('still sends', 'body'); +}); + +test('multi-channel helpers write one result per actual channel', async () => { + const s = script( + { + GOTIFY_URL: 'https://fixture.invalid', + GOTIFY_TOKEN: 'private-token', + SMTP_EMAIL: 'sender@example.test', + SMTP_PASSWORD: 'fixture', + SMTP_SERVICE: 'fixture', + }, + { id: 123 }, + ); + await s.send('title', 'body'); + assert.deepEqual(s.logs.map((x) => x.notifyType).sort(), ['gotify', 'smtp']); + assert.ok(s.logs.every((x) => x.success)); +}); + +test('script audit protobuf round trips without notification credentials', () => { + const p = load('back/protos/api.ts'); + const value = p.RecordNotifyLogRequest.create({ + title: '中文', + content: '内容', + success: false, + notifyType: 'smtp', + scriptName: 'job.py', + taskId: '12', + }); + assert.deepEqual( + p.RecordNotifyLogRequest.decode( + p.RecordNotifyLogRequest.encode(value).finish(), + ), + value, + ); + assert.ok(p.ApiService.recordNotifyLog); +}); + +test('multi-target Chronocat records one failure if any recipient fails', async () => { + let calls = 0; + const response = {}; + Object.defineProperty(response, 'httpStatus', { + get: () => (++calls === 1 ? 200 : 500), + }); + const s = script( + { + CHRONOCAT_URL: 'https://fixture.invalid', + CHRONOCAT_TOKEN: 'private-token', + CHRONOCAT_QQ: 'user_id=123&group_id=456', + }, + response, + ); + await s.send('title', 'body'); + assert.equal(calls, 2); + assert.equal(s.logs.length, 1); + assert.equal(s.logs[0].success, false); +}); diff --git a/test/back/script-notification-rpc.test.cjs b/test/back/script-notification-rpc.test.cjs new file mode 100644 index 00000000..5f06c7f2 --- /dev/null +++ b/test/back/script-notification-rpc.test.cjs @@ -0,0 +1,114 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { promisify } = require('node:util'); +const { execFile } = require('node:child_process'); +const grpc = require('@grpc/grpc-js'); +const loader = require('@grpc/proto-loader'); +const load = require('../helpers/load-security-module.cjs'); +const run = promisify(execFile); + +test('JS and Python script reports reach the same authenticated RPC with bounded failure', async (t) => { + const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-audit-rpc-')); + t.after(() => fs.rmSync(data, { recursive: true, force: true })); + fs.mkdirSync(path.join(data, 'config')); + const certs = await load('back/config/grpcCerts.ts', { + './index': { configPath: path.join(data, 'config') }, + './util': { fileExist: async (p) => fs.existsSync(p) }, + '../loaders/logger': { info() {}, error() {} }, + }).initGrpcCerts(); + const definition = loader.loadSync(path.resolve('back/protos/api.proto'), { + keepCase: true, + defaults: true, + }); + const Api = grpc.loadPackageDefinition(definition).com.ql.api.Api; + const received = []; + const server = new grpc.Server(); + server.addService(Api.service, { + RecordNotifyLog: (call, callback) => { + received.push(call.request); + callback(null, { code: 200 }); + }, + }); + const credentials = grpc.ServerCredentials.createSsl( + Buffer.from(certs.caCert), + [ + { + private_key: Buffer.from(certs.serverKey), + cert_chain: Buffer.from(certs.serverCert), + }, + ], + true, + ); + const port = await new Promise((resolve, reject) => + server.bindAsync('127.0.0.1:0', credentials, (error, port) => + error ? reject(error) : resolve(port), + ), + ); + t.after(() => server.forceShutdown()); + const env = { + ...process.env, + QL_DIR: process.cwd(), + QL_DATA_DIR: data, + GRPC_PORT: String(port), + ID: '9', + HITOKOTO: 'false', + }; + await run( + process.execPath, + [ + '-e', + `require('./shell/preload/notificationAudit.js')({title:'JS task',content:'body',success:true,notifyType:'smtp',scriptName:'task.js',taskId:process.env.ID})`, + ], + { env, timeout: 8000 }, + ); + const python = `import importlib.util,sys,types,smtplib,os +sys.modules['requests']=types.SimpleNamespace() +spec=importlib.util.spec_from_file_location('notify',os.path.join(os.environ['QL_DIR'],'sample/notify.py')) +n=importlib.util.module_from_spec(spec);spec.loader.exec_module(n) +class Mail: + def __init__(self,*a,**kw): pass + def login(self,*a): pass + def sendmail(self,*a): return {} + def close(self): pass +smtplib.SMTP_SSL=Mail +n.push_config={'SMTP_SERVER':'fixture','SMTP_SSL':'true','SMTP_EMAIL':'sender@example.test','SMTP_PASSWORD':'private-password','SMTP_NAME':'fixture','HITOKOTO':False} +n.send('Python success','body') +def rejected(*a,**kw): raise RuntimeError('private-password') +smtplib.SMTP_SSL=rejected +n.send('Python failure','body') +`; + await run('python3', ['-c', python], { env, timeout: 12000 }); + assert.deepEqual( + received.map((x) => [x.title, x.success]), + [ + ['JS task', true], + ['Python success', true], + ['Python failure', false], + ], + ); + assert.ok(received.every((x) => x.taskId === '9')); + assert.ok(!JSON.stringify(received).includes('private-password')); + // Missing certificates and an older/unavailable backend must not kill scripts. + await run( + process.execPath, + [ + '-e', + `require('./shell/preload/notificationAudit.js')({title:'offline'}).then(()=>console.log('done'))`, + ], + { env: { ...env, QL_DATA_DIR: path.join(data, 'missing') }, timeout: 8000 }, + ); + server.forceShutdown(); + const started = Date.now(); + await run( + process.execPath, + [ + '-e', + `require('./shell/preload/notificationAudit.js')({title:'offline'})`, + ], + { env, timeout: 8000 }, + ); + assert.ok(Date.now() - started < 5000); +}); diff --git a/test/back/wpush-grpc.test.cjs b/test/back/wpush-grpc.test.cjs index 3229882d..c40070fa 100644 --- a/test/back/wpush-grpc.test.cjs +++ b/test/back/wpush-grpc.test.cjs @@ -64,7 +64,7 @@ test('decoded SystemNotify requests reach WPUSH with explicit or saved configura '../config/const': constants, '../config/util': {}, '../data/dependence': {}, - '../data/system': {}, + '../data/system': { AuthDataType: { notifyLog: 'notifyLog' }, NotifyStatus: { success: 0, fail: 1 }, SystemModel: { create: async () => {}, findAll: async () => [] } }, '../shared/pLimit': {}, '../schedule/client': {}, '../shared/i18n': { t: (x) => x }, @@ -79,7 +79,7 @@ test('decoded SystemNotify requests reach WPUSH with explicit or saved configura wpushApiKey: 'saved-key', }), }; - const system = new SystemService(); + const system = new SystemService({ error() {} }); system.notificationService = notification; for (const info of [