mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-17 07:20:51 +08:00
feat(notify): audit system and JavaScript/Python script notifications
This commit is contained in:
@@ -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(
|
route.delete(
|
||||||
'/log',
|
'/log',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ export enum AuthDataType {
|
|||||||
'removeLogFrequency' = 'removeLogFrequency',
|
'removeLogFrequency' = 'removeLogFrequency',
|
||||||
'systemConfig' = 'systemConfig',
|
'systemConfig' = 'systemConfig',
|
||||||
'authConfig' = 'authConfig',
|
'authConfig' = 'authConfig',
|
||||||
|
'notifyLog' = 'notifyLog',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const NotifyStatus = LoginStatus;
|
||||||
|
export type NotifyStatus = LoginStatus;
|
||||||
|
|
||||||
export interface SystemConfigInfo {
|
export interface SystemConfigInfo {
|
||||||
lang?: string;
|
lang?: string;
|
||||||
panelTitle?: string;
|
panelTitle?: string;
|
||||||
@@ -53,6 +57,17 @@ export interface LoginLogInfo {
|
|||||||
status?: LoginStatus;
|
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 {
|
export interface TokenInfo {
|
||||||
value: string;
|
value: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -88,6 +103,7 @@ export interface AuthInfo {
|
|||||||
export type SystemModelInfo = SystemConfigInfo &
|
export type SystemModelInfo = SystemConfigInfo &
|
||||||
Partial<NotificationInfo> &
|
Partial<NotificationInfo> &
|
||||||
LoginLogInfo &
|
LoginLogInfo &
|
||||||
|
Partial<NotifyLogInfo> &
|
||||||
Partial<AuthInfo>;
|
Partial<AuthInfo>;
|
||||||
|
|
||||||
export interface SystemInstance
|
export interface SystemInstance
|
||||||
|
|||||||
@@ -260,6 +260,15 @@ message SystemNotifyRequest {
|
|||||||
optional NotificationInfo notificationInfo = 3;
|
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 {
|
service Api {
|
||||||
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
||||||
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
||||||
@@ -271,6 +280,7 @@ service Api {
|
|||||||
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
|
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
|
||||||
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
|
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
|
||||||
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
|
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
|
||||||
|
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
|
||||||
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
|
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
|
||||||
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
|
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
|
||||||
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
|
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
|
||||||
|
|||||||
+175
-1
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-ts_proto v2.6.1
|
// protoc-gen-ts_proto v2.6.1
|
||||||
// protoc v3.17.3
|
// protoc v6.31.1
|
||||||
// source: back/protos/api.proto
|
// source: back/protos/api.proto
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
@@ -417,6 +417,15 @@ export interface SystemNotifyRequest {
|
|||||||
notificationInfo?: NotificationInfo | undefined;
|
notificationInfo?: NotificationInfo | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecordNotifyLogRequest {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
success: boolean;
|
||||||
|
notifyType: string;
|
||||||
|
scriptName: string;
|
||||||
|
taskId: string;
|
||||||
|
}
|
||||||
|
|
||||||
function createBaseEnvItem(): EnvItem {
|
function createBaseEnvItem(): EnvItem {
|
||||||
return {
|
return {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
@@ -4296,6 +4305,146 @@ export const SystemNotifyRequest: MessageFns<SystemNotifyRequest> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest {
|
||||||
|
return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RecordNotifyLogRequest: MessageFns<RecordNotifyLogRequest> = {
|
||||||
|
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 extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(base?: I): RecordNotifyLogRequest {
|
||||||
|
return RecordNotifyLogRequest.fromPartial(base ?? ({} as any));
|
||||||
|
},
|
||||||
|
fromPartial<I extends Exact<DeepPartial<RecordNotifyLogRequest>, 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 type ApiService = typeof ApiService;
|
||||||
export const ApiService = {
|
export const ApiService = {
|
||||||
getEnvs: {
|
getEnvs: {
|
||||||
@@ -4388,6 +4537,15 @@ export const ApiService = {
|
|||||||
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
|
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
|
||||||
responseDeserialize: (value: Buffer) => Response.decode(value),
|
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: {
|
getCronDetail: {
|
||||||
path: "/com.ql.api.Api/GetCronDetail",
|
path: "/com.ql.api.Api/GetCronDetail",
|
||||||
requestStream: false,
|
requestStream: false,
|
||||||
@@ -4482,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation {
|
|||||||
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
|
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
|
||||||
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
|
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
|
||||||
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
|
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
|
||||||
|
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
|
||||||
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
|
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
|
||||||
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
|
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
|
||||||
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
|
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
|
||||||
@@ -4644,6 +4803,21 @@ export interface ApiClient extends Client {
|
|||||||
options: Partial<CallOptions>,
|
options: Partial<CallOptions>,
|
||||||
callback: (error: ServiceError | null, response: Response) => void,
|
callback: (error: ServiceError | null, response: Response) => void,
|
||||||
): ClientUnaryCall;
|
): 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<CallOptions>,
|
||||||
|
callback: (error: ServiceError | null, response: Response) => void,
|
||||||
|
): ClientUnaryCall;
|
||||||
getCronDetail(
|
getCronDetail(
|
||||||
request: CronDetailRequest,
|
request: CronDetailRequest,
|
||||||
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
|
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
|
||||||
|
|||||||
+25
-2
@@ -16,6 +16,7 @@ import {
|
|||||||
MoveEnvRequest,
|
MoveEnvRequest,
|
||||||
Response,
|
Response,
|
||||||
SystemNotifyRequest,
|
SystemNotifyRequest,
|
||||||
|
RecordNotifyLogRequest,
|
||||||
UpdateEnvNamesRequest,
|
UpdateEnvNamesRequest,
|
||||||
UpdateEnvRequest,
|
UpdateEnvRequest,
|
||||||
} from '../protos/api';
|
} from '../protos/api';
|
||||||
@@ -238,7 +239,8 @@ export const systemNotify = async (
|
|||||||
const data = await systemService.notify({
|
const data = await systemService.notify({
|
||||||
title: call.request.title,
|
title: call.request.title,
|
||||||
content: call.request.content,
|
content: call.request.content,
|
||||||
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
|
notificationInfo: call.request
|
||||||
|
.notificationInfo as unknown as NotificationInfo,
|
||||||
});
|
});
|
||||||
callback(null, data);
|
callback(null, data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -246,10 +248,31 @@ export const systemNotify = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const recordNotifyLog = async (
|
||||||
|
call: ServerUnaryCall<RecordNotifyLogRequest, Response>,
|
||||||
|
callback: sendUnaryData<Response>,
|
||||||
|
) => {
|
||||||
|
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 => {
|
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
|
||||||
if (!data) return undefined;
|
if (!data) return undefined;
|
||||||
// create() returns a Sequelize instance; spreading it omits attribute getters.
|
// 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 {
|
return {
|
||||||
...cron,
|
...cron,
|
||||||
labels: cron.labels ?? [],
|
labels: cron.labels ?? [],
|
||||||
|
|||||||
+36
-20
@@ -1,3 +1,4 @@
|
|||||||
|
import { persistNotificationAudit } from '../shared/notificationAudit';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { Inject, Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
@@ -55,27 +56,33 @@ export default class NotificationService {
|
|||||||
content: string,
|
content: string,
|
||||||
notificationInfo?: NotificationInfo,
|
notificationInfo?: NotificationInfo,
|
||||||
): Promise<boolean | undefined> {
|
): Promise<boolean | undefined> {
|
||||||
let { type, ...rest } = await this.userService.getNotificationMode();
|
let type: string | undefined;
|
||||||
if (notificationInfo?.type) {
|
let success = false;
|
||||||
type = notificationInfo?.type;
|
try {
|
||||||
}
|
const saved = await this.userService.getNotificationMode();
|
||||||
|
type = notificationInfo?.type || saved.type;
|
||||||
if (type) {
|
if (type) {
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.content = content;
|
this.content = content;
|
||||||
let params = rest;
|
const { type: _, ...overrides } = notificationInfo || {};
|
||||||
if (notificationInfo) {
|
const { type: _saved, ...rest } = saved;
|
||||||
const { type: _, ...others } = notificationInfo;
|
this.params = { ...rest, ...overrides };
|
||||||
params = { ...rest, ...others };
|
|
||||||
}
|
|
||||||
this.params = params;
|
|
||||||
const notificationModeAction = this.modeMap.get(type);
|
|
||||||
try {
|
try {
|
||||||
return await notificationModeAction?.call(this);
|
success = !!(await this.modeMap.get(type)?.call(this));
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return success;
|
||||||
|
} finally {
|
||||||
|
await persistNotificationAudit({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
notifyType: type,
|
||||||
|
source: 'system',
|
||||||
|
status: success ? 0 : 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async testNotify(
|
public async testNotify(
|
||||||
@@ -84,14 +91,23 @@ export default class NotificationService {
|
|||||||
content: string,
|
content: string,
|
||||||
) {
|
) {
|
||||||
const { type, ...rest } = info;
|
const { type, ...rest } = info;
|
||||||
if (type) {
|
if (!type) return true;
|
||||||
|
let success = false;
|
||||||
|
try {
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.params = rest;
|
this.params = rest;
|
||||||
const notificationModeAction = this.modeMap.get(type);
|
success = !!(await this.modeMap.get(type)?.call(this));
|
||||||
return await notificationModeAction?.call(this);
|
return success;
|
||||||
|
} finally {
|
||||||
|
await persistNotificationAudit({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
notifyType: type,
|
||||||
|
source: 'system',
|
||||||
|
status: success ? 0 : 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseMailRecipients(value?: string) {
|
private parseMailRecipients(value?: string) {
|
||||||
@@ -921,7 +937,8 @@ export default class NotificationService {
|
|||||||
private async openiLink() {
|
private async openiLink() {
|
||||||
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
|
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
|
||||||
this.params;
|
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 url = `${baseUrl}/bot/v1/message/send`;
|
||||||
const body: Record<string, string> = {
|
const body: Record<string, string> = {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -974,5 +991,4 @@ export default class NotificationService {
|
|||||||
throw new Error(error.response ? error.response.body : error);
|
throw new Error(error.response ? error.response.body : error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-11
@@ -1,3 +1,4 @@
|
|||||||
|
import { persistNotificationAudit } from '../shared/notificationAudit';
|
||||||
import { spawn } from 'cross-spawn';
|
import { spawn } from 'cross-spawn';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
@@ -32,6 +33,8 @@ import {
|
|||||||
SystemInstance,
|
SystemInstance,
|
||||||
SystemModel,
|
SystemModel,
|
||||||
SystemModelInfo,
|
SystemModelInfo,
|
||||||
|
NotifyStatus,
|
||||||
|
NotifyLogInfo,
|
||||||
} from '../data/system';
|
} from '../data/system';
|
||||||
import taskLimit from '../shared/pLimit';
|
import taskLimit from '../shared/pLimit';
|
||||||
import cronClient from '../schedule/client';
|
import cronClient from '../schedule/client';
|
||||||
@@ -53,7 +56,7 @@ export default class SystemService {
|
|||||||
@Inject('logger') private logger: winston.Logger,
|
@Inject('logger') private logger: winston.Logger,
|
||||||
private scheduleService: ScheduleService,
|
private scheduleService: ScheduleService,
|
||||||
private sockService: SockService,
|
private sockService: SockService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public async getSystemConfig() {
|
public async getSystemConfig() {
|
||||||
const doc = await this.getDb({ type: AuthDataType.systemConfig });
|
const doc = await this.getDb({ type: AuthDataType.systemConfig });
|
||||||
@@ -158,7 +161,9 @@ export default class SystemService {
|
|||||||
if (info.dependenceProxy) {
|
if (info.dependenceProxy) {
|
||||||
await fs.promises.writeFile(
|
await fs.promises.writeFile(
|
||||||
config.dependenceProxyFile,
|
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 {
|
} else {
|
||||||
await fs.promises.rm(config.dependenceProxyFile);
|
await fs.promises.rm(config.dependenceProxyFile);
|
||||||
@@ -293,7 +298,7 @@ export default class SystemService {
|
|||||||
);
|
);
|
||||||
const text = await body.text();
|
const text = await body.text();
|
||||||
lastVersionContent = parseContentVersion(text);
|
lastVersionContent = parseContentVersion(text);
|
||||||
} catch (error) { }
|
} catch (error) {}
|
||||||
|
|
||||||
if (!lastVersionContent) {
|
if (!lastVersionContent) {
|
||||||
lastVersionContent = currentVersionContent;
|
lastVersionContent = currentVersionContent;
|
||||||
@@ -407,11 +412,13 @@ export default class SystemService {
|
|||||||
if (notificationInfo && typeString) {
|
if (notificationInfo && typeString) {
|
||||||
notificationInfo.type = typeString;
|
notificationInfo.type = typeString;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSuccess = await this.notificationService.notify(
|
const isSuccess = await this.notificationService.notify(
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
notificationInfo,
|
notificationInfo,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
return { code: 200, message: t('通知发送成功') };
|
return { code: 200, message: t('通知发送成功') };
|
||||||
} else {
|
} 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<void> {
|
||||||
|
await persistNotificationAudit(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
|
||||||
|
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)) {
|
if (!command.startsWith(TASK_COMMAND)) {
|
||||||
command = `${TASK_COMMAND} ${command}`;
|
command = `${TASK_COMMAND} ${command}`;
|
||||||
}
|
}
|
||||||
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
|
const logPathPrefix = logPath ? `real_log_path=${logPath}` : '';
|
||||||
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
|
this.scheduleService.runTask(
|
||||||
|
`${logPathPrefix} real_time=true ${command}`,
|
||||||
|
callback,
|
||||||
|
{
|
||||||
command,
|
command,
|
||||||
id: command.replace(/ /g, '-'),
|
id: command.replace(/ /g, '-'),
|
||||||
runOrigin: 'system',
|
runOrigin: 'system',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop({ command, pid }: { command: string; pid: number }) {
|
public async stop({ command, pid }: { command: string; pid: number }) {
|
||||||
@@ -459,8 +486,20 @@ export default class SystemService {
|
|||||||
if (type && type.length) {
|
if (type && type.length) {
|
||||||
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
|
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
|
||||||
}
|
}
|
||||||
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
|
const allowed = new Set([
|
||||||
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
|
'db',
|
||||||
|
'upload',
|
||||||
|
'config',
|
||||||
|
'scripts',
|
||||||
|
'log',
|
||||||
|
'deps',
|
||||||
|
'syslog',
|
||||||
|
'dep_cache',
|
||||||
|
'raw',
|
||||||
|
'repo',
|
||||||
|
'ssh.d',
|
||||||
|
'script-history',
|
||||||
|
]);
|
||||||
if (dataDirs.some((dir) => !allowed.has(dir))) {
|
if (dataDirs.some((dir) => !allowed.has(dir))) {
|
||||||
return res.status(400).send({ code: 400, message: t('参数错误') });
|
return res.status(400).send({ code: 400, message: t('参数错误') });
|
||||||
}
|
}
|
||||||
@@ -470,9 +509,13 @@ export default class SystemService {
|
|||||||
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
|
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
|
||||||
}
|
}
|
||||||
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
|
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
|
||||||
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
|
await promisify(execFile)(
|
||||||
|
'tar',
|
||||||
|
['-zcf', config.dataTgzFile, '--', ...dataPaths],
|
||||||
|
{
|
||||||
cwd: path.dirname(config.dataPath),
|
cwd: path.dirname(config.dataPath),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
res.download(config.dataTgzFile);
|
res.download(config.dataTgzFile);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return res.send({ code: 400, message: error.message });
|
return res.send({ code: 400, message: error.message });
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,12 @@ export interface OpenOperation {
|
|||||||
anonymous?: boolean;
|
anonymous?: boolean;
|
||||||
}
|
}
|
||||||
export const openOperations: readonly OpenOperation[] = [
|
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-list', method: 'GET', path: 'crons/views' },
|
||||||
{
|
{
|
||||||
name: 'task view-create',
|
name: 'task view-create',
|
||||||
@@ -478,7 +484,12 @@ export const openOperations: readonly OpenOperation[] = [
|
|||||||
path: 'system/config/dependence-clean',
|
path: 'system/config/dependence-clean',
|
||||||
body: 'json',
|
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 overview', method: 'GET', path: 'dashboard/overview' },
|
||||||
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
|
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
|
||||||
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
|
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
|
||||||
|
|||||||
@@ -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 时不经过这些入口,无法自动纳入通知日志。
|
||||||
+289
-116
File diff suppressed because it is too large
Load Diff
+162
-27
@@ -10,6 +10,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.header import Header
|
from email.header import Header
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
@@ -161,10 +163,11 @@ for k in push_config:
|
|||||||
push_config[k] = v
|
push_config[k] = v
|
||||||
|
|
||||||
|
|
||||||
def bark(title: str, content: str) -> None:
|
def bark(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 bark 推送消息。
|
使用 bark 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("BARK_PUSH"):
|
if not push_config.get("BARK_PUSH"):
|
||||||
return
|
return
|
||||||
print("bark 服务启动")
|
print("bark 服务启动")
|
||||||
@@ -200,22 +203,28 @@ def bark(title: str, content: str) -> None:
|
|||||||
).json()
|
).json()
|
||||||
|
|
||||||
if response["code"] == 200:
|
if response["code"] == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("bark 推送成功!")
|
print("bark 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("bark 推送失败!")
|
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}")
|
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"):
|
if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"):
|
||||||
return
|
return
|
||||||
print("钉钉机器人 服务启动")
|
print("钉钉机器人 服务启动")
|
||||||
@@ -236,15 +245,19 @@ def dingding_bot(title: str, content: str) -> None:
|
|||||||
).json()
|
).json()
|
||||||
|
|
||||||
if not response["errcode"]:
|
if not response["errcode"]:
|
||||||
|
audit_results.append(True)
|
||||||
print("钉钉机器人 推送成功!")
|
print("钉钉机器人 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("钉钉机器人 推送失败!")
|
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"):
|
if not push_config.get("FSKEY"):
|
||||||
return
|
return
|
||||||
print("飞书 服务启动")
|
print("飞书 服务启动")
|
||||||
@@ -268,15 +281,19 @@ def feishu_bot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=json.dumps(data)).json()
|
response = requests.post(url, data=json.dumps(data)).json()
|
||||||
|
|
||||||
if response.get("StatusCode") == 0 or response.get("code") == 0:
|
if response.get("StatusCode") == 0 or response.get("code") == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("飞书 推送成功!")
|
print("飞书 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("飞书 推送失败!错误信息如下:\n", response)
|
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 推送消息。
|
使用 go_cqhttp 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"):
|
if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"):
|
||||||
return
|
return
|
||||||
print("go-cqhttp 服务启动")
|
print("go-cqhttp 服务启动")
|
||||||
@@ -285,15 +302,19 @@ def go_cqhttp(title: str, content: str) -> None:
|
|||||||
response = requests.get(url).json()
|
response = requests.get(url).json()
|
||||||
|
|
||||||
if response["status"] == "ok":
|
if response["status"] == "ok":
|
||||||
|
audit_results.append(True)
|
||||||
print("go-cqhttp 推送成功!")
|
print("go-cqhttp 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("go-cqhttp 推送失败!")
|
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 推送消息。
|
使用 gotify 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"):
|
if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"):
|
||||||
return
|
return
|
||||||
print("gotify 服务启动")
|
print("gotify 服务启动")
|
||||||
@@ -307,15 +328,19 @@ def gotify(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data).json()
|
response = requests.post(url, data=data).json()
|
||||||
|
|
||||||
if response.get("id"):
|
if response.get("id"):
|
||||||
|
audit_results.append(True)
|
||||||
print("gotify 推送成功!")
|
print("gotify 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("gotify 推送失败!")
|
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 推送消息。
|
使用 iGot 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("IGOT_PUSH_KEY"):
|
if not push_config.get("IGOT_PUSH_KEY"):
|
||||||
return
|
return
|
||||||
print("iGot 服务启动")
|
print("iGot 服务启动")
|
||||||
@@ -326,15 +351,19 @@ def iGot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data, headers=headers).json()
|
response = requests.post(url, data=data, headers=headers).json()
|
||||||
|
|
||||||
if response["ret"] == 0:
|
if response["ret"] == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("iGot 推送成功!")
|
print("iGot 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'iGot 推送失败!{response["errMsg"]}')
|
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 推送消息。
|
通过 serverJ 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("PUSH_KEY"):
|
if not push_config.get("PUSH_KEY"):
|
||||||
return
|
return
|
||||||
print("serverJ 服务启动")
|
print("serverJ 服务启动")
|
||||||
@@ -351,15 +380,19 @@ def serverJ(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data).json()
|
response = requests.post(url, data=data).json()
|
||||||
|
|
||||||
if response.get("errno") == 0 or response.get("code") == 0:
|
if response.get("errno") == 0 or response.get("code") == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("serverJ 推送成功!")
|
print("serverJ 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'serverJ 推送失败!错误码:{response["message"]}')
|
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 推送消息
|
通过PushDeer 推送消息
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("DEER_KEY"):
|
if not push_config.get("DEER_KEY"):
|
||||||
return
|
return
|
||||||
print("PushDeer 服务启动")
|
print("PushDeer 服务启动")
|
||||||
@@ -376,15 +409,19 @@ def pushdeer(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data).json()
|
response = requests.post(url, data=data).json()
|
||||||
|
|
||||||
if len(response.get("content").get("result")) > 0:
|
if len(response.get("content").get("result")) > 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("PushDeer 推送成功!")
|
print("PushDeer 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("PushDeer 推送失败!错误信息:", response)
|
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 推送消息
|
通过Chat 推送消息
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"):
|
if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"):
|
||||||
return
|
return
|
||||||
print("chat 服务启动")
|
print("chat 服务启动")
|
||||||
@@ -393,15 +430,19 @@ def chat(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data)
|
response = requests.post(url, data=data)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("Chat 推送成功!")
|
print("Chat 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("Chat 推送失败!错误信息:", response)
|
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 推送消息。
|
通过 pushplus 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("PUSH_PLUS_TOKEN"):
|
if not push_config.get("PUSH_PLUS_TOKEN"):
|
||||||
return
|
return
|
||||||
print("PUSHPLUS 服务启动")
|
print("PUSHPLUS 服务启动")
|
||||||
@@ -424,7 +465,9 @@ def pushplus_bot(title: str, content: str) -> None:
|
|||||||
|
|
||||||
code = response["code"]
|
code = response["code"]
|
||||||
if code == 200:
|
if code == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"])
|
print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"])
|
||||||
|
audit_results.append(True)
|
||||||
print(
|
print(
|
||||||
"注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果"
|
"注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果"
|
||||||
)
|
)
|
||||||
@@ -437,16 +480,20 @@ def pushplus_bot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url_old, data=body, headers=headers).json()
|
response = requests.post(url=url_old, data=body, headers=headers).json()
|
||||||
|
|
||||||
if response["code"] == 200:
|
if response["code"] == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("PUSHPLUS(hxtrip) 推送成功!")
|
print("PUSHPLUS(hxtrip) 推送成功!")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("PUSHPLUS 推送失败!")
|
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"):
|
if not push_config.get("WE_PLUS_BOT_TOKEN"):
|
||||||
return
|
return
|
||||||
print("微加机器人 服务启动")
|
print("微加机器人 服务启动")
|
||||||
@@ -469,15 +516,19 @@ def weplus_bot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, data=body, headers=headers).json()
|
response = requests.post(url=url, data=body, headers=headers).json()
|
||||||
|
|
||||||
if response["code"] == 200:
|
if response["code"] == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("微加机器人 推送成功!")
|
print("微加机器人 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("微加机器人 推送失败!")
|
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 推送消息。
|
使用 qmsg 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"):
|
if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"):
|
||||||
return
|
return
|
||||||
print("qmsg 服务启动")
|
print("qmsg 服务启动")
|
||||||
@@ -487,15 +538,19 @@ def qmsg_bot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, params=payload).json()
|
response = requests.post(url=url, params=payload).json()
|
||||||
|
|
||||||
if response["code"] == 0:
|
if response["code"] == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("qmsg 推送成功!")
|
print("qmsg 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'qmsg 推送失败!{response["reason"]}')
|
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 推送消息。
|
通过 企业微信 APP 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("QYWX_AM"):
|
if not push_config.get("QYWX_AM"):
|
||||||
return
|
return
|
||||||
QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM"))
|
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)
|
response = wx.send_mpnews(title, content, media_id, touser)
|
||||||
|
|
||||||
if response == "ok":
|
if response == "ok":
|
||||||
|
audit_results.append(True)
|
||||||
print("企业微信推送成功!")
|
print("企业微信推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("企业微信推送失败!错误信息如下:\n", response)
|
print("企业微信推送失败!错误信息如下:\n", response)
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
class WeCom:
|
class WeCom:
|
||||||
@@ -588,10 +646,11 @@ class WeCom:
|
|||||||
return respone["errmsg"]
|
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"):
|
if not push_config.get("QYWX_KEY"):
|
||||||
return
|
return
|
||||||
print("企业微信机器人服务启动")
|
print("企业微信机器人服务启动")
|
||||||
@@ -608,15 +667,19 @@ def wecom_bot(title: str, content: str) -> None:
|
|||||||
).json()
|
).json()
|
||||||
|
|
||||||
if response["errcode"] == 0:
|
if response["errcode"] == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("企业微信机器人推送成功!")
|
print("企业微信机器人推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("企业微信机器人推送失败!")
|
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 机器人 推送消息。
|
使用 telegram 机器人 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"):
|
if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"):
|
||||||
return
|
return
|
||||||
print("tg 服务启动")
|
print("tg 服务启动")
|
||||||
@@ -652,15 +715,19 @@ def telegram_bot(title: str, content: str) -> None:
|
|||||||
).json()
|
).json()
|
||||||
|
|
||||||
if response["ok"]:
|
if response["ok"]:
|
||||||
|
audit_results.append(True)
|
||||||
print("tg 推送成功!")
|
print("tg 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("tg 推送失败!")
|
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 (
|
if (
|
||||||
not push_config.get("AIBOTK_KEY")
|
not push_config.get("AIBOTK_KEY")
|
||||||
or not push_config.get("AIBOTK_TYPE")
|
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()
|
response = requests.post(url=url, data=body, headers=headers).json()
|
||||||
print(response)
|
print(response)
|
||||||
if response["code"] == 0:
|
if response["code"] == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("智能微秘书 推送成功!")
|
print("智能微秘书 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'智能微秘书 推送失败!{response["error"]}')
|
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 邮件 推送消息。
|
使用 SMTP 邮件 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if (
|
if (
|
||||||
not push_config.get("SMTP_SERVER")
|
not push_config.get("SMTP_SERVER")
|
||||||
or not push_config.get("SMTP_SSL")
|
or not push_config.get("SMTP_SSL")
|
||||||
@@ -736,15 +807,19 @@ def smtp(title: str, content: str) -> None:
|
|||||||
message.as_bytes(),
|
message.as_bytes(),
|
||||||
)
|
)
|
||||||
smtp_server.close()
|
smtp_server.close()
|
||||||
|
audit_results.append(True)
|
||||||
print("SMTP 邮件 推送成功!")
|
print("SMTP 邮件 推送成功!")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"SMTP 邮件 推送失败!{e}")
|
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 推送消息。
|
使用 PushMe 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("PUSHME_KEY"):
|
if not push_config.get("PUSHME_KEY"):
|
||||||
return
|
return
|
||||||
print("PushMe 服务启动")
|
print("PushMe 服务启动")
|
||||||
@@ -764,15 +839,19 @@ def pushme(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data)
|
response = requests.post(url, data=data)
|
||||||
|
|
||||||
if response.status_code == 200 and response.text == "success":
|
if response.status_code == 200 and response.text == "success":
|
||||||
|
audit_results.append(True)
|
||||||
print("PushMe 推送成功!")
|
print("PushMe 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"PushMe 推送失败!{response.status_code} {response.text}")
|
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 推送消息。
|
使用 CHRONOCAT 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if (
|
if (
|
||||||
not push_config.get("CHRONOCAT_URL")
|
not push_config.get("CHRONOCAT_URL")
|
||||||
or not push_config.get("CHRONOCAT_QQ")
|
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))
|
response = requests.post(url, headers=headers, data=json.dumps(data))
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
if chat_type == 1:
|
if chat_type == 1:
|
||||||
|
audit_results.append(True)
|
||||||
print(f"QQ个人消息:{ids}推送成功!")
|
print(f"QQ个人消息:{ids}推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(True)
|
||||||
print(f"QQ群消息:{ids}推送成功!")
|
print(f"QQ群消息:{ids}推送成功!")
|
||||||
else:
|
else:
|
||||||
if chat_type == 1:
|
if chat_type == 1:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"QQ个人消息:{ids}推送失败!")
|
print(f"QQ个人消息:{ids}推送失败!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"QQ群消息:{ids}推送失败!")
|
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 推送消息
|
通过 Ntfy 推送消息
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
audit_results = []
|
||||||
def encode_rfc2047(text: str) -> str:
|
def encode_rfc2047(text: str) -> str:
|
||||||
"""将文本编码为符合 RFC 2047 标准的格式"""
|
"""将文本编码为符合 RFC 2047 标准的格式"""
|
||||||
encoded_bytes = base64.b64encode(text.encode("utf-8"))
|
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")
|
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
|
||||||
response = requests.post(url, data=data, headers=headers)
|
response = requests.post(url, data=data, headers=headers)
|
||||||
if response.status_code == 200: # 使用 response.status_code 进行检查
|
if response.status_code == 200: # 使用 response.status_code 进行检查
|
||||||
|
audit_results.append(True)
|
||||||
print("Ntfy 推送成功!")
|
print("Ntfy 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("Ntfy 推送失败!错误信息:", response.text)
|
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 推送消息。
|
通过 wxpusher 推送消息。
|
||||||
支持的环境变量:
|
支持的环境变量:
|
||||||
@@ -866,6 +954,7 @@ def wxpusher_bot(title: str, content: str) -> None:
|
|||||||
- WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔
|
- WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔
|
||||||
- WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔
|
- WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("WXPUSHER_APP_TOKEN"):
|
if not push_config.get("WXPUSHER_APP_TOKEN"):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -909,17 +998,21 @@ def wxpusher_bot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, json=data, headers=headers).json()
|
response = requests.post(url=url, json=data, headers=headers).json()
|
||||||
|
|
||||||
if response.get("code") == 1000:
|
if response.get("code") == 1000:
|
||||||
|
audit_results.append(True)
|
||||||
print("wxpusher 推送成功!")
|
print("wxpusher 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"wxpusher 推送失败!错误信息:{response.get('msg')}")
|
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)推送消息。
|
||||||
支持的环境变量:
|
支持的环境变量:
|
||||||
- WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个
|
- WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("WXPUSHER_SPT_LIST"):
|
if not push_config.get("WXPUSHER_SPT_LIST"):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -956,17 +1049,21 @@ def wxpusher_spt(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, json=data, headers=headers).json()
|
response = requests.post(url=url, json=data, headers=headers).json()
|
||||||
|
|
||||||
if response.get("code") == 1000:
|
if response.get("code") == 1000:
|
||||||
|
audit_results.append(True)
|
||||||
print("wxpusher SPT 推送成功!")
|
print("wxpusher SPT 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"wxpusher SPT 推送失败!错误信息:{response.get('msg')}")
|
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 推送消息。
|
通过 WPUSH 推送消息。
|
||||||
官方文档: https://wpush.cn/docs
|
官方文档: https://wpush.cn/docs
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("WPUSH_APIKEY"):
|
if not push_config.get("WPUSH_APIKEY"):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -986,12 +1083,15 @@ def wpush(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, json=data, headers=headers, timeout=15).json()
|
response = requests.post(url=url, json=data, headers=headers, timeout=15).json()
|
||||||
|
|
||||||
if response.get("code") == 0:
|
if response.get("code") == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("WPUSH 推送成功!")
|
print("WPUSH 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'WPUSH 推送失败!错误信息:{response.get("message") or response}')
|
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 推送消息。
|
通过 OpeniLink 推送消息。
|
||||||
支持的环境变量:
|
支持的环境变量:
|
||||||
@@ -999,6 +1099,7 @@ def openilink(title: str, content: str) -> None:
|
|||||||
- OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com
|
- OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com
|
||||||
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取
|
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("OPENILINK_APP_TOKEN"):
|
if not push_config.get("OPENILINK_APP_TOKEN"):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1023,9 +1124,12 @@ def openilink(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, json=data, headers=headers).json()
|
response = requests.post(url=url, json=data, headers=headers).json()
|
||||||
|
|
||||||
if response.get("ok"):
|
if response.get("ok"):
|
||||||
|
audit_results.append(True)
|
||||||
print("OpeniLink 推送成功!")
|
print("OpeniLink 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'OpeniLink 推送失败!错误信息:{response.get("error")}')
|
print(f'OpeniLink 推送失败!错误信息:{response.get("error")}')
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def parse_headers(headers):
|
def parse_headers(headers):
|
||||||
@@ -1079,10 +1183,11 @@ def parse_body(body, content_type, value_format_fn=None):
|
|||||||
return parsed
|
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"):
|
if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1114,9 +1219,12 @@ def custom_notify(title: str, content: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("自定义通知推送成功!")
|
print("自定义通知推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"自定义通知推送失败!{response.status_code} {response.text}")
|
print(f"自定义通知推送失败!{response.status_code} {response.text}")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def one() -> str:
|
def one() -> str:
|
||||||
@@ -1204,6 +1312,33 @@ def add_notify_function():
|
|||||||
return 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):
|
def send(title: str, content: str, ignore_default_config: bool = False, **kwargs):
|
||||||
if kwargs:
|
if kwargs:
|
||||||
global push_config
|
global push_config
|
||||||
@@ -1228,7 +1363,7 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
|
|||||||
|
|
||||||
notify_function = add_notify_function()
|
notify_function = add_notify_function()
|
||||||
ts = [
|
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
|
for mode in notify_function
|
||||||
]
|
]
|
||||||
[t.start() for t in ts]
|
[t.start() for t in ts]
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class GrpcClient {
|
|||||||
'updateEnvNames',
|
'updateEnvNames',
|
||||||
'getEnvById',
|
'getEnvById',
|
||||||
'systemNotify',
|
'systemNotify',
|
||||||
|
'recordNotifyLog',
|
||||||
'getCronDetail',
|
'getCronDetail',
|
||||||
'createCron',
|
'createCron',
|
||||||
'updateCron',
|
'updateCron',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -699,6 +699,13 @@
|
|||||||
"已保存,本次未记录历史版本": "Saved without a history snapshot",
|
"已保存,本次未记录历史版本": "Saved without a history snapshot",
|
||||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
|
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
|
||||||
"保存失败": "Unable to save",
|
"保存失败": "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.",
|
"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"
|
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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文件路径",
|
"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路径"
|
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import SecuritySettings from './security';
|
import SecuritySettings from './security';
|
||||||
import LoginLog from './loginLog';
|
import LoginLog from './loginLog';
|
||||||
|
import NotifyLog from './notifyLog';
|
||||||
import NotificationSetting from './notification';
|
import NotificationSetting from './notification';
|
||||||
import Other from './other';
|
import Other from './other';
|
||||||
import About from './about';
|
import About from './about';
|
||||||
@@ -126,6 +127,7 @@ const Setting = () => {
|
|||||||
const [editedApp, setEditedApp] = useState<any>();
|
const [editedApp, setEditedApp] = useState<any>();
|
||||||
const [tabActiveKey, setTabActiveKey] = useState('security');
|
const [tabActiveKey, setTabActiveKey] = useState('security');
|
||||||
const [loginLogData, setLoginLogData] = useState<any[]>([]);
|
const [loginLogData, setLoginLogData] = useState<any[]>([]);
|
||||||
|
const [notifyLogData, setNotifyLogData] = useState<any[]>([]);
|
||||||
const [notificationInfo, setNotificationInfo] = useState<any>();
|
const [notificationInfo, setNotificationInfo] = useState<any>();
|
||||||
const containergRef = useRef<HTMLDivElement>(null);
|
const containergRef = useRef<HTMLDivElement>(null);
|
||||||
const [height, setHeight] = useState<number>(0);
|
const [height, setHeight] = useState<number>(0);
|
||||||
@@ -254,6 +256,8 @@ const Setting = () => {
|
|||||||
getApps();
|
getApps();
|
||||||
} else if (activeKey === 'login') {
|
} else if (activeKey === 'login') {
|
||||||
getLoginLog();
|
getLoginLog();
|
||||||
|
} else if (activeKey === 'notifylog') {
|
||||||
|
getNotifyLog();
|
||||||
} else if (activeKey === 'notification') {
|
} else if (activeKey === 'notification') {
|
||||||
getNotification();
|
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(() => {
|
useEffect(() => {
|
||||||
if (isDemoEnv) {
|
if (isDemoEnv) {
|
||||||
getApps();
|
getApps();
|
||||||
@@ -345,6 +362,11 @@ const Setting = () => {
|
|||||||
label: intl.get('登录日志'),
|
label: intl.get('登录日志'),
|
||||||
children: <LoginLog height={height} data={loginLogData} />,
|
children: <LoginLog height={height} data={loginLogData} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'notifylog',
|
||||||
|
label: intl.get('通知日志'),
|
||||||
|
children: <NotifyLog height={height} data={notifyLogData} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'client-ip',
|
key: 'client-ip',
|
||||||
label: intl.get('客户端 IP'),
|
label: intl.get('客户端 IP'),
|
||||||
|
|||||||
@@ -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<number, string> = {
|
||||||
|
0: '成功',
|
||||||
|
1: '失败',
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotifyStatusColor: Record<number, string> = {
|
||||||
|
0: 'success',
|
||||||
|
1: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: intl.get('通知来源'),
|
||||||
|
key: 'source',
|
||||||
|
width: 220,
|
||||||
|
render: (_: unknown, record: NotifyLogItem) => (
|
||||||
|
<span>
|
||||||
|
{intl.get(record.source === 'script' ? '脚本通知' : '系统通知')}
|
||||||
|
{record.scriptName ? ` · ${record.scriptName}` : ''}
|
||||||
|
{record.taskId ? ` (#${record.taskId})` : ''}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<Tag color={NotifyStatusColor[statusKey]} style={{ marginRight: 0 }}>
|
||||||
|
{intl.get(NotifyStatusLabel[statusKey])}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const NotifyLog = ({
|
||||||
|
data,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
data: Array<NotifyLogItem>;
|
||||||
|
height: number;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
size="middle"
|
||||||
|
scroll={{ x: 1000, y: height }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotifyLog;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -64,7 +64,7 @@ test('decoded SystemNotify requests reach WPUSH with explicit or saved configura
|
|||||||
'../config/const': constants,
|
'../config/const': constants,
|
||||||
'../config/util': {},
|
'../config/util': {},
|
||||||
'../data/dependence': {},
|
'../data/dependence': {},
|
||||||
'../data/system': {},
|
'../data/system': { AuthDataType: { notifyLog: 'notifyLog' }, NotifyStatus: { success: 0, fail: 1 }, SystemModel: { create: async () => {}, findAll: async () => [] } },
|
||||||
'../shared/pLimit': {},
|
'../shared/pLimit': {},
|
||||||
'../schedule/client': {},
|
'../schedule/client': {},
|
||||||
'../shared/i18n': { t: (x) => x },
|
'../shared/i18n': { t: (x) => x },
|
||||||
@@ -79,7 +79,7 @@ test('decoded SystemNotify requests reach WPUSH with explicit or saved configura
|
|||||||
wpushApiKey: 'saved-key',
|
wpushApiKey: 'saved-key',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const system = new SystemService();
|
const system = new SystemService({ error() {} });
|
||||||
system.notificationService = notification;
|
system.notificationService = notification;
|
||||||
|
|
||||||
for (const info of [
|
for (const info of [
|
||||||
|
|||||||
Reference in New Issue
Block a user