NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1,61 @@
|
||||
// TODO: further refactor in NapCat.Packet v2
|
||||
import { NapProtoMsg, ProtoField, ScalarType } from 'napcat-protobuf';
|
||||
|
||||
const LikeDetail = {
|
||||
txt: ProtoField(1, ScalarType.STRING),
|
||||
uin: ProtoField(3, ScalarType.INT64),
|
||||
nickname: ProtoField(5, ScalarType.STRING),
|
||||
};
|
||||
|
||||
const LikeMsg = {
|
||||
times: ProtoField(1, ScalarType.INT32),
|
||||
time: ProtoField(2, ScalarType.INT32),
|
||||
detail: ProtoField(3, () => LikeDetail),
|
||||
};
|
||||
|
||||
const ProfileLikeSubTip = {
|
||||
msg: ProtoField(14, () => LikeMsg),
|
||||
};
|
||||
|
||||
const ProfileLikeTip = {
|
||||
msgType: ProtoField(1, ScalarType.INT32),
|
||||
subType: ProtoField(2, ScalarType.INT32),
|
||||
content: ProtoField(203, () => ProfileLikeSubTip),
|
||||
};
|
||||
|
||||
const SysMessageHeader = {
|
||||
PeerNumber: ProtoField(1, ScalarType.UINT32),
|
||||
PeerString: ProtoField(2, ScalarType.STRING),
|
||||
Uin: ProtoField(5, ScalarType.UINT32),
|
||||
Uid: ProtoField(6, ScalarType.STRING, true),
|
||||
};
|
||||
|
||||
const SysMessageMsgSpec = {
|
||||
msgType: ProtoField(1, ScalarType.UINT32),
|
||||
subType: ProtoField(2, ScalarType.UINT32),
|
||||
subSubType: ProtoField(3, ScalarType.UINT32),
|
||||
msgSeq: ProtoField(5, ScalarType.UINT32),
|
||||
time: ProtoField(6, ScalarType.UINT32),
|
||||
msgId: ProtoField(12, ScalarType.UINT64),
|
||||
other: ProtoField(13, ScalarType.UINT32),
|
||||
};
|
||||
|
||||
const SysMessageBodyWrapper = {
|
||||
wrappedBody: ProtoField(2, ScalarType.BYTES),
|
||||
};
|
||||
|
||||
const SysMessage = {
|
||||
header: ProtoField(1, () => SysMessageHeader, false, true),
|
||||
msgSpec: ProtoField(2, () => SysMessageMsgSpec, false, true),
|
||||
bodyWrapper: ProtoField(3, () => SysMessageBodyWrapper),
|
||||
};
|
||||
|
||||
export function decodeProfileLikeTip (buffer: Uint8Array) {
|
||||
const msg = new NapProtoMsg(ProfileLikeTip);
|
||||
return msg.decode(buffer);
|
||||
}
|
||||
|
||||
export function decodeSysMessage (buffer: Uint8Array) {
|
||||
const msg = new NapProtoMsg(SysMessage);
|
||||
return msg.decode(buffer);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
|
||||
export async function encodeSilk (filePath: string, temporaryDirectory: string, logger: LogWrapper) {
|
||||
const converted = !(await FFmpegService.isSilk(filePath));
|
||||
const pttPath = converted ? path.join(temporaryDirectory, randomUUID()) : filePath;
|
||||
if (converted) {
|
||||
logger.log(`语音文件${filePath}需要转换成silk`);
|
||||
await FFmpegService.convertToNTSilkTct(filePath, pttPath);
|
||||
}
|
||||
const duration = await FFmpegService.getDuration(pttPath);
|
||||
logger.log('语音文件已就绪:', pttPath, '时长:', duration);
|
||||
return { converted, path: pttPath, duration };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import type { NapCatCore } from '@/napcat-core';
|
||||
import json5 from 'json5';
|
||||
import { TypeCompiler, TypeCheck } from '@sinclair/typebox/compiler';
|
||||
import { Value } from '@sinclair/typebox/value';
|
||||
import { TSchema } from '@sinclair/typebox';
|
||||
|
||||
export abstract class ConfigBase<T> {
|
||||
name: string;
|
||||
core: NapCatCore;
|
||||
configPath: string;
|
||||
configData: T = {} as T;
|
||||
configSchema: TSchema;
|
||||
validate: TypeCheck<TSchema>;
|
||||
|
||||
protected constructor (name: string, core: NapCatCore, configPath: string, ConfigSchema: TSchema) {
|
||||
this.name = name;
|
||||
this.core = core;
|
||||
this.configPath = configPath;
|
||||
this.configSchema = ConfigSchema;
|
||||
this.validate = TypeCompiler.Compile(ConfigSchema);
|
||||
fs.mkdirSync(this.configPath, { recursive: true });
|
||||
this.read();
|
||||
}
|
||||
|
||||
getConfigPath (pathName?: string): string {
|
||||
const filename = pathName ? `${this.name}_${pathName}.json` : `${this.name}.json`;
|
||||
return path.join(this.configPath, filename);
|
||||
}
|
||||
|
||||
read (): T {
|
||||
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||
const defaultConfigPath = this.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
if (fs.existsSync(defaultConfigPath)) {
|
||||
this.configData = this.loadConfig(defaultConfigPath);
|
||||
}
|
||||
this.save();
|
||||
return this.configData;
|
||||
}
|
||||
return this.loadConfig(configPath);
|
||||
}
|
||||
|
||||
private loadConfig (configPath: string): T {
|
||||
try {
|
||||
const newConfigData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
let data: unknown = newConfigData;
|
||||
data = Value.Parse(this.configSchema, data);
|
||||
if (!this.validate.Check(data)) {
|
||||
throw new Error([...this.validate.Errors(data)].map(e => e.message).join(', '));
|
||||
}
|
||||
this.configData = data as T;
|
||||
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
|
||||
return this.configData;
|
||||
} catch (e: unknown) {
|
||||
this.handleError(e, '读取配置文件时发生错误');
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
|
||||
save (newConfigData: T = this.configData): void {
|
||||
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||
let data: unknown = newConfigData;
|
||||
data = Value.Parse(this.configSchema, data);
|
||||
if (!this.validate.Check(data)) {
|
||||
throw new Error([...this.validate.Errors(data)].map(e => e.message).join(', '));
|
||||
}
|
||||
this.configData = data as T;
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2));
|
||||
} catch (e: unknown) {
|
||||
this.handleError(e, `保存配置文件 ${configPath} 时发生错误:`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError (e: unknown, message: string): void {
|
||||
if (e instanceof SyntaxError) {
|
||||
this.core.context.logger.logError('[Core] [Config] 操作配置文件格式错误,请检查配置文件:', e.message);
|
||||
} else {
|
||||
this.core.context.logger.logError(`[Core] [Config] ${message}:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ConfigBase } from '@/napcat-core/helper/config-base';
|
||||
import { NapCatCore } from '@/napcat-core/index';
|
||||
import { Type, Static, TSchema } from '@sinclair/typebox';
|
||||
|
||||
import { Value } from '@sinclair/typebox/value';
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import json5 from 'json5';
|
||||
|
||||
export const BypassOptionsSchema = Type.Object({
|
||||
hook: Type.Boolean({ default: true }),
|
||||
window: Type.Boolean({ default: true }),
|
||||
module: Type.Boolean({ default: true }),
|
||||
process: Type.Boolean({ default: true }),
|
||||
container: Type.Boolean({ default: true }),
|
||||
js: Type.Boolean({ default: true }),
|
||||
});
|
||||
|
||||
export const NapcatConfigSchema = Type.Object({
|
||||
fileLog: Type.Boolean({ default: false }),
|
||||
consoleLog: Type.Boolean({ default: true }),
|
||||
fileLogLevel: Type.String({ default: 'debug' }),
|
||||
consoleLogLevel: Type.String({ default: 'info' }),
|
||||
packetBackend: Type.String({ default: 'auto' }),
|
||||
packetServer: Type.String({ default: '' }),
|
||||
o3HookMode: Type.Number({ default: 0 }),
|
||||
autoTimeSync: Type.Boolean({ default: true }),
|
||||
bypass: Type.Optional(BypassOptionsSchema),
|
||||
});
|
||||
|
||||
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
|
||||
|
||||
/**
|
||||
* 从指定配置目录读取 napcat.json,按 NapcatConfigSchema 校验并填充默认值
|
||||
* 用于登录前(无 NapCatCore 实例时)的早期配置读取
|
||||
*/
|
||||
export function loadNapcatConfig (configPath: string): NapcatConfig {
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
const configFile = path.join(configPath, 'napcat.json');
|
||||
if (fs.existsSync(configFile)) {
|
||||
data = json5.parse(fs.readFileSync(configFile, 'utf-8'));
|
||||
}
|
||||
} catch {
|
||||
// 读取失败时使用 schema 默认值
|
||||
}
|
||||
data = Value.Parse(NapcatConfigSchema, data) as Record<string, unknown>;
|
||||
return data as NapcatConfig;
|
||||
}
|
||||
|
||||
export class NapCatConfigLoader extends ConfigBase<NapcatConfig> {
|
||||
constructor (core: NapCatCore, configPath: string, schema: TSchema) {
|
||||
super('napcat', core, configPath, schema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ListenerNamingMapping, ServiceNamingMapping } from '@/napcat-core/index';
|
||||
|
||||
interface InternalMapKey {
|
||||
timeout: number;
|
||||
createtime: number;
|
||||
func: (...arg: any[]) => any;
|
||||
checker: ((...args: any[]) => boolean) | undefined;
|
||||
}
|
||||
|
||||
type EnsureFunc<T> = T extends (...args: any) => any ? T : never;
|
||||
|
||||
type FuncKeys<T> = Extract<
|
||||
{
|
||||
[K in keyof T]: EnsureFunc<T[K]> extends never ? never : K;
|
||||
}[keyof T],
|
||||
string
|
||||
>;
|
||||
|
||||
export type ListenerClassBase = Record<string, string>;
|
||||
|
||||
export class NTEventWrapper {
|
||||
private readonly WrapperSession: NodeIQQNTWrapperSession | undefined; // WrapperSession
|
||||
private readonly listenerManager: Map<string, ListenerClassBase> = new Map<string, ListenerClassBase>(); // ListenerName-Unique -> Listener实例
|
||||
private readonly EventTask = new Map<string, Map<string, Map<string, InternalMapKey>>>(); // tasks ListenerMainName -> ListenerSubName-> uuid -> {timeout,createtime,func}
|
||||
|
||||
constructor (
|
||||
wrapperSession: NodeIQQNTWrapperSession
|
||||
) {
|
||||
this.WrapperSession = wrapperSession;
|
||||
}
|
||||
|
||||
createProxyDispatch (ListenerMainName: string) {
|
||||
const dispatcherListenerFunc = this.dispatcherListener.bind(this);
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get (target: any, prop: any, receiver: any) {
|
||||
if (typeof target[prop] === 'undefined') {
|
||||
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
|
||||
return (...args: any[]) => {
|
||||
dispatcherListenerFunc(ListenerMainName, prop, ...args).then();
|
||||
};
|
||||
}
|
||||
// 如果方法存在,正常返回
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
createEventFunction<
|
||||
Service extends keyof ServiceNamingMapping,
|
||||
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||
T extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
|
||||
>(eventName: `${Service}/${ServiceMethod}`): T | undefined {
|
||||
const eventNameArr = eventName.split('/');
|
||||
type eventType = {
|
||||
[key: string]: () => { [key: string]: (...params: Parameters<T>) => Promise<ReturnType<T>>; };
|
||||
};
|
||||
if (eventNameArr.length > 1) {
|
||||
const serviceName = 'get' + (eventNameArr[0]?.replace('NodeIKernel', '') ?? '');
|
||||
const eventName = eventNameArr[1];
|
||||
const services = (this.WrapperSession as unknown as eventType)[serviceName]?.();
|
||||
if (!services || !eventName) {
|
||||
return undefined;
|
||||
}
|
||||
let event = services[eventName];
|
||||
|
||||
// 重新绑定this
|
||||
event = event?.bind(services);
|
||||
if (event) {
|
||||
return event as T;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
createListenerFunction<T> (listenerMainName: string, uniqueCode: string = ''): T {
|
||||
const existListener = this.listenerManager.get(listenerMainName + uniqueCode);
|
||||
if (!existListener) {
|
||||
const Listener = this.createProxyDispatch(listenerMainName);
|
||||
const ServiceSubName = /^NodeIKernel(.*?)Listener$/.exec(listenerMainName)![1];
|
||||
const Service = `NodeIKernel${ServiceSubName}Service/addKernel${ServiceSubName}Listener`;
|
||||
|
||||
// @ts-ignore
|
||||
this.createEventFunction(Service)(Listener as T);
|
||||
this.listenerManager.set(listenerMainName + uniqueCode, Listener);
|
||||
return Listener as T;
|
||||
}
|
||||
return existListener as T;
|
||||
}
|
||||
|
||||
// 统一回调清理事件
|
||||
async dispatcherListener (ListenerMainName: string, ListenerSubName: string, ...args: any[]) {
|
||||
this.EventTask.get(ListenerMainName)
|
||||
?.get(ListenerSubName)
|
||||
?.forEach((task, uuid) => {
|
||||
if (task.createtime + task.timeout < Date.now()) {
|
||||
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.delete(uuid);
|
||||
return;
|
||||
}
|
||||
if (task?.checker?.(...args)) {
|
||||
task.func(...args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async callNoListenerEvent<
|
||||
Service extends keyof ServiceNamingMapping,
|
||||
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
|
||||
>(
|
||||
serviceAndMethod: `${Service}/${ServiceMethod}`,
|
||||
...args: Parameters<EventType>
|
||||
): Promise<Awaited<ReturnType<EventType>>> {
|
||||
return (this.createEventFunction(serviceAndMethod))!(...args);
|
||||
}
|
||||
|
||||
async registerListen<
|
||||
Listener extends keyof ListenerNamingMapping,
|
||||
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
|
||||
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
|
||||
>(
|
||||
listenerAndMethod: `${Listener}/${ListenerMethod}`,
|
||||
checker: (...args: Parameters<ListenerType>) => boolean,
|
||||
waitTimes = 1,
|
||||
timeout = 5000
|
||||
) {
|
||||
return new Promise<Parameters<ListenerType>>((resolve, reject) => {
|
||||
const ListenerNameList = listenerAndMethod.split('/');
|
||||
const ListenerMainName = ListenerNameList[0] ?? '';
|
||||
const ListenerSubName = ListenerNameList[1] ?? '';
|
||||
const id = randomUUID();
|
||||
let complete = 0;
|
||||
let retData: Parameters<ListenerType> | undefined;
|
||||
|
||||
function sendDataCallback () {
|
||||
if (complete === 0) {
|
||||
reject(new Error(' ListenerName:' + listenerAndMethod + ' timeout'));
|
||||
} else {
|
||||
resolve(retData!);
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutRef = setTimeout(sendDataCallback, timeout);
|
||||
const eventCallback = {
|
||||
timeout,
|
||||
createtime: Date.now(),
|
||||
checker,
|
||||
func: (...args: Parameters<ListenerType>) => {
|
||||
complete++;
|
||||
retData = args;
|
||||
if (complete >= waitTimes) {
|
||||
clearTimeout(timeoutRef);
|
||||
sendDataCallback();
|
||||
}
|
||||
},
|
||||
};
|
||||
if (!this.EventTask.get(ListenerMainName)) {
|
||||
this.EventTask.set(ListenerMainName, new Map());
|
||||
}
|
||||
if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) {
|
||||
this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map());
|
||||
}
|
||||
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback);
|
||||
this.createListenerFunction(ListenerMainName);
|
||||
});
|
||||
}
|
||||
|
||||
async callNormalEventV2<
|
||||
Service extends keyof ServiceNamingMapping,
|
||||
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||
Listener extends keyof ListenerNamingMapping,
|
||||
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
|
||||
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>,
|
||||
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
|
||||
>(
|
||||
serviceAndMethod: `${Service}/${ServiceMethod}`,
|
||||
listenerAndMethod: `${Listener}/${ListenerMethod}`,
|
||||
args: Parameters<EventType>,
|
||||
checkerEvent: (ret: Awaited<ReturnType<EventType>>) => boolean = () => true,
|
||||
checkerListener: (...args: Parameters<ListenerType>) => boolean = () => true,
|
||||
callbackTimesToWait = 1,
|
||||
timeout = 5000
|
||||
) {
|
||||
const id = randomUUID();
|
||||
let complete = 0;
|
||||
let retData: Parameters<ListenerType> | undefined;
|
||||
let retEvent: any = {};
|
||||
|
||||
function sendDataCallback (resolve: any, reject: any) {
|
||||
if (complete === 0) {
|
||||
reject(
|
||||
new Error(
|
||||
'Timeout: NTEvent serviceAndMethod:' +
|
||||
serviceAndMethod +
|
||||
' ListenerName:' +
|
||||
listenerAndMethod +
|
||||
' EventRet:\n' +
|
||||
JSON.stringify(retEvent, null, 4) +
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
resolve([retEvent as Awaited<ReturnType<EventType>>, ...retData!]);
|
||||
}
|
||||
}
|
||||
|
||||
const ListenerNameList = listenerAndMethod.split('/');
|
||||
const ListenerMainName = ListenerNameList[0] ?? '';
|
||||
const ListenerSubName = ListenerNameList[1] ?? '';
|
||||
|
||||
return new Promise<[EventRet: Awaited<ReturnType<EventType>>, ...Parameters<ListenerType>]>(
|
||||
(resolve, reject) => {
|
||||
const timeoutRef = setTimeout(() => sendDataCallback(resolve, reject), timeout);
|
||||
|
||||
const eventCallback = {
|
||||
timeout,
|
||||
createtime: Date.now(),
|
||||
checker: checkerListener,
|
||||
func: (...args: any[]) => {
|
||||
complete++;
|
||||
retData = args as Parameters<ListenerType>;
|
||||
if (complete >= callbackTimesToWait) {
|
||||
clearTimeout(timeoutRef);
|
||||
sendDataCallback(resolve, reject);
|
||||
}
|
||||
},
|
||||
};
|
||||
if (!this.EventTask.get(ListenerMainName)) {
|
||||
this.EventTask.set(ListenerMainName, new Map());
|
||||
}
|
||||
if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) {
|
||||
this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map());
|
||||
}
|
||||
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback);
|
||||
this.createListenerFunction(ListenerMainName);
|
||||
|
||||
const eventResult = this.createEventFunction(serviceAndMethod)!(...(args));
|
||||
|
||||
const eventRetHandle = (eventData: any) => {
|
||||
retEvent = eventData;
|
||||
if (!checkerEvent(retEvent) && timeoutRef.hasRef()) {
|
||||
clearTimeout(timeoutRef);
|
||||
reject(
|
||||
new Error(
|
||||
'EventChecker Failed: NTEvent serviceAndMethod:' +
|
||||
serviceAndMethod +
|
||||
' ListenerName:' +
|
||||
listenerAndMethod +
|
||||
' EventRet:\n' +
|
||||
JSON.stringify(retEvent, null, 4) +
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
if (eventResult instanceof Promise) {
|
||||
eventResult.then((eventResult: any) => {
|
||||
eventRetHandle(eventResult);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
eventRetHandle(eventResult);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// 更正导入语句
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as https from 'https';
|
||||
import * as os from 'os';
|
||||
import * as compressing from 'compressing'; // 修正导入方式
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
|
||||
const downloadOri = 'https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip';
|
||||
const urls = [
|
||||
'https://j.1win.ggff.net/' + downloadOri,
|
||||
'https://git.yylx.win/' + downloadOri,
|
||||
'https://ghfile.geekertao.top/' + downloadOri,
|
||||
'https://gh-proxy.net/' + downloadOri,
|
||||
'https://ghm.078465.xyz/' + downloadOri,
|
||||
'https://gitproxy.127731.xyz/' + downloadOri,
|
||||
'https://jiashu.1win.eu.org/' + downloadOri,
|
||||
'https://github.tbedu.top/' + downloadOri,
|
||||
downloadOri,
|
||||
];
|
||||
|
||||
/**
|
||||
* 测试URL是否可用
|
||||
* @param url 待测试的URL
|
||||
* @returns 如果URL可访问返回true,否则返回false
|
||||
*/
|
||||
async function testUrl (url: string): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const req = https.get(url, { timeout: 5000 }, (res) => {
|
||||
// 检查状态码是否表示成功
|
||||
const statusCode = res.statusCode || 0;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
// 终止请求并返回true
|
||||
req.destroy();
|
||||
resolve(true);
|
||||
} else {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找第一个可用的URL
|
||||
* @returns 返回第一个可用的URL,如果都不可用则返回null
|
||||
*/
|
||||
async function findAvailableUrl (): Promise<string | null> {
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const available = await testUrl(url);
|
||||
if (available) {
|
||||
return url;
|
||||
}
|
||||
} catch (_error) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 下载文件
|
||||
* @param url 下载URL
|
||||
* @param destPath 目标保存路径
|
||||
* @returns 成功返回true,失败返回false
|
||||
*/
|
||||
async function downloadFile (url: string, destPath: string, progressCallback?: (percent: number) => void): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const req = https.get(url, (res) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
// 获取文件总大小
|
||||
const totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
||||
let downloadedSize = 0;
|
||||
let lastReportedPercent = -1; // 上次报告的百分比
|
||||
let lastReportTime = 0; // 上次报告的时间戳
|
||||
|
||||
// 如果有内容长度和进度回调,则添加数据监听
|
||||
if (totalSize > 0 && progressCallback) {
|
||||
// 初始报告 0%
|
||||
progressCallback(0);
|
||||
lastReportTime = Date.now();
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
const currentPercent = Math.floor((downloadedSize / totalSize) * 100);
|
||||
const now = Date.now();
|
||||
|
||||
// 只在以下条件触发回调:
|
||||
// 1. 百分比变化至少为1%
|
||||
// 2. 距离上次报告至少500毫秒
|
||||
// 3. 确保报告100%完成
|
||||
if ((currentPercent !== lastReportedPercent &&
|
||||
(currentPercent - lastReportedPercent >= 1 || currentPercent === 100)) &&
|
||||
(now - lastReportTime >= 1000 || currentPercent === 100)) {
|
||||
progressCallback(currentPercent);
|
||||
lastReportedPercent = currentPercent;
|
||||
lastReportTime = now;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pipeline(res, file)
|
||||
.then(() => {
|
||||
// 确保最后报告100%
|
||||
if (progressCallback && lastReportedPercent !== 100) {
|
||||
progressCallback(100);
|
||||
}
|
||||
resolve(true);
|
||||
})
|
||||
.catch(() => resolve(false));
|
||||
} else {
|
||||
file.close();
|
||||
fs.unlink(destPath, () => { });
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
file.close();
|
||||
fs.unlink(destPath, () => { });
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压缩zip文件中的特定内容
|
||||
* 只解压bin目录中的文件到目标目录
|
||||
* @param zipPath 压缩文件路径
|
||||
* @param extractDir 解压目标路径
|
||||
*/
|
||||
async function extractBinDirectory (zipPath: string, extractDir: string): Promise<void> {
|
||||
// 确保目标目录存在
|
||||
if (!fs.existsSync(extractDir)) {
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 解压文件
|
||||
const zipStream = new compressing.zip.UncompressStream({ source: zipPath });
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// 监听条目事件
|
||||
zipStream.on('entry', (header, stream, next) => {
|
||||
// 获取文件路径
|
||||
const filePath = header.name;
|
||||
|
||||
// 匹配内层bin目录中的文件
|
||||
// 例如:ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1/bin/ffmpeg.exe
|
||||
if (filePath.includes('/bin/') && filePath.endsWith('.exe')) {
|
||||
// 提取文件名
|
||||
const fileName = path.basename(filePath);
|
||||
const targetPath = path.join(extractDir, fileName);
|
||||
|
||||
// 创建写入流
|
||||
const writeStream = fs.createWriteStream(targetPath);
|
||||
|
||||
// 将流管道连接到文件
|
||||
stream.pipe(writeStream);
|
||||
|
||||
// 监听写入完成事件
|
||||
writeStream.on('finish', () => {
|
||||
next();
|
||||
});
|
||||
|
||||
writeStream.on('error', () => {
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
// 跳过不需要的文件
|
||||
stream.resume();
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
zipStream.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
zipStream.on('finish', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并设置FFmpeg
|
||||
* @param destDir 目标安装目录,默认为用户临时目录下的ffmpeg文件夹
|
||||
* @param tempDir 临时文件目录,默认为系统临时目录
|
||||
* @returns 返回ffmpeg可执行文件的路径,如果失败则返回null
|
||||
*/
|
||||
export async function downloadFFmpeg (
|
||||
destDir?: string,
|
||||
tempDir?: string,
|
||||
progressCallback?: (percent: number, stage: string) => void
|
||||
): Promise<string | null> {
|
||||
// 仅限Windows
|
||||
if (os.platform() !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const destinationDir = destDir || path.join(os.tmpdir(), 'ffmpeg');
|
||||
const tempDirectory = tempDir || os.tmpdir();
|
||||
const zipFilePath = path.join(tempDirectory, 'ffmpeg.zip'); // 临时下载到指定临时目录
|
||||
const ffmpegExePath = path.join(destinationDir, 'ffmpeg.exe');
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(destinationDir)) {
|
||||
fs.mkdirSync(destinationDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(tempDirectory)) {
|
||||
fs.mkdirSync(tempDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
// 如果ffmpeg已经存在,直接返回路径
|
||||
if (fs.existsSync(ffmpegExePath)) {
|
||||
if (progressCallback) progressCallback(100, '已找到FFmpeg');
|
||||
return ffmpegExePath;
|
||||
}
|
||||
|
||||
// 查找可用URL
|
||||
if (progressCallback) progressCallback(0, '查找可用下载源');
|
||||
const availableUrl = await findAvailableUrl();
|
||||
if (!availableUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
if (progressCallback) progressCallback(5, '开始下载FFmpeg');
|
||||
const downloaded = await downloadFile(
|
||||
availableUrl,
|
||||
zipFilePath,
|
||||
(percent) => {
|
||||
// 下载占总进度的70%
|
||||
if (progressCallback) progressCallback(5 + Math.floor(percent * 0.7), '下载FFmpeg');
|
||||
}
|
||||
);
|
||||
|
||||
if (!downloaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 直接解压bin目录文件到目标目录
|
||||
if (progressCallback) progressCallback(75, '解压FFmpeg');
|
||||
await extractBinDirectory(zipFilePath, destinationDir);
|
||||
|
||||
// 清理下载文件
|
||||
if (progressCallback) progressCallback(95, '清理临时文件');
|
||||
try {
|
||||
fs.unlinkSync(zipFilePath);
|
||||
} catch (_err) {
|
||||
// 忽略清理临时文件失败的错误
|
||||
}
|
||||
|
||||
// 检查ffmpeg.exe是否成功解压
|
||||
if (fs.existsSync(ffmpegExePath)) {
|
||||
if (progressCallback) progressCallback(100, 'FFmpeg安装完成');
|
||||
return ffmpegExePath;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查系统PATH环境变量中是否存在指定可执行文件
|
||||
* @param executable 可执行文件名
|
||||
* @returns 如果找到返回完整路径,否则返回null
|
||||
*/
|
||||
function findExecutableInPath (executable: string): string | null {
|
||||
// 仅适用于Windows系统
|
||||
if (os.platform() !== 'win32') return null;
|
||||
|
||||
// 获取PATH环境变量
|
||||
const pathEnv = process.env['PATH'] || '';
|
||||
const pathDirs = pathEnv.split(';');
|
||||
|
||||
// 检查每个目录
|
||||
for (const dir of pathDirs) {
|
||||
if (!dir) continue;
|
||||
try {
|
||||
const filePath = path.join(dir, executable);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
} catch (_error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function downloadFFmpegIfNotExists (log: LogWrapper) {
|
||||
// 仅限Windows
|
||||
if (os.platform() !== 'win32') {
|
||||
return {
|
||||
path: null,
|
||||
reset: false,
|
||||
};
|
||||
}
|
||||
const ffmpegInPath = findExecutableInPath('ffmpeg.exe');
|
||||
const ffprobeInPath = findExecutableInPath('ffprobe.exe');
|
||||
|
||||
if (ffmpegInPath && ffprobeInPath) {
|
||||
const ffmpegDir = path.dirname(ffmpegInPath);
|
||||
return {
|
||||
path: ffmpegDir,
|
||||
reset: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 如果环境变量中没有,检查项目目录中是否存在
|
||||
const currentPath = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ffmpeg_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffmpeg.exe'));
|
||||
const ffprobe_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffprobe.exe'));
|
||||
|
||||
if (!ffmpeg_exist || !ffprobe_exist) {
|
||||
const url = await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => {
|
||||
log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`);
|
||||
});
|
||||
if (!url) {
|
||||
log.log('[FFmpeg] [Error] 下载FFmpeg失败');
|
||||
return {
|
||||
path: null,
|
||||
reset: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: path.join(currentPath, 'ffmpeg'),
|
||||
reset: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: path.join(currentPath, 'ffmpeg'),
|
||||
reset: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* FFmpeg Adapter Factory
|
||||
* 自动检测并选择最佳的 FFmpeg 适配器
|
||||
*/
|
||||
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { FFmpegAddonAdapter, getAddonPath } from './ffmpeg-addon-adapter';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { FFmpegExecAdapter } from './ffmpeg-exec-adapter';
|
||||
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
|
||||
|
||||
/**
|
||||
* FFmpeg 适配器工厂
|
||||
*/
|
||||
export class FFmpegAdapterFactory {
|
||||
private static instance: IFFmpegAdapter | null = null;
|
||||
private static initPromise: Promise<IFFmpegAdapter> | null = null;
|
||||
|
||||
/**
|
||||
* 初始化并获取最佳的 FFmpeg 适配器
|
||||
* @param logger 日志记录器
|
||||
* @param ffmpegPath FFmpeg 可执行文件路径(用于 Exec 适配器)
|
||||
* @param ffprobePath FFprobe 可执行文件路径(用于 Exec 适配器)
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath,用于 Addon 适配器)
|
||||
*/
|
||||
static async getAdapter (
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string = 'ffmpeg',
|
||||
ffprobePath: string = 'ffprobe',
|
||||
binaryPath?: string
|
||||
): Promise<IFFmpegAdapter> {
|
||||
// 如果已经初始化,直接返回
|
||||
if (this.instance) {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// 如果正在初始化,等待初始化完成
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// 开始初始化
|
||||
this.initPromise = this.initialize(logger, ffmpegPath, ffprobePath, binaryPath);
|
||||
|
||||
try {
|
||||
this.instance = await this.initPromise;
|
||||
return this.instance;
|
||||
} finally {
|
||||
this.initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化适配器
|
||||
*/
|
||||
private static async initialize (
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string,
|
||||
ffprobePath: string,
|
||||
binaryPath?: string
|
||||
): Promise<IFFmpegAdapter> {
|
||||
const useCommandLine = Boolean(process.env['FFMPEG_PATH'] || process.env['FFPROBE_PATH']);
|
||||
if (binaryPath && !useCommandLine && existsSync(getAddonPath(binaryPath))) {
|
||||
const addonAdapter = new FFmpegAddonAdapter(binaryPath);
|
||||
|
||||
logger.log('[FFmpeg] 检查 Native Addon 可用性...');
|
||||
if (await addonAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] ✓ 使用 Native Addon 适配器');
|
||||
return addonAdapter;
|
||||
}
|
||||
|
||||
throw new Error('FFmpeg Native Addon 加载失败: ' + getAddonPath(binaryPath));
|
||||
}
|
||||
|
||||
const execAdapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath);
|
||||
|
||||
logger.log(`[FFmpeg] 检查命令行工具可用性: ${ffmpegPath}`);
|
||||
if (await execAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] 使用命令行工具适配器 ✓');
|
||||
return execAdapter;
|
||||
}
|
||||
|
||||
throw new Error('FFmpeg 命令行工具不可用,请检查 ffmpeg 和 ffprobe 路径: ' + ffmpegPath + ', ' + ffprobePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置适配器(用于测试或重新初始化)
|
||||
*/
|
||||
static reset (): void {
|
||||
this.instance = null;
|
||||
this.initPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 FFmpeg 路径并重新初始化
|
||||
* @param logger 日志记录器
|
||||
* @param ffmpegPath FFmpeg 可执行文件路径
|
||||
* @param ffprobePath FFprobe 可执行文件路径
|
||||
*/
|
||||
static async updateFFmpegPath (
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string,
|
||||
ffprobePath: string
|
||||
): Promise<IFFmpegAdapter> {
|
||||
const adapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath);
|
||||
if (!await adapter.isAvailable()) {
|
||||
throw new Error('FFmpeg 路径不可用: ' + ffmpegPath + ', ' + ffprobePath);
|
||||
}
|
||||
this.instance = adapter;
|
||||
logger.log('[FFmpeg] 已更新命令行工具路径:', ffmpegPath, ffprobePath);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前适配器(不初始化)
|
||||
*/
|
||||
static getCurrentAdapter (): IFFmpegAdapter | null {
|
||||
return this.instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* FFmpeg Adapter Interface
|
||||
* 定义统一的 FFmpeg 操作接口,支持多种实现方式
|
||||
*/
|
||||
|
||||
/**
|
||||
* 视频信息结果
|
||||
*/
|
||||
export interface VideoInfoResult {
|
||||
/** 视频宽度(像素) */
|
||||
width: number;
|
||||
/** 视频高度(像素) */
|
||||
height: number;
|
||||
/** 视频时长(秒) */
|
||||
duration: number;
|
||||
/** 容器格式 */
|
||||
format: string;
|
||||
/** 缩略图 Buffer */
|
||||
thumbnail?: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg 适配器接口
|
||||
*/
|
||||
export interface IFFmpegAdapter {
|
||||
/** 适配器名称 */
|
||||
readonly name: string;
|
||||
|
||||
/** 是否可用 */
|
||||
isAvailable (): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 获取视频信息(包含缩略图)
|
||||
* @param videoPath 视频文件路径
|
||||
* @returns 视频信息
|
||||
*/
|
||||
getVideoInfo (videoPath: string): Promise<VideoInfoResult>;
|
||||
|
||||
/**
|
||||
* 获取音视频文件时长
|
||||
* @param filePath 文件路径
|
||||
* @returns 时长(秒)
|
||||
*/
|
||||
getDuration (filePath: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* 判断是否为 Silk 格式
|
||||
* @param filePath 文件路径
|
||||
*/
|
||||
isSilk (filePath: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 转换音频为 PCM 格式
|
||||
* @param filePath 输入文件路径
|
||||
* @param pcmPath 输出 PCM 文件路径
|
||||
* @returns PCM 数据 Buffer
|
||||
*/
|
||||
convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }>;
|
||||
|
||||
/**
|
||||
* 转换音频文件
|
||||
* @param inputFile 输入文件路径
|
||||
* @param outputFile 输出文件路径
|
||||
* @param format 目标格式 ('amr' | 'silk' 等)
|
||||
*/
|
||||
convertFile (inputFile: string, outputFile: string, format: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 提取视频缩略图
|
||||
* @param videoPath 视频文件路径
|
||||
* @param thumbnailPath 缩略图输出路径
|
||||
*/
|
||||
extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void>;
|
||||
|
||||
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* FFmpeg Native Addon Adapter
|
||||
* 使用原生 Node.js Addon 实现的 FFmpeg 适配器
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { openSync, readSync, closeSync } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import type { FFmpeg } from './ffmpeg-addon';
|
||||
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
|
||||
import { dlopen } from 'node:process';
|
||||
|
||||
/**
|
||||
* 获取 Native Addon 路径
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
|
||||
*/
|
||||
export function getAddonPath (binaryPath: string): string {
|
||||
const addonFileName: string = process.platform + '.' + process.arch;
|
||||
const addonPath = path.join(binaryPath, './native/ffmpeg/', `ffmpegAddon.${addonFileName}.node`);
|
||||
return addonPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg Native Addon 适配器实现
|
||||
*/
|
||||
export class FFmpegAddonAdapter implements IFFmpegAdapter {
|
||||
public readonly name = 'FFmpegAddon';
|
||||
private addon: FFmpeg | null = null;
|
||||
private binaryPath: string;
|
||||
|
||||
constructor (binaryPath: string) {
|
||||
this.binaryPath = binaryPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Addon 是否可用
|
||||
*/
|
||||
async isAvailable (): Promise<boolean> {
|
||||
try {
|
||||
const temp_addon = { exports: {} };
|
||||
dlopen(temp_addon, getAddonPath(this.binaryPath));
|
||||
this.addon = temp_addon.exports as FFmpeg;
|
||||
return this.addon !== null;
|
||||
} catch (error) {
|
||||
console.log('[FFmpegAddonAdapter] Failed to load addon:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAddon (): FFmpeg {
|
||||
if (!this.addon) {
|
||||
throw new Error('FFmpeg Addon is not available');
|
||||
}
|
||||
return this.addon;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频信息
|
||||
*/
|
||||
async getVideoInfo (videoPath: string): Promise<VideoInfoResult> {
|
||||
const addon = this.ensureAddon();
|
||||
const info = await addon.getVideoInfo(videoPath);
|
||||
|
||||
const format = info.format.includes(',') ? info.format.split(',')[0] ?? info.format : info.format;
|
||||
console.log('[FFmpegAddonAdapter] Detected format:', format);
|
||||
return {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
duration: info.duration,
|
||||
format,
|
||||
thumbnail: info.image,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时长
|
||||
*/
|
||||
async getDuration (filePath: string): Promise<number> {
|
||||
const addon = this.ensureAddon();
|
||||
return addon.getDuration(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Silk 格式
|
||||
*/
|
||||
async isSilk (filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const fd = openSync(filePath, 'r');
|
||||
const buffer = Buffer.alloc(10);
|
||||
readSync(fd, buffer, 0, 10, 0);
|
||||
closeSync(fd);
|
||||
const header = buffer.toString();
|
||||
return header.includes('#!SILK') || header.includes('\x02#!SILK');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PCM
|
||||
*/
|
||||
async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
|
||||
const addon = this.ensureAddon();
|
||||
const result = await addon.decodeAudioToPCM(filePath, pcmPath, 24000);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换文件
|
||||
*/
|
||||
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
console.log('[FFmpegAddonAdapter] Converting file:', inputFile, 'to', outputFile, 'as', format);
|
||||
await addon.decodeAudioToFmt(inputFile, outputFile, format);
|
||||
}
|
||||
|
||||
async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
await addon.convertToNTSilkTct(inputFile, outputFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取缩略图
|
||||
*/
|
||||
async extractThumbnail (videoPath: string, thumbnailPath: string, format: 'jpg' | 'png' = 'jpg'): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
const info = await addon.getVideoInfo(videoPath, format);
|
||||
|
||||
// 将缩略图写入文件
|
||||
await writeFile(thumbnailPath, info.image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* FFmpeg Node.js Native Addon Type Definitions
|
||||
*
|
||||
* This addon provides FFmpeg functionality for Node.js including:
|
||||
* - Video information extraction with thumbnail generation
|
||||
* - Audio/Video duration detection
|
||||
* - Audio format conversion to NTSILK
|
||||
* - Audio decoding to PCM
|
||||
*/
|
||||
|
||||
/**
|
||||
* Video information result object
|
||||
*/
|
||||
export interface VideoInfo {
|
||||
/** Video width in pixels */
|
||||
width: number;
|
||||
|
||||
/** Video height in pixels */
|
||||
height: number;
|
||||
|
||||
/** Video duration in seconds */
|
||||
duration: number;
|
||||
|
||||
/** Generated thumbnail image format */
|
||||
format: string;
|
||||
|
||||
/** Video codec name (e.g., "h264", "hevc", "vp9") */
|
||||
videoCodec: string;
|
||||
|
||||
/** First frame thumbnail as JPEG image buffer */
|
||||
image: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio PCM decoding result object
|
||||
*/
|
||||
export interface AudioPCMResult {
|
||||
/** PCM audio data as 16-bit signed integer samples */
|
||||
pcm: Buffer;
|
||||
|
||||
/** Sample rate in Hz (e.g., 44100, 48000, 24000) */
|
||||
sampleRate: number;
|
||||
|
||||
/** Number of audio channels (1 for mono, 2 for stereo) */
|
||||
channels: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg interface providing all audio/video processing methods
|
||||
*/
|
||||
export interface FFmpeg {
|
||||
convertFile (inputFile: string, outputFile: string, format: string): Promise<{ success: boolean; }>;
|
||||
/**
|
||||
* Get video information including resolution, duration, format, codec and first frame thumbnail
|
||||
*/
|
||||
getVideoInfo (filePath: string, format?: 'jpg' | 'png'): Promise<VideoInfo>;
|
||||
|
||||
/**
|
||||
* Get duration of audio or video file in seconds
|
||||
*/
|
||||
getDuration (filePath: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Convert audio file to NTSILK format (WeChat voice message format)
|
||||
*/
|
||||
convertToNTSilkTct (inputPath: string, outputPath: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Decode audio file to raw PCM data
|
||||
*/
|
||||
decodeAudioToPCM (filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean, sampleRate: number; }>;
|
||||
decodeAudioToFmt (filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string; }>;
|
||||
|
||||
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* FFmpeg Exec Adapter
|
||||
* 使用 execFile 调用 FFmpeg 命令行工具的适配器实现
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, openSync, readSync, closeSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
function ensureDirExists (filePath: string): void {
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg 命令行适配器实现
|
||||
*/
|
||||
export class FFmpegExecAdapter implements IFFmpegAdapter {
|
||||
public readonly name = 'FFmpegExec';
|
||||
|
||||
constructor (
|
||||
private ffmpegPath: string = 'ffmpeg',
|
||||
private ffprobePath: string = 'ffprobe'
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 检查 FFmpeg 和 FFprobe 是否可用
|
||||
*/
|
||||
async isAvailable (): Promise<boolean> {
|
||||
// 首先检查当前路径
|
||||
try {
|
||||
await Promise.all([
|
||||
execFileAsync(this.ffmpegPath, ['-version'], { timeout: 10000, windowsHide: true }),
|
||||
execFileAsync(this.ffprobePath, ['-version'], { timeout: 10000, windowsHide: true }),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 FFmpeg 路径
|
||||
*/
|
||||
setFFmpegPath (ffmpegPath: string): void {
|
||||
this.ffmpegPath = ffmpegPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 FFprobe 路径
|
||||
*/
|
||||
setFFprobePath (ffprobePath: string): void {
|
||||
this.ffprobePath = ffprobePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频信息
|
||||
*/
|
||||
async getVideoInfo (videoPath: string): Promise<VideoInfoResult> {
|
||||
const [probe, frame] = await Promise.all([
|
||||
execFileAsync(this.ffprobePath, [
|
||||
'-v', 'error', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=width,height:format=duration,format_name',
|
||||
'-of', 'json', videoPath,
|
||||
], { timeout: 30000, windowsHide: true }),
|
||||
execFileAsync(this.ffmpegPath, [
|
||||
'-v', 'error', '-i', videoPath, '-frames:v', '1',
|
||||
'-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1',
|
||||
], { encoding: 'buffer', maxBuffer: 16 * 1024 * 1024, timeout: 30000, windowsHide: true }),
|
||||
]);
|
||||
const metadata = JSON.parse(probe.stdout);
|
||||
const stream = metadata.streams[0];
|
||||
const duration = Number(metadata.format.duration);
|
||||
if (!stream || !Number.isFinite(duration) || duration < 0 || frame.stdout.length === 0) {
|
||||
throw new Error('FFmpeg 无法读取视频信息或首帧: ' + videoPath);
|
||||
}
|
||||
return {
|
||||
width: stream.width,
|
||||
height: stream.height,
|
||||
duration,
|
||||
format: metadata.format.format_name,
|
||||
thumbnail: frame.stdout,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时长
|
||||
*/
|
||||
async getDuration (filePath: string): Promise<number> {
|
||||
const { stdout } = await execFileAsync(this.ffprobePath, [
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'default=noprint_wrappers=1:nokey=1',
|
||||
filePath,
|
||||
], { timeout: 30000, windowsHide: true });
|
||||
|
||||
const duration = parseFloat(stdout.trim());
|
||||
if (!Number.isFinite(duration) || duration < 0) {
|
||||
throw new Error(`ffprobe 返回了无效的时长值: "${stdout.trim()}"`);
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Silk 格式
|
||||
*/
|
||||
async isSilk (filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const fd = openSync(filePath, 'r');
|
||||
const buffer = Buffer.alloc(10);
|
||||
readSync(fd, buffer, 0, 10, 0);
|
||||
closeSync(fd);
|
||||
const header = buffer.toString();
|
||||
return header.includes('#!SILK') || header.includes('\x02#!SILK');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PCM
|
||||
*/
|
||||
async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
|
||||
try {
|
||||
ensureDirExists(pcmPath);
|
||||
|
||||
await execFileAsync(this.ffmpegPath, [
|
||||
'-y',
|
||||
'-i', filePath,
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-f', 's16le',
|
||||
pcmPath,
|
||||
], { timeout: 120000, windowsHide: true });
|
||||
|
||||
if (!existsSync(pcmPath)) {
|
||||
throw new Error('转换PCM失败,输出文件不存在');
|
||||
}
|
||||
|
||||
return { result: true, sampleRate: 24000 };
|
||||
} catch (error: any) {
|
||||
throw new Error(`FFmpeg处理转换出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换文件
|
||||
*/
|
||||
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
try {
|
||||
ensureDirExists(outputFile);
|
||||
|
||||
const params = format === 'amr'
|
||||
? [
|
||||
'-i', inputFile,
|
||||
'-vn', '-ac', '1',
|
||||
'-ar', '8000',
|
||||
'-b:a', '12.2k',
|
||||
'-y',
|
||||
outputFile,
|
||||
]
|
||||
: [
|
||||
'-i', inputFile,
|
||||
'-vn', '-ac', '1',
|
||||
...(format === 'spx' ? ['-ar', '32000'] : []),
|
||||
'-y',
|
||||
outputFile,
|
||||
];
|
||||
|
||||
await execFileAsync(this.ffmpegPath, params, { timeout: 120000, windowsHide: true });
|
||||
|
||||
if (!existsSync(outputFile)) {
|
||||
throw new Error('转换失败,输出文件不存在');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting file:', error);
|
||||
throw new Error(`文件转换失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取缩略图
|
||||
*/
|
||||
async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
try {
|
||||
ensureDirExists(thumbnailPath);
|
||||
|
||||
const { stderr } = await execFileAsync(this.ffmpegPath, [
|
||||
'-i', videoPath,
|
||||
'-vframes', '1',
|
||||
'-y', // 覆盖输出文件
|
||||
thumbnailPath,
|
||||
], { timeout: 30000, windowsHide: true });
|
||||
|
||||
if (!existsSync(thumbnailPath)) {
|
||||
throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting thumbnail:', error);
|
||||
throw new Error(`提取缩略图失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
|
||||
ensureDirExists(outputFile);
|
||||
await execFileAsync(this.ffmpegPath, [
|
||||
'-v', 'error', '-y', '-i', inputFile, '-vn', '-ac', '1', '-ar', '24000',
|
||||
'-c:a', 'ntsilk_s16le', '-f', 'ntsilk_s16le', outputFile,
|
||||
], { timeout: 120000, windowsHide: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { statSync, existsSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import type { VideoInfo } from './video';
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { FFmpegAdapterFactory } from './ffmpeg-adapter-factory';
|
||||
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
|
||||
import { FFmpegExecAdapter } from './ffmpeg-exec-adapter';
|
||||
import { FFmpegAddonAdapter } from './ffmpeg-addon-adapter';
|
||||
|
||||
export const getFFmpegPath = (tool: 'ffmpeg' | 'ffprobe', binaryPath?: string): string => {
|
||||
const configuredPath = process.env[tool === 'ffmpeg' ? 'FFMPEG_PATH' : 'FFPROBE_PATH'];
|
||||
if (configuredPath) return configuredPath;
|
||||
const executable = tool + (process.platform === 'win32' ? '.exe' : '');
|
||||
if (binaryPath) {
|
||||
const localPath = path.join(binaryPath, 'ffmpeg', executable);
|
||||
if (existsSync(localPath)) return localPath;
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
const directories = (process.env['PATH'] ?? '').split(':').filter(Boolean);
|
||||
directories.push(process.arch === 'arm64' ? '/opt/homebrew/bin' : '/usr/local/bin');
|
||||
for (const directory of directories) {
|
||||
const candidate = path.join(directory, executable);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return executable;
|
||||
};
|
||||
|
||||
export let FFMPEG_CMD = 'ffmpeg';
|
||||
export let FFPROBE_CMD = 'ffprobe';
|
||||
export class FFmpegService {
|
||||
private static adapter: IFFmpegAdapter | null = null;
|
||||
private static initialized = false;
|
||||
private static initializationError: unknown;
|
||||
|
||||
/**
|
||||
* 初始化 FFmpeg 服务
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
|
||||
* @param logger 日志记录器
|
||||
*/
|
||||
public static async init (binaryPath: string, logger: LogWrapper): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查本地 ffmpeg 路径
|
||||
FFMPEG_CMD = getFFmpegPath('ffmpeg', binaryPath);
|
||||
FFPROBE_CMD = getFFmpegPath('ffprobe', binaryPath);
|
||||
|
||||
try {
|
||||
this.adapter = await FFmpegAdapterFactory.getAdapter(
|
||||
logger,
|
||||
FFMPEG_CMD,
|
||||
FFPROBE_CMD,
|
||||
binaryPath
|
||||
);
|
||||
} catch (error) {
|
||||
this.initializationError = error;
|
||||
logger.logError('[FFmpeg] 媒体功能初始化失败:', error);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
public static getAdapterName (): string {
|
||||
if (!this.adapter) {
|
||||
throw this.initializationError ?? new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
|
||||
}
|
||||
return this.adapter.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 FFmpeg 适配器
|
||||
*/
|
||||
private static async getAdapter (): Promise<IFFmpegAdapter> {
|
||||
if (!this.adapter) {
|
||||
throw this.initializationError ?? new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
|
||||
}
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
public static async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.convertToNTSilkTct(inputFile, outputFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 FFmpeg 路径并更新适配器
|
||||
* @deprecated 建议使用 init() 方法初始化
|
||||
*/
|
||||
public static async setFfmpegPath (ffmpegPath: string, logger: LogWrapper): Promise<void> {
|
||||
const suffix = process.platform === 'win32' ? '.exe' : '';
|
||||
const ffmpegCommand = path.join(ffmpegPath, 'ffmpeg' + suffix);
|
||||
const ffprobeCommand = path.join(ffmpegPath, 'ffprobe' + suffix);
|
||||
this.adapter = await FFmpegAdapterFactory.updateFFmpegPath(logger, ffmpegCommand, ffprobeCommand);
|
||||
FFMPEG_CMD = ffmpegCommand;
|
||||
FFPROBE_CMD = ffprobeCommand;
|
||||
this.initializationError = undefined;
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取视频缩略图
|
||||
*/
|
||||
public static async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.extractThumbnail(videoPath, thumbnailPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接发送到 QQ kernel 的旧视频元素使用 PNG 缩略图。
|
||||
* Packet/forward 路径继续使用当前适配器生成的 JPEG。
|
||||
*/
|
||||
public static async extractLegacyVideoThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const currentAdapter = await this.getAdapter();
|
||||
if (currentAdapter instanceof FFmpegAddonAdapter) {
|
||||
await currentAdapter.extractThumbnail(videoPath, thumbnailPath, 'png');
|
||||
return;
|
||||
}
|
||||
const adapter = new FFmpegExecAdapter(FFMPEG_CMD, FFPROBE_CMD);
|
||||
await adapter.extractThumbnail(videoPath, thumbnailPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换音频文件
|
||||
*/
|
||||
public static async convertAudioFmt (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.convertFile(inputFile, outputFile, format);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音频时长
|
||||
*/
|
||||
public static async getDuration (filePath: string): Promise<number> {
|
||||
const adapter = await this.getAdapter();
|
||||
return adapter.getDuration(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Silk 格式
|
||||
*/
|
||||
public static async isSilk (filePath: string): Promise<boolean> {
|
||||
const adapter = await this.getAdapter();
|
||||
return adapter.isSilk(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PCM 格式
|
||||
*/
|
||||
public static async convert (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
|
||||
const adapter = await this.getAdapter();
|
||||
return adapter.convertToPCM(filePath, pcmPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频信息
|
||||
*/
|
||||
public static async getVideoInfo (videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
||||
const adapter = await this.getAdapter();
|
||||
|
||||
const fileSize = statSync(videoPath).size;
|
||||
const videoInfo = await adapter.getVideoInfo(videoPath);
|
||||
if (thumbnailPath && videoInfo.thumbnail) {
|
||||
writeFileSync(thumbnailPath, videoInfo.thumbnail);
|
||||
}
|
||||
return {
|
||||
width: videoInfo.width,
|
||||
height: videoInfo.height,
|
||||
time: videoInfo.duration,
|
||||
format: videoInfo.format,
|
||||
size: fileSize,
|
||||
filePath: videoPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const defaultVideoThumbB64 =
|
||||
'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
Line truncated
|
||||
|
||||
export interface VideoInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
time: number;
|
||||
format: string;
|
||||
size: number;
|
||||
filePath: string;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||
|
||||
interface ForwardMsgJson {
|
||||
app: string;
|
||||
config: ForwardMsgJsonConfig,
|
||||
desc: string,
|
||||
extra: ForwardMsgJsonExtra,
|
||||
meta: ForwardMsgJsonMeta,
|
||||
prompt: string,
|
||||
ver: string,
|
||||
view: string;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonConfig {
|
||||
autosize: number,
|
||||
forward: number,
|
||||
round: number,
|
||||
type: string,
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonExtra {
|
||||
filename: string,
|
||||
tsum: number,
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMeta {
|
||||
detail: ForwardMsgJsonMetaDetail;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMetaDetail {
|
||||
news: {
|
||||
text: string;
|
||||
}[],
|
||||
resid: string,
|
||||
source: string,
|
||||
summary: string,
|
||||
uniseq: string;
|
||||
}
|
||||
|
||||
interface ForwardAdaptMsg {
|
||||
senderName?: string;
|
||||
isGroupMsg?: boolean;
|
||||
msg?: ForwardAdaptMsgElement[];
|
||||
}
|
||||
|
||||
interface ForwardAdaptMsgElement {
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export class ForwardMsgBuilder {
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
const id = uuid ?? crypto.randomUUID();
|
||||
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||
if (!source) {
|
||||
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
|
||||
}
|
||||
if (!news) {
|
||||
news = msg.length === 0
|
||||
? [{
|
||||
text: 'Nya~ This message is send from NapCat.Packet!',
|
||||
}]
|
||||
: msg.map(m => ({
|
||||
text: `${m.senderName}: ${m.msg?.map(msg => msg.preview).join('')}`,
|
||||
}));
|
||||
}
|
||||
if (!summary) {
|
||||
summary = `查看${msg.length}条转发消息`;
|
||||
}
|
||||
if (!prompt) {
|
||||
prompt = '[聊天记录]';
|
||||
}
|
||||
return {
|
||||
app: 'com.tencent.multimsg',
|
||||
config: {
|
||||
autosize: 1,
|
||||
forward: 1,
|
||||
round: 1,
|
||||
type: 'normal',
|
||||
width: 300,
|
||||
},
|
||||
desc: prompt,
|
||||
extra: {
|
||||
filename: id,
|
||||
tsum: msg.length,
|
||||
},
|
||||
meta: {
|
||||
detail: {
|
||||
news,
|
||||
resid: resId,
|
||||
source,
|
||||
summary,
|
||||
uniseq: id,
|
||||
},
|
||||
},
|
||||
prompt,
|
||||
ver: '0.0.0.5',
|
||||
view: 'contact',
|
||||
};
|
||||
}
|
||||
|
||||
static fromResId (resId: string): ForwardMsgJson {
|
||||
return this.build(resId, []);
|
||||
}
|
||||
|
||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
return this.build(resId, packetMsg.map(msg => ({
|
||||
senderName: msg.senderName,
|
||||
isGroupMsg: msg.groupId !== undefined,
|
||||
msg: msg.msg.map(m => ({
|
||||
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
||||
})),
|
||||
})),
|
||||
source,
|
||||
news,
|
||||
summary,
|
||||
prompt,
|
||||
uuid
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { NodeIKernelRichMediaService } from '../services/NodeIKernelRichMediaService';
|
||||
|
||||
type GroupFileService = Pick<NodeIKernelRichMediaService, 'getGroupFileInfo' | 'deleteGroupFile'>;
|
||||
|
||||
function withTimeout<T> (promise: Promise<T>, operation: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${operation}超时,请重新查询群文件列表确认文件状态`)), 15000);
|
||||
promise.then(resolve, reject).finally(() => clearTimeout(timer));
|
||||
});
|
||||
}
|
||||
|
||||
function assertBusId (busId: number): void {
|
||||
if (!Number.isInteger(busId) || busId <= 0 || busId > 0xFFFFFFFF) {
|
||||
throw new Error('Invalid bus_id');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGroupFiles (service: GroupFileService, groupCode: string, files: string[], busIds?: number[]) {
|
||||
if (!files.length || files.some(fileId => !fileId)) {
|
||||
throw new Error('Invalid file_id');
|
||||
}
|
||||
if (busIds && busIds.length !== files.length) {
|
||||
throw new Error('bus_id 数量必须与文件数量一致');
|
||||
}
|
||||
|
||||
const resolvedBusIds: number[] = [];
|
||||
for (const [index, fileId] of files.entries()) {
|
||||
let busId = busIds?.[index];
|
||||
if (busId === undefined) {
|
||||
const info = await withTimeout(service.getGroupFileInfo(groupCode, fileId), '查询群文件信息');
|
||||
if (info?.result !== 0) {
|
||||
throw new Error(`查询群文件信息失败: ${info?.errMsg || info?.result}`);
|
||||
}
|
||||
const detail = info.groupFileInfoResult;
|
||||
if (detail?.retCode !== 0 || detail.fileInfo?.fileId !== fileId) {
|
||||
throw new Error(`查询群文件信息失败: ${detail?.clientWording || detail?.retMsg || '未返回目标文件信息'}`);
|
||||
}
|
||||
busId = detail.fileInfo.busId;
|
||||
}
|
||||
assertBusId(busId);
|
||||
resolvedBusIds.push(busId);
|
||||
}
|
||||
|
||||
const response = await withTimeout(service.deleteGroupFile(groupCode, resolvedBusIds, files), '删除群文件');
|
||||
if (response?.result !== 0) {
|
||||
throw new Error(`删除群文件失败: ${response?.errMsg || response?.result}`);
|
||||
}
|
||||
const detail = response.transGroupFileResult;
|
||||
if (detail?.result?.retCode !== 0) {
|
||||
throw new Error(`删除群文件失败: ${detail?.result?.clientWording || detail?.result?.retMsg || detail?.result?.retCode || '缺少删除结果'}`);
|
||||
}
|
||||
if (!Array.isArray(detail.successFileIdList) || !Array.isArray(detail.failFileIdList)) {
|
||||
throw new Error('删除群文件失败: 缺少逐文件删除结果');
|
||||
}
|
||||
const missing = files.filter(fileId => !detail.successFileIdList.includes(fileId));
|
||||
if (detail.failFileIdList.length || missing.length) {
|
||||
const failed = [...new Set([...detail.failFileIdList, ...missing])];
|
||||
throw new Error(`删除群文件失败: ${failed.join(', ')}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import winston, { format, transports } from 'winston';
|
||||
import { truncateString } from 'napcat-common/src/helper';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { NTMsgAtType, ChatType, ElementType, MessageElement, RawMessage, SelfInfo } from '@/napcat-core/index';
|
||||
import { ILogWrapper } from 'napcat-common/src/log-interface';
|
||||
import EventEmitter from 'node:events';
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
FATAL = 'fatal',
|
||||
}
|
||||
|
||||
function getFormattedTimestamp () {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = now.getDate().toString().padStart(2, '0');
|
||||
const hours = now.getHours().toString().padStart(2, '0');
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = now.getSeconds().toString().padStart(2, '0');
|
||||
const milliseconds = now.getMilliseconds().toString().padStart(3, '0');
|
||||
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}.${milliseconds}`;
|
||||
}
|
||||
|
||||
const logEmitter = new EventEmitter();
|
||||
export type LogListener = (msg: string) => void;
|
||||
class Subscription {
|
||||
public static MAX_HISTORY = 100;
|
||||
public static history: string[] = [];
|
||||
|
||||
subscribe (listener: LogListener) {
|
||||
for (const history of Subscription.history) {
|
||||
try {
|
||||
listener(history);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
logEmitter.on('log', listener);
|
||||
}
|
||||
|
||||
unsubscribe (listener: LogListener) {
|
||||
logEmitter.off('log', listener);
|
||||
}
|
||||
|
||||
notify (msg: string) {
|
||||
logEmitter.emit('log', msg);
|
||||
if (Subscription.history.length >= Subscription.MAX_HISTORY) {
|
||||
Subscription.history.shift();
|
||||
}
|
||||
Subscription.history.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
export const logSubscription = new Subscription();
|
||||
|
||||
export class LogWrapper implements ILogWrapper {
|
||||
fileLogEnabled = false;
|
||||
consoleLogEnabled = true;
|
||||
logger: winston.Logger;
|
||||
|
||||
private logPath: string;
|
||||
private fileTransportAdded = false;
|
||||
private fileLogLevel: string = LogLevel.DEBUG;
|
||||
|
||||
constructor (logDir: string) {
|
||||
const filename = `${getFormattedTimestamp()}.log`;
|
||||
this.logPath = path.join(logDir, filename);
|
||||
|
||||
this.logger = winston.createLogger({
|
||||
level: 'debug',
|
||||
format: format.combine(
|
||||
format.timestamp({ format: 'MM-DD HH:mm:ss' }),
|
||||
format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
|
||||
return `${timestamp} [${level}] ${userInfo}${message}`;
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
new transports.Console({
|
||||
format: format.combine(
|
||||
format.colorize(),
|
||||
format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
|
||||
return `${timestamp} [${level}] ${userInfo}${message}`;
|
||||
})
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
this.setLogSelfInfo({ nick: '', uid: '' });
|
||||
this.cleanOldLogs(logDir);
|
||||
}
|
||||
|
||||
cleanOldLogs (logDir: string) {
|
||||
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
fs.readdir(logDir).then((files) => {
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(logDir, file);
|
||||
this.deleteOldLogFile(filePath, oneWeekAgo);
|
||||
});
|
||||
}).catch((err) => {
|
||||
this.logError('Failed to read log directory', err);
|
||||
});
|
||||
}
|
||||
|
||||
private deleteOldLogFile (filePath: string, oneWeekAgo: number) {
|
||||
fs.stat(filePath).then((stats) => {
|
||||
if (stats.mtime.getTime() < oneWeekAgo) {
|
||||
fs.unlink(filePath).catch((err) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
this.logWarn(`File already deleted: ${filePath}`);
|
||||
} else {
|
||||
this.logError('Failed to delete old log file', err);
|
||||
}
|
||||
} else {
|
||||
this.log(`Deleted old log file: ${filePath}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
this.logError('Failed to get file stats', err);
|
||||
});
|
||||
}
|
||||
|
||||
setFileAndConsoleLogLevel (fileLogLevel: LogLevel, consoleLogLevel: LogLevel) {
|
||||
this.fileLogLevel = fileLogLevel;
|
||||
this.logger.transports.forEach((transport) => {
|
||||
if (transport instanceof transports.File) {
|
||||
transport.level = fileLogLevel;
|
||||
} else if (transport instanceof transports.Console) {
|
||||
transport.level = consoleLogLevel;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setLogSelfInfo (selfInfo: { nick: string; uid: string; }) {
|
||||
const userInfo = `${selfInfo.nick}`;
|
||||
this.logger.defaultMeta = { userInfo };
|
||||
}
|
||||
|
||||
setFileLogEnabled (isEnabled: boolean) {
|
||||
this.fileLogEnabled = isEnabled;
|
||||
if (isEnabled && !this.fileTransportAdded) {
|
||||
this.fileTransportAdded = true;
|
||||
this.logger.add(new transports.File({
|
||||
filename: this.logPath,
|
||||
level: this.fileLogLevel,
|
||||
maxsize: 5 * 1024 * 1024, // 5MB
|
||||
maxFiles: 5,
|
||||
}));
|
||||
}
|
||||
this.logger.transports.forEach((transport) => {
|
||||
if (transport instanceof transports.File) {
|
||||
transport.silent = !isEnabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setConsoleLogEnabled (isEnabled: boolean) {
|
||||
this.consoleLogEnabled = isEnabled;
|
||||
this.logger.transports.forEach((transport) => {
|
||||
if (transport instanceof transports.Console) {
|
||||
transport.silent = !isEnabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatMsg (msg: any[]) {
|
||||
return msg
|
||||
.map((msgItem) => {
|
||||
if (msgItem instanceof Error) {
|
||||
return msgItem.stack;
|
||||
} else if (typeof msgItem === 'object') {
|
||||
return JSON.stringify(truncateString(JSON.parse(JSON.stringify(msgItem, null, 2))));
|
||||
}
|
||||
return msgItem;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
_log (level: LogLevel, ...args: any[]) {
|
||||
if (this.consoleLogEnabled || this.fileLogEnabled) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const message = this.formatMsg(args).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\x1B\[[0-?]*[ -/]*[@-~]|\x1B/g, '');
|
||||
if (this.consoleLogEnabled && this.fileLogEnabled) {
|
||||
this.logger.log(level, message);
|
||||
} else if (this.consoleLogEnabled) {
|
||||
this.logger.log(level, message);
|
||||
} else if (this.fileLogEnabled) {
|
||||
this.logger.log(level, message);
|
||||
}
|
||||
logSubscription.notify(JSON.stringify({ level, message }));
|
||||
}
|
||||
}
|
||||
|
||||
log (...args: any[]) {
|
||||
this._log(LogLevel.INFO, ...args);
|
||||
}
|
||||
|
||||
logDebug (...args: any[]) {
|
||||
this._log(LogLevel.DEBUG, ...args);
|
||||
}
|
||||
|
||||
logError (...args: any[]) {
|
||||
this._log(LogLevel.ERROR, ...args);
|
||||
}
|
||||
|
||||
logWarn (...args: any[]) {
|
||||
this._log(LogLevel.WARN, ...args);
|
||||
}
|
||||
|
||||
logFatal (...args: any[]) {
|
||||
this._log(LogLevel.FATAL, ...args);
|
||||
}
|
||||
|
||||
logMessage (msg: RawMessage, selfInfo: SelfInfo) {
|
||||
const isSelfSent = msg.senderUin === selfInfo.uin;
|
||||
|
||||
if (msg.elements[0]?.elementType === ElementType.GreyTip) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`${isSelfSent ? '发送 ->' : '接收 <-'} ${rawMessageToText(msg)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function rawMessageToText (msg: RawMessage, recursiveLevel = 0): string {
|
||||
if (recursiveLevel > 2) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
const tokens: string[] = [];
|
||||
|
||||
if (msg.chatType === ChatType.KCHATTYPEC2C) {
|
||||
tokens.push(`私聊 (${msg.peerUin})`);
|
||||
} else if (msg.chatType === ChatType.KCHATTYPEGROUP) {
|
||||
if (recursiveLevel < 1) {
|
||||
tokens.push(`群聊 [${msg.peerName}(${msg.peerUin})]`);
|
||||
}
|
||||
if (msg.senderUin !== '0') {
|
||||
tokens.push(`[${msg.sendMemberName || msg.sendRemarkName || msg.sendNickName}(${msg.senderUin})]`);
|
||||
}
|
||||
} else if (msg.chatType === ChatType.KCHATTYPEDATALINE) {
|
||||
tokens.push('移动设备');
|
||||
} else {
|
||||
tokens.push(`临时消息 (${msg.peerUin})`);
|
||||
}
|
||||
|
||||
for (const element of msg.elements) {
|
||||
tokens.push(msgElementToText(element, msg, recursiveLevel));
|
||||
}
|
||||
|
||||
return tokens.join(' ');
|
||||
}
|
||||
|
||||
function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLevel: number): string {
|
||||
if (element.textElement) {
|
||||
return textElementToText(element.textElement);
|
||||
}
|
||||
|
||||
if (element.replyElement) {
|
||||
return replyElementToText(element.replyElement, msg, recursiveLevel);
|
||||
}
|
||||
|
||||
if (element.picElement) {
|
||||
return '[图片]';
|
||||
}
|
||||
|
||||
if (element.fileElement) {
|
||||
if (element.fileElement.fileUuid) {
|
||||
return `[文件 ${element.fileElement.fileName}]`;
|
||||
} else if (element.elementType === ElementType.TOFURECORD) {
|
||||
return `[在线文件 ${element.fileElement.fileName}]`;
|
||||
} else if (element.elementType === ElementType.ONLINEFOLDER) {
|
||||
return `[在线文件夹 ${element.fileElement.fileName}/]`;
|
||||
}
|
||||
}
|
||||
|
||||
if (element.videoElement) {
|
||||
return '[视频]';
|
||||
}
|
||||
|
||||
if (element.pttElement) {
|
||||
return `[语音 ${element.pttElement.duration}s]`;
|
||||
}
|
||||
|
||||
if (element.arkElement) {
|
||||
return '[卡片消息]';
|
||||
}
|
||||
|
||||
if (element.faceElement) {
|
||||
return `[表情 ${element.faceElement.faceText ?? ''}]`;
|
||||
}
|
||||
|
||||
if (element.marketFaceElement) {
|
||||
return element.marketFaceElement.faceName;
|
||||
}
|
||||
|
||||
if (element.markdownElement) {
|
||||
if (element.markdownElement?.mdSummary) {
|
||||
return element.markdownElement.mdSummary;
|
||||
} else {
|
||||
return '[Markdown 消息]';
|
||||
}
|
||||
}
|
||||
|
||||
if (element.multiForwardMsgElement) {
|
||||
return '[转发消息]';
|
||||
}
|
||||
|
||||
if (element.elementType === ElementType.GreyTip) {
|
||||
return '[灰条消息]';
|
||||
} else if (element.elementType === ElementType.FILE) {
|
||||
return '[文件发送中]';
|
||||
}
|
||||
|
||||
return `[未实现 (ElementType = ${element.elementType})]`;
|
||||
}
|
||||
|
||||
function textElementToText (textElement: any): string {
|
||||
if (textElement.atType === NTMsgAtType.ATTYPEUNKNOWN) {
|
||||
const originalContentLines = textElement.content.split('\n');
|
||||
return `${originalContentLines[0]}${originalContentLines.length > 1 ? ' ...' : ''}`;
|
||||
} else if (textElement.atType === NTMsgAtType.ATTYPEALL) {
|
||||
return '@全体成员';
|
||||
} else if (textElement.atType === NTMsgAtType.ATTYPEONE) {
|
||||
return `${textElement.content} (${textElement.atUid})`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function replyElementToText (replyElement: any, msg: RawMessage, recursiveLevel: number): string {
|
||||
const recordMsgOrNull = msg.records.find((record) => replyElement.sourceMsgIdInRecords === record.msgId);
|
||||
return `[回复消息 ${recordMsgOrNull && recordMsgOrNull.peerUin !== '284840486' && recordMsgOrNull.peerUin !== '1094950020'
|
||||
? rawMessageToText(recordMsgOrNull, recursiveLevel + 1)
|
||||
: `未找到消息记录 (MsgId = ${replyElement.sourceMsgIdInRecords})`
|
||||
}]`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import { PicType } from '../types';
|
||||
export async function getFileTypeForSendType (picPath: string): Promise<PicType> {
|
||||
const fileTypeResult = (await fileTypeFromFile(picPath))?.ext ?? 'jpg';
|
||||
const picTypeMap: { [key: string]: PicType } = {
|
||||
// 'webp': PicType.NEWPIC_WEBP,
|
||||
gif: PicType.NEWPIC_GIF,
|
||||
// 'png': PicType.NEWPIC_APNG,
|
||||
// 'jpg': PicType.NEWPIC_JPEG,
|
||||
// 'jpeg': PicType.NEWPIC_JPEG,
|
||||
// 'bmp': PicType.NEWPIC_BMP,
|
||||
};
|
||||
return picTypeMap[fileTypeResult] ?? PicType.NEWPIC_JPEG;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface OfflineRecoveryOptions {
|
||||
// 当前账号是否在线
|
||||
isOnline: () => boolean;
|
||||
// 刚下线:同步 WebUI 状态
|
||||
onOffline: (tips: string) => void;
|
||||
// 静默离线期间账号自己恢复了,不需要重启
|
||||
onRecovered: () => void;
|
||||
// 重启 Worker;返回 false 或抛错表示失败,之后可以再次触发
|
||||
restart: (tips: string) => Promise<boolean>;
|
||||
// 被踢下线后多久重启。常规状态轮询为 3 秒,留出一个轮询周期,确保浏览器先清除旧二维码
|
||||
kickedDelayMs?: number;
|
||||
// 静默离线后多久重启。网络短暂断开时 QQ 也可能报离线,先给它自己重连的机会,
|
||||
// 不然一次网络抖动就会把整个 Worker 重启掉
|
||||
selfOfflineDelayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下线恢复:被踢下线(KickedOffLine)和静默离线(SelfOffline)共用。
|
||||
* 返回的函数在每次收到下线事件时调用,allowSelfRecovery 为 true 表示静默离线。
|
||||
*/
|
||||
export function createOfflineRecovery ({
|
||||
isOnline,
|
||||
onOffline,
|
||||
onRecovered,
|
||||
restart,
|
||||
kickedDelayMs = 3_500,
|
||||
selfOfflineDelayMs = 60_000,
|
||||
}: OfflineRecoveryOptions) {
|
||||
let restartRequested = false;
|
||||
let selfRecoveryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return (tips: string, allowSelfRecovery: boolean) => {
|
||||
onOffline(tips);
|
||||
|
||||
if (restartRequested) {
|
||||
// 被踢时离线状态可能先到:还在等静默离线自行恢复的话,不用再等了
|
||||
if (allowSelfRecovery || !selfRecoveryTimer) return;
|
||||
clearTimeout(selfRecoveryTimer);
|
||||
}
|
||||
restartRequested = true;
|
||||
// 不在 RefreshQRcode 请求里直接重启 Worker,否则请求在返回前就被中断,
|
||||
// 浏览器只能得到 ERR_EMPTY_RESPONSE;延后调度也让前端先看到「二维码已清空、正在恢复」
|
||||
const timer = setTimeout(() => {
|
||||
selfRecoveryTimer = undefined;
|
||||
if (allowSelfRecovery && isOnline()) {
|
||||
restartRequested = false;
|
||||
onRecovered();
|
||||
return;
|
||||
}
|
||||
restart(tips).then((ok) => {
|
||||
if (!ok) restartRequested = false;
|
||||
}, () => {
|
||||
restartRequested = false;
|
||||
});
|
||||
}, allowSelfRecovery ? selfOfflineDelayMs : kickedDelayMs);
|
||||
selfRecoveryTimer = allowSelfRecovery ? timer : undefined;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Self-reported profile region only; never infer a location from other fields. */
|
||||
export interface ProfileRegion {
|
||||
country?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
function text (value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
/** Keep one source together: mixing old/new components can invent a region. */
|
||||
export function selectProfileRegion (fresh?: ProfileRegion, fallback?: ProfileRegion): Required<ProfileRegion> {
|
||||
for (const source of [fresh, fallback]) {
|
||||
const region = { country: text(source?.country), province: text(source?.province), city: text(source?.city) };
|
||||
if (Object.values(region).some(Boolean)) return region;
|
||||
}
|
||||
return { country: '', province: '', city: '' };
|
||||
}
|
||||
|
||||
export function profileRegionArea (source?: ProfileRegion): string {
|
||||
const region = selectProfileRegion(source);
|
||||
return [...new Set(Object.values(region).filter(Boolean))].join(' ');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
|
||||
export function proxyHandlerOf (logger: LogWrapper) {
|
||||
return {
|
||||
get (target: any, prop: any, receiver: any) {
|
||||
if (typeof target[prop] === 'undefined') {
|
||||
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
|
||||
|
||||
return (..._args: unknown[]) => {
|
||||
logger.logDebug(`${target.constructor.name} has no method ${prop}`);
|
||||
};
|
||||
}
|
||||
// 如果方法存在,正常返回
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function proxiedListenerOf<T extends object> (listener: T, logger: LogWrapper) {
|
||||
return new Proxy<T>(listener, proxyHandlerOf(logger));
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import fs from 'node:fs';
|
||||
import { systemPlatform } from 'napcat-common/src/system';
|
||||
import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor, parseAppidFromMajorV2 } from 'napcat-common/src/helper';
|
||||
import AppidTable from '@/napcat-core/external/appid.json';
|
||||
import { LogWrapper } from './log';
|
||||
import { getMajorPath } from '@/napcat-core/index';
|
||||
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from 'napcat-common/src/types';
|
||||
import path from 'node:path';
|
||||
|
||||
export class QQBasicInfoWrapper {
|
||||
QQMainPath: string | undefined;
|
||||
QQPackageInfoPath: string | undefined;
|
||||
QQVersionConfigPath: string | undefined;
|
||||
isQuickUpdate: boolean | undefined;
|
||||
QQVersionConfig: QQVersionConfigType | undefined;
|
||||
QQPackageInfo: QQPackageInfoType | undefined;
|
||||
QQVersionAppid: string | undefined;
|
||||
QQVersionQua: string | undefined;
|
||||
context: { logger: LogWrapper; };
|
||||
|
||||
constructor (context: { logger: LogWrapper; }) {
|
||||
// 基础目录获取
|
||||
this.context = context;
|
||||
this.QQMainPath = process.execPath;
|
||||
if (process.platform === 'darwin' && path.basename(this.QQMainPath) === 'QQ Helper') {
|
||||
// 实用进程特殊处理 实用进程目录和QQ差远了
|
||||
this.QQMainPath = path.resolve(path.dirname(this.QQMainPath), '../../../../', 'MacOS', 'QQ');
|
||||
}
|
||||
this.QQVersionConfigPath = getQQVersionConfigPath(this.QQMainPath);
|
||||
|
||||
// 基础信息获取 无快更则启用默认模板填充
|
||||
this.isQuickUpdate = !!this.QQVersionConfigPath;
|
||||
this.QQVersionConfig = this.isQuickUpdate
|
||||
? JSON.parse(fs.readFileSync(this.QQVersionConfigPath!).toString())
|
||||
: getDefaultQQVersionConfigInfo();
|
||||
|
||||
this.QQPackageInfoPath = getQQPackageInfoPath(this.QQMainPath, this.QQVersionConfig?.curVersion);
|
||||
this.QQPackageInfo = JSON.parse(fs.readFileSync(this.QQPackageInfoPath).toString());
|
||||
const { appid: IQQVersionAppid, qua: IQQVersionQua } = this.getAppidV2();
|
||||
this.QQVersionAppid = IQQVersionAppid;
|
||||
this.QQVersionQua = IQQVersionQua;
|
||||
}
|
||||
|
||||
// 基础函数
|
||||
getQQBuildStr () {
|
||||
return this.QQVersionConfig?.curVersion.split('-')[1] ?? this.QQPackageInfo?.buildVersion;
|
||||
}
|
||||
|
||||
getFullQQVersion () {
|
||||
const version = this.isQuickUpdate ? this.QQVersionConfig?.curVersion : this.QQPackageInfo?.version;
|
||||
if (!version) throw new Error('QQ版本获取失败');
|
||||
return version;
|
||||
}
|
||||
|
||||
requireMinNTQQBuild (buildStr: string) {
|
||||
const currentBuild = +(this.getQQBuildStr() ?? '0');
|
||||
if (currentBuild === 0) throw new Error('QQBuildStr获取失败');
|
||||
return currentBuild >= parseInt(buildStr);
|
||||
}
|
||||
|
||||
// 此方法不要直接使用
|
||||
getQUAFallback () {
|
||||
const platformMapping: Partial<Record<NodeJS.Platform, string>> = {
|
||||
win32: `V1_WIN_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
darwin: `V1_MAC_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
linux: `V1_LNX_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
};
|
||||
return platformMapping[systemPlatform] ?? (platformMapping.win32)!;
|
||||
}
|
||||
|
||||
getAppIdFallback () {
|
||||
const platformMapping: Partial<Record<NodeJS.Platform, string>> = {
|
||||
win32: '537246092',
|
||||
darwin: '537246140',
|
||||
linux: '537246140',
|
||||
};
|
||||
return platformMapping[systemPlatform] ?? '537246092';
|
||||
}
|
||||
|
||||
getAppidV2 (): { appid: string; qua: string; } {
|
||||
// 通过已有表 性能好
|
||||
const appidTable = AppidTable as unknown as QQAppidTableType;
|
||||
const fullVersion = this.getFullQQVersion();
|
||||
if (fullVersion) {
|
||||
// 同一 Linux 版本的 x64 和 arm64 使用不同的 AppID,优先匹配架构。
|
||||
const data = appidTable[`${fullVersion}-${process.arch}`] ?? appidTable[fullVersion];
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
// 通过Major拉取 性能差
|
||||
try {
|
||||
const majorAppid = this.getAppidV2ByMajor(fullVersion);
|
||||
if (majorAppid) {
|
||||
this.context.logger.log('[QQ版本兼容性检测] 当前版本Appid未内置 通过Major获取 为了更好的性能请尝试更新NapCat');
|
||||
return { appid: majorAppid, qua: this.getQUAFallback() };
|
||||
}
|
||||
} catch {
|
||||
this.context.logger.log('[QQ版本兼容性检测] 通过Major 获取Appid异常 请检测NapCat/QQNT是否正常');
|
||||
}
|
||||
// 最终兜底为老版本
|
||||
this.context.logger.log('[QQ版本兼容性检测] 获取Appid异常 请检测NapCat/QQNT是否正常');
|
||||
this.context.logger.log(`[QQ版本兼容性检测] ${fullVersion} 版本兼容性不佳,可能会导致一些功能无法正常使用`);
|
||||
return { appid: this.getAppIdFallback(), qua: this.getQUAFallback() };
|
||||
}
|
||||
|
||||
getAppidV2ByMajor (QQVersion: string) {
|
||||
if (!this.QQMainPath) {
|
||||
throw new Error('QQMainPath未定义 无法通过Major获取Appid');
|
||||
}
|
||||
const majorPath = getMajorPath(this.QQMainPath, QQVersion);
|
||||
// 优先通过 QQAppId/ 标记搜索
|
||||
const appidV2 = parseAppidFromMajorV2(majorPath);
|
||||
if (appidV2) {
|
||||
return appidV2;
|
||||
}
|
||||
// 回落到旧方式
|
||||
const appid = parseAppidFromMajor(majorPath);
|
||||
return appid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { RequestUtil } from 'napcat-common/src/request';
|
||||
import { LogWrapper } from './log';
|
||||
|
||||
interface ServerRkeyData {
|
||||
group_rkey: string;
|
||||
private_rkey: string;
|
||||
expired_time: number;
|
||||
}
|
||||
interface OneBotApiRet {
|
||||
status: string,
|
||||
retcode: number,
|
||||
data: ServerRkeyData,
|
||||
message: string,
|
||||
wording: string,
|
||||
}
|
||||
interface UrlFailureInfo {
|
||||
count: number;
|
||||
lastTimestamp: number;
|
||||
}
|
||||
|
||||
export class RkeyManager {
|
||||
serverUrl: string[] = [];
|
||||
logger: LogWrapper;
|
||||
private rkeyData: ServerRkeyData = {
|
||||
group_rkey: '',
|
||||
private_rkey: '',
|
||||
expired_time: 0,
|
||||
};
|
||||
|
||||
private urlFailures: Map<string, UrlFailureInfo> = new Map();
|
||||
private readonly FAILURE_LIMIT: number = 4;
|
||||
private readonly ONE_DAY: number = 24 * 60 * 60 * 1000;
|
||||
|
||||
constructor (serverUrl: string[], logger: LogWrapper) {
|
||||
this.logger = logger;
|
||||
this.serverUrl = serverUrl;
|
||||
}
|
||||
|
||||
async getRkey () {
|
||||
const availableUrls = this.getAvailableUrls();
|
||||
if (availableUrls.length === 0) {
|
||||
this.logger.logError('[Rkey] 所有服务均已禁用, 图片使用FallBack机制');
|
||||
throw new Error('获取rkey失败:所有服务URL均已被禁用');
|
||||
}
|
||||
|
||||
if (this.isExpired()) {
|
||||
try {
|
||||
await this.refreshRkey();
|
||||
} catch (e) {
|
||||
throw new Error(`${e}`);
|
||||
}
|
||||
}
|
||||
return this.rkeyData;
|
||||
}
|
||||
|
||||
private getAvailableUrls (): string[] {
|
||||
return this.serverUrl.filter(url => !this.isUrlDisabled(url));
|
||||
}
|
||||
|
||||
private isUrlDisabled (url: string): boolean {
|
||||
const failureInfo = this.urlFailures.get(url);
|
||||
if (!failureInfo) return false;
|
||||
|
||||
const now = new Date().getTime();
|
||||
// 如果已经过了一天,重置失败计数
|
||||
if (now - failureInfo.lastTimestamp > this.ONE_DAY) {
|
||||
failureInfo.count = 0;
|
||||
this.urlFailures.set(url, failureInfo);
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureInfo.count >= this.FAILURE_LIMIT;
|
||||
}
|
||||
|
||||
private updateUrlFailure (url: string) {
|
||||
const now = new Date().getTime();
|
||||
const failureInfo = this.urlFailures.get(url) || { count: 0, lastTimestamp: 0 };
|
||||
|
||||
// 如果已经过了一天,重置失败计数
|
||||
if (now - failureInfo.lastTimestamp > this.ONE_DAY) {
|
||||
failureInfo.count = 1;
|
||||
} else {
|
||||
failureInfo.count++;
|
||||
}
|
||||
|
||||
failureInfo.lastTimestamp = now;
|
||||
this.urlFailures.set(url, failureInfo);
|
||||
|
||||
if (failureInfo.count >= this.FAILURE_LIMIT) {
|
||||
this.logger.logError(`[Rkey] URL ${url} 已被禁用,失败次数达到 ${this.FAILURE_LIMIT} 次`);
|
||||
}
|
||||
}
|
||||
|
||||
isExpired (): boolean {
|
||||
const now = new Date().getTime() / 1000;
|
||||
return now > this.rkeyData.expired_time;
|
||||
}
|
||||
|
||||
async refreshRkey () {
|
||||
const availableUrls = this.getAvailableUrls();
|
||||
|
||||
if (availableUrls.length === 0) {
|
||||
this.logger.logError('[Rkey] 所有服务均已禁用');
|
||||
throw new Error('获取rkey失败:所有服务URL均已被禁用');
|
||||
}
|
||||
|
||||
for (const url of availableUrls) {
|
||||
try {
|
||||
let temp = await RequestUtil.HttpGetJson<ServerRkeyData>(url, 'GET');
|
||||
if (typeof temp === 'object' && temp !== null && 'retcode' in temp) {
|
||||
// 支持Onebot Ret风格
|
||||
temp = (temp as unknown as OneBotApiRet).data;
|
||||
}
|
||||
if (
|
||||
typeof temp?.group_rkey !== 'string' ||
|
||||
typeof temp?.private_rkey !== 'string' ||
|
||||
typeof temp?.expired_time !== 'number' ||
|
||||
temp.group_rkey.length <= 6 ||
|
||||
temp.private_rkey.length <= 6 ||
|
||||
temp.expired_time <= 0
|
||||
) {
|
||||
throw new Error('Rkey 服务返回格式无效:group_rkey/private_rkey/expired_time 缺失或不合法');
|
||||
}
|
||||
this.rkeyData = {
|
||||
group_rkey: temp.group_rkey.slice(6),
|
||||
private_rkey: temp.private_rkey.slice(6),
|
||||
expired_time: temp.expired_time,
|
||||
};
|
||||
return;
|
||||
} catch (e) {
|
||||
this.logger.logError(`[Rkey] 异常服务 ${url} 异常 / `, e);
|
||||
this.updateUrlFailure(url);
|
||||
|
||||
if (url === availableUrls[availableUrls.length - 1]) {
|
||||
throw new Error(`获取rkey失败: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 服务器时间矫正 - 全局 Hook
|
||||
*
|
||||
* 保留 Date.now 原始引用,启动后通过 getServerTime 计算偏移量,
|
||||
* 直接覆盖 Date.now(),使全局所有调用自动获得矫正后的时间戳。
|
||||
*/
|
||||
|
||||
/** 原始 Date.now 引用,不受 hook 影响 */
|
||||
export const OriginalDateNow: () => number = Date.now.bind(Date);
|
||||
|
||||
/**
|
||||
* 初始化全局时间矫正,覆盖 Date.now()
|
||||
* @param getServerTimeFn 获取服务器时间的函数,返回毫秒级时间戳字符串
|
||||
* @returns 偏移量(毫秒)
|
||||
*/
|
||||
export function hookGlobalDateNow (getServerTimeFn: () => string): number {
|
||||
const start = OriginalDateNow();
|
||||
const serverTimeStr = getServerTimeFn();
|
||||
const end = OriginalDateNow();
|
||||
const serverTimeMs = parseInt(serverTimeStr, 10);
|
||||
|
||||
if (isNaN(serverTimeMs) || serverTimeMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 补偿网络延迟:假定往返时间对称,服务器时间对应本地时间的中点 (start + end) / 2
|
||||
const offsetMs = Math.round(serverTimeMs - (start + end) / 2);
|
||||
|
||||
if (Math.abs(offsetMs) > 5000) {
|
||||
Date.now = () => OriginalDateNow() + offsetMs;
|
||||
}
|
||||
|
||||
return offsetMs;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
|
||||
import { ServiceNamingMapping } from '@/napcat-core/services/index';
|
||||
import { NTEventWrapper } from './event';
|
||||
|
||||
/**
|
||||
* 创建 Service 方法的代理
|
||||
* 拦截所有方法调用,通过 EventWrapper 进行调用
|
||||
*/
|
||||
function createServiceMethodProxy<S extends keyof ServiceNamingMapping> (
|
||||
serviceName: S,
|
||||
originalService: ServiceNamingMapping[S],
|
||||
eventWrapper: NTEventWrapper
|
||||
): ServiceNamingMapping[S] {
|
||||
return new Proxy(originalService as object, {
|
||||
get (target, prop, receiver) {
|
||||
const originalValue = Reflect.get(target, prop, receiver);
|
||||
|
||||
// 如果不是函数,直接返回原始值
|
||||
if (typeof originalValue !== 'function') {
|
||||
return originalValue;
|
||||
}
|
||||
|
||||
const methodName = prop as string;
|
||||
|
||||
// 返回一个包装函数,通过 EventWrapper 调用
|
||||
return function (this: unknown, ...args: unknown[]) {
|
||||
// 构造 EventWrapper 需要的路径格式: ServiceName/MethodName
|
||||
const eventPath = `${serviceName}/${methodName}`;
|
||||
|
||||
// 尝试通过 EventWrapper 调用
|
||||
try {
|
||||
// 使用 callNoListenerEvent 的底层实现逻辑
|
||||
const eventFunc = (eventWrapper as any).createEventFunction(eventPath);
|
||||
if (eventFunc) {
|
||||
return eventFunc(...args);
|
||||
}
|
||||
} catch {
|
||||
// 如果 EventWrapper 调用失败,回退到原始调用
|
||||
}
|
||||
|
||||
// 回退到原始方法调用
|
||||
return originalValue.apply(originalService, args);
|
||||
};
|
||||
},
|
||||
}) as ServiceNamingMapping[S];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Session 的双层代理
|
||||
* 第一层:拦截 getXXXService 方法
|
||||
* 第二层:拦截 Service 上的具体方法调用
|
||||
*/
|
||||
export function createSessionProxy (
|
||||
session: NodeIQQNTWrapperSession,
|
||||
eventWrapper: NTEventWrapper
|
||||
): NodeIQQNTWrapperSession {
|
||||
// 缓存已代理的 Service,避免重复创建
|
||||
const serviceProxyCache = new Map<string, unknown>();
|
||||
|
||||
return new Proxy(session, {
|
||||
get (target, prop, receiver) {
|
||||
const propName = prop as string;
|
||||
|
||||
// 检查是否是 getXXXService 方法
|
||||
if (typeof propName === 'string' && propName.startsWith('get') && propName.endsWith('Service')) {
|
||||
// 提取 Service 名称: getMsgService -> NodeIKernelMsgService
|
||||
const servicePart = propName.slice(3); // 移除 'get' 前缀
|
||||
const serviceName = `NodeIKernel${servicePart}` as keyof ServiceNamingMapping;
|
||||
|
||||
// 返回一个函数,该函数返回代理后的 Service
|
||||
return function () {
|
||||
// 检查缓存
|
||||
if (serviceProxyCache.has(serviceName)) {
|
||||
return serviceProxyCache.get(serviceName);
|
||||
}
|
||||
|
||||
// 获取原始 Service
|
||||
const originalGetter = Reflect.get(target, prop, receiver) as () => unknown;
|
||||
const originalService = originalGetter.call(target);
|
||||
|
||||
// 检查是否在 ServiceNamingMapping 中定义
|
||||
if (isKnownService(serviceName)) {
|
||||
// 创建 Service 方法代理
|
||||
const proxiedService = createServiceMethodProxy(
|
||||
serviceName,
|
||||
originalService as ServiceNamingMapping[typeof serviceName],
|
||||
eventWrapper
|
||||
);
|
||||
serviceProxyCache.set(serviceName, proxiedService);
|
||||
return proxiedService;
|
||||
}
|
||||
|
||||
// 未知的 Service,直接返回原始对象
|
||||
serviceProxyCache.set(serviceName, originalService);
|
||||
return originalService;
|
||||
};
|
||||
}
|
||||
|
||||
// 非 getXXXService 方法,直接返回原始值
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Service 名称是否在已知的映射中
|
||||
*/
|
||||
function isKnownService (serviceName: string): serviceName is keyof ServiceNamingMapping {
|
||||
const knownServices: string[] = [
|
||||
'NodeIKernelAvatarService',
|
||||
'NodeIKernelBuddyService',
|
||||
'NodeIKernelFileAssistantService',
|
||||
'NodeIKernelGroupService',
|
||||
'NodeIKernelLoginService',
|
||||
'NodeIKernelMsgService',
|
||||
'NodeIKernelOnlineStatusService',
|
||||
'NodeIKernelProfileLikeService',
|
||||
'NodeIKernelProfileService',
|
||||
'NodeIKernelTicketService',
|
||||
'NodeIKernelStorageCleanService',
|
||||
'NodeIKernelRobotService',
|
||||
'NodeIKernelRichMediaService',
|
||||
'NodeIKernelDbToolsService',
|
||||
'NodeIKernelTipOffService',
|
||||
'NodeIKernelSearchService',
|
||||
'NodeIKernelCollectionService',
|
||||
];
|
||||
return knownServices.includes(serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带有 EventWrapper 集成的 InstanceContext
|
||||
* 这是推荐的使用方式,在创建 context 时自动代理 session
|
||||
*/
|
||||
export function createProxiedSession (
|
||||
session: NodeIQQNTWrapperSession,
|
||||
eventWrapper: NTEventWrapper
|
||||
): NodeIQQNTWrapperSession {
|
||||
return createSessionProxy(session, eventWrapper);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import os from 'node:os';
|
||||
import EventEmitter from 'node:events';
|
||||
import { IStatusHelperSubscription } from 'napcat-common/src/status-interface';
|
||||
export interface SystemStatus {
|
||||
cpu: {
|
||||
model: string,
|
||||
speed: string;
|
||||
usage: {
|
||||
system: string;
|
||||
qq: string;
|
||||
},
|
||||
core: number;
|
||||
},
|
||||
memory: {
|
||||
total: string;
|
||||
usage: {
|
||||
system: string;
|
||||
qq: string;
|
||||
};
|
||||
},
|
||||
arch: string;
|
||||
}
|
||||
|
||||
export class StatusHelper {
|
||||
private psCpuUsage = process.cpuUsage();
|
||||
private psCurrentTime = process.hrtime();
|
||||
private cpuTimes = os.cpus().map(cpu => cpu.times);
|
||||
|
||||
private replaceNaN (value: number) {
|
||||
return isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
private sysCpuInfo () {
|
||||
const currentTimes = os.cpus().map(cpu => cpu.times);
|
||||
const { total, active } = currentTimes.map((times, index) => {
|
||||
const prevTimes = this.cpuTimes[index];
|
||||
const totalCurrent = times.user + times.nice + times.sys + times.idle + times.irq;
|
||||
const totalPrev = (prevTimes?.user ?? 0) + (prevTimes?.nice ?? 0) + (prevTimes?.sys ?? 0) + (prevTimes?.idle ?? 0) + (prevTimes?.irq ?? 0);
|
||||
const activeCurrent = totalCurrent - times.idle;
|
||||
const activePrev = totalPrev - (prevTimes?.idle ?? 0);
|
||||
return {
|
||||
total: totalCurrent - totalPrev,
|
||||
active: activeCurrent - activePrev,
|
||||
};
|
||||
}).reduce((acc, cur) => ({
|
||||
total: acc.total + cur.total,
|
||||
active: acc.active + cur.active,
|
||||
}), { total: 0, active: 0 });
|
||||
this.cpuTimes = currentTimes;
|
||||
return {
|
||||
usage: this.replaceNaN(((active / total) * 100)).toFixed(2),
|
||||
model: os.cpus()[0]?.model ?? 'none',
|
||||
speed: os.cpus()[0]?.speed ?? 0,
|
||||
core: os.cpus().length,
|
||||
};
|
||||
}
|
||||
|
||||
private sysMemoryUsage () {
|
||||
const { total, free } = { total: os.totalmem(), free: os.freemem() };
|
||||
return ((total - free) / 1024 / 1024).toFixed(2);
|
||||
}
|
||||
|
||||
private qqUsage () {
|
||||
const mem = process.memoryUsage();
|
||||
const numCpus = os.cpus().length;
|
||||
const usageDiff = process.cpuUsage(this.psCpuUsage);
|
||||
const endTime = process.hrtime(this.psCurrentTime);
|
||||
this.psCpuUsage = process.cpuUsage();
|
||||
this.psCurrentTime = process.hrtime();
|
||||
const usageMS = (usageDiff.user + usageDiff.system) / 1e3;
|
||||
const totalMS = endTime[0] * 1e3 + endTime[1] / 1e6;
|
||||
const normPercent = (usageMS / totalMS / numCpus) * 100;
|
||||
return {
|
||||
cpu: this.replaceNaN(normPercent).toFixed(2),
|
||||
memory: ((mem.heapTotal + mem.external + mem.arrayBuffers) / 1024 / 1024).toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
systemStatus (): SystemStatus {
|
||||
const qqUsage = this.qqUsage();
|
||||
const sysCpuInfo = this.sysCpuInfo();
|
||||
return {
|
||||
cpu: {
|
||||
core: sysCpuInfo.core,
|
||||
model: sysCpuInfo.model,
|
||||
speed: (sysCpuInfo.speed / 1000).toFixed(2),
|
||||
usage: {
|
||||
system: sysCpuInfo.usage,
|
||||
qq: qqUsage.cpu,
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
total: (os.totalmem() / 1024 / 1024).toFixed(2),
|
||||
usage: {
|
||||
system: this.sysMemoryUsage(),
|
||||
qq: qqUsage.memory,
|
||||
},
|
||||
},
|
||||
arch: `${os.platform()} ${os.arch()} ${os.release()}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class StatusHelperSubscription extends EventEmitter implements IStatusHelperSubscription {
|
||||
private statusHelper: StatusHelper;
|
||||
private interval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor (time: number = 3000) {
|
||||
super();
|
||||
this.statusHelper = new StatusHelper();
|
||||
this.on('newListener', (event: string) => {
|
||||
if (event === 'statusUpdate' && this.listenerCount('statusUpdate') === 0) {
|
||||
this.startInterval(time);
|
||||
}
|
||||
});
|
||||
this.on('removeListener', (event: string) => {
|
||||
if (event === 'statusUpdate' && this.listenerCount('statusUpdate') === 0) {
|
||||
this.stopInterval();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startInterval (time: number) {
|
||||
this.interval ??= setInterval(() => {
|
||||
const status = this.statusHelper.systemStatus();
|
||||
this.emit('statusUpdate', status);
|
||||
}, time);
|
||||
}
|
||||
|
||||
private stopInterval () {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const statusHelperSubscription = new StatusHelperSubscription();
|
||||
Reference in new issue
Block a user