mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-12 16:00:27 +00:00
refactor: 优化eslint配置,提升代码质量 (#1341)
* feat: 统一并标准化eslint * lint: napcat.webui * lint: napcat.webui * lint: napcat.core * build: fix * lint: napcat.webui * refactor: 重构eslint * Update README.md
This commit is contained in:
@@ -2,19 +2,19 @@ import { encode } from 'silk-wasm';
|
||||
import { parentPort } from 'worker_threads';
|
||||
|
||||
export interface EncodeArgs {
|
||||
input: ArrayBufferView | ArrayBuffer
|
||||
sampleRate: number
|
||||
input: ArrayBufferView | ArrayBuffer
|
||||
sampleRate: number
|
||||
}
|
||||
export function recvTask<T>(cb: (taskData: T) => Promise<unknown>) {
|
||||
parentPort?.on('message', async (taskData: T) => {
|
||||
try {
|
||||
let ret = await cb(taskData);
|
||||
parentPort?.postMessage(ret);
|
||||
} catch (error: unknown) {
|
||||
parentPort?.postMessage({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
export function recvTask<T> (cb: (taskData: T) => Promise<unknown>) {
|
||||
parentPort?.on('message', async (taskData: T) => {
|
||||
try {
|
||||
const ret = await cb(taskData);
|
||||
parentPort?.postMessage(ret);
|
||||
} catch (error: unknown) {
|
||||
parentPort?.postMessage({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
recvTask<EncodeArgs>(async ({ input, sampleRate }) => {
|
||||
return await encode(input, sampleRate);
|
||||
});
|
||||
return await encode(input, sampleRate);
|
||||
});
|
||||
|
||||
@@ -10,76 +10,75 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
|
||||
|
||||
function getWorkerPath() {
|
||||
//return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
|
||||
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'audio-worker.mjs');
|
||||
function getWorkerPath () {
|
||||
// return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
|
||||
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'audio-worker.mjs');
|
||||
}
|
||||
|
||||
|
||||
async function guessDuration(pttPath: string, logger: LogWrapper) {
|
||||
const pttFileInfo = await fsPromise.stat(pttPath);
|
||||
const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s
|
||||
logger.log('通过文件大小估算语音的时长:', duration);
|
||||
return duration;
|
||||
async function guessDuration (pttPath: string, logger: LogWrapper) {
|
||||
const pttFileInfo = await fsPromise.stat(pttPath);
|
||||
const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s
|
||||
logger.log('通过文件大小估算语音的时长:', duration);
|
||||
return duration;
|
||||
}
|
||||
|
||||
async function handleWavFile(
|
||||
file: Buffer,
|
||||
filePath: string,
|
||||
pcmPath: string
|
||||
async function handleWavFile (
|
||||
file: Buffer,
|
||||
filePath: string,
|
||||
pcmPath: string
|
||||
): Promise<{ input: Buffer; sampleRate: number }> {
|
||||
const { fmt } = getWavFileInfo(file);
|
||||
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
|
||||
const result = await FFmpegService.convert(filePath, pcmPath);
|
||||
return { input: await fsPromise.readFile(pcmPath), sampleRate: result.sampleRate };
|
||||
}
|
||||
return { input: file, sampleRate: fmt.sampleRate };
|
||||
const { fmt } = getWavFileInfo(file);
|
||||
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
|
||||
const result = await FFmpegService.convert(filePath, pcmPath);
|
||||
return { input: await fsPromise.readFile(pcmPath), sampleRate: result.sampleRate };
|
||||
}
|
||||
return { input: file, sampleRate: fmt.sampleRate };
|
||||
}
|
||||
|
||||
export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: LogWrapper) {
|
||||
try {
|
||||
const file = await fsPromise.readFile(filePath);
|
||||
const pttPath = path.join(TEMP_DIR, randomUUID());
|
||||
if (!isSilk(file)) {
|
||||
logger.log(`语音文件${filePath}需要转换成silk`);
|
||||
const pcmPath = `${pttPath}.pcm`;
|
||||
// const { input, sampleRate } = isWav(file) ? await handleWavFile(file, filePath, pcmPath): { input: await FFmpegService.convert(filePath, pcmPath) ? await fsPromise.readFile(pcmPath) : Buffer.alloc(0), sampleRate: 24000 };
|
||||
let input: Buffer;
|
||||
let sampleRate: number;
|
||||
if (isWav(file)) {
|
||||
const result = await handleWavFile(file, filePath, pcmPath);
|
||||
input = result.input;
|
||||
sampleRate = result.sampleRate;
|
||||
} else {
|
||||
const result = await FFmpegService.convert(filePath, pcmPath);
|
||||
input = await fsPromise.readFile(pcmPath);
|
||||
sampleRate = result.sampleRate;
|
||||
}
|
||||
const silk = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { input: input, sampleRate: sampleRate });
|
||||
fsPromise.unlink(pcmPath).catch((e) => logger.logError('删除临时文件失败', pcmPath, e));
|
||||
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
||||
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
|
||||
return {
|
||||
converted: true,
|
||||
path: pttPath,
|
||||
duration: silk.duration / 1000,
|
||||
};
|
||||
} else {
|
||||
let duration = 0;
|
||||
try {
|
||||
duration = getDuration(file) / 1000;
|
||||
} catch (e: unknown) {
|
||||
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
|
||||
duration = await guessDuration(filePath, logger);
|
||||
}
|
||||
return {
|
||||
converted: false,
|
||||
path: filePath,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.logError('convert silk failed', error);
|
||||
return {};
|
||||
export async function encodeSilk (filePath: string, TEMP_DIR: string, logger: LogWrapper) {
|
||||
try {
|
||||
const file = await fsPromise.readFile(filePath);
|
||||
const pttPath = path.join(TEMP_DIR, randomUUID());
|
||||
if (!isSilk(file)) {
|
||||
logger.log(`语音文件${filePath}需要转换成silk`);
|
||||
const pcmPath = `${pttPath}.pcm`;
|
||||
// const { input, sampleRate } = isWav(file) ? await handleWavFile(file, filePath, pcmPath): { input: await FFmpegService.convert(filePath, pcmPath) ? await fsPromise.readFile(pcmPath) : Buffer.alloc(0), sampleRate: 24000 };
|
||||
let input: Buffer;
|
||||
let sampleRate: number;
|
||||
if (isWav(file)) {
|
||||
const result = await handleWavFile(file, filePath, pcmPath);
|
||||
input = result.input;
|
||||
sampleRate = result.sampleRate;
|
||||
} else {
|
||||
const result = await FFmpegService.convert(filePath, pcmPath);
|
||||
input = await fsPromise.readFile(pcmPath);
|
||||
sampleRate = result.sampleRate;
|
||||
}
|
||||
const silk = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { input, sampleRate });
|
||||
fsPromise.unlink(pcmPath).catch((e) => logger.logError('删除临时文件失败', pcmPath, e));
|
||||
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
||||
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
|
||||
return {
|
||||
converted: true,
|
||||
path: pttPath,
|
||||
duration: silk.duration / 1000,
|
||||
};
|
||||
} else {
|
||||
let duration = 0;
|
||||
try {
|
||||
duration = getDuration(file) / 1000;
|
||||
} catch (e: unknown) {
|
||||
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
|
||||
duration = await guessDuration(filePath, logger);
|
||||
}
|
||||
return {
|
||||
converted: false,
|
||||
path: filePath,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.logError('convert silk failed', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +1,79 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type TaskExecutor<T> = (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void, onCancel: (callback: () => void) => void) => void | Promise<void>;
|
||||
|
||||
export class CancelableTask<T> {
|
||||
private promise: Promise<T>;
|
||||
private cancelCallback: (() => void) | null = null;
|
||||
private isCanceled = false;
|
||||
private cancelListeners: Array<() => void> = [];
|
||||
private promise: Promise<T>;
|
||||
private cancelCallback: (() => void) | null = null;
|
||||
private isCanceled = false;
|
||||
private cancelListeners: Array<() => void> = [];
|
||||
|
||||
constructor(executor: TaskExecutor<T>) {
|
||||
this.promise = new Promise<T>((resolve, reject) => {
|
||||
const onCancel = (callback: () => void) => {
|
||||
this.cancelCallback = callback;
|
||||
};
|
||||
constructor (executor: TaskExecutor<T>) {
|
||||
this.promise = new Promise<T>((resolve, reject) => {
|
||||
const onCancel = (callback: () => void) => {
|
||||
this.cancelCallback = callback;
|
||||
};
|
||||
|
||||
const execute = async () => {
|
||||
try {
|
||||
await executor(
|
||||
(value) => {
|
||||
if (!this.isCanceled) {
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
(reason) => {
|
||||
if (!this.isCanceled) {
|
||||
reject(reason);
|
||||
}
|
||||
},
|
||||
onCancel
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.isCanceled) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
execute();
|
||||
});
|
||||
}
|
||||
|
||||
public cancel() {
|
||||
if (this.cancelCallback) {
|
||||
this.cancelCallback();
|
||||
const execute = async () => {
|
||||
try {
|
||||
await executor(
|
||||
(value) => {
|
||||
if (!this.isCanceled) {
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
(reason) => {
|
||||
if (!this.isCanceled) {
|
||||
reject(reason);
|
||||
}
|
||||
},
|
||||
onCancel
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.isCanceled) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
this.isCanceled = true;
|
||||
this.cancelListeners.forEach(listener => listener());
|
||||
}
|
||||
};
|
||||
|
||||
public isTaskCanceled(): boolean {
|
||||
return this.isCanceled;
|
||||
}
|
||||
execute();
|
||||
});
|
||||
}
|
||||
|
||||
public onCancel(listener: () => void) {
|
||||
this.cancelListeners.push(listener);
|
||||
public cancel () {
|
||||
if (this.cancelCallback) {
|
||||
this.cancelCallback();
|
||||
}
|
||||
this.isCanceled = true;
|
||||
this.cancelListeners.forEach(listener => listener());
|
||||
}
|
||||
|
||||
public then<TResult1 = T, TResult2 = never>(
|
||||
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
|
||||
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return this.promise.then(onfulfilled, onrejected);
|
||||
}
|
||||
public isTaskCanceled (): boolean {
|
||||
return this.isCanceled;
|
||||
}
|
||||
|
||||
public catch<TResult = never>(
|
||||
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null
|
||||
): Promise<T | TResult> {
|
||||
return this.promise.catch(onrejected);
|
||||
}
|
||||
public onCancel (listener: () => void) {
|
||||
this.cancelListeners.push(listener);
|
||||
}
|
||||
|
||||
public finally(onfinally?: (() => void) | undefined | null): Promise<T> {
|
||||
return this.promise.finally(onfinally);
|
||||
}
|
||||
public then<TResult1 = T, TResult2 = never>(
|
||||
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
|
||||
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return this.promise.then(onfulfilled, onrejected);
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
next: () => this.promise.then(value => ({ value, done: true })),
|
||||
};
|
||||
}
|
||||
}
|
||||
public catch<TResult = never>(
|
||||
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null
|
||||
): Promise<T | TResult> {
|
||||
return this.promise.catch(onrejected);
|
||||
}
|
||||
|
||||
public finally (onfinally?: (() => void) | undefined | null): Promise<T> {
|
||||
return this.promise.finally(onfinally);
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator] () {
|
||||
return {
|
||||
next: () => this.promise.then(value => ({ value, done: true })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,228 +2,228 @@ import fs from 'fs';
|
||||
// generate Claude 3.7 Sonet Thinking
|
||||
|
||||
interface FileRecord {
|
||||
filePath: string;
|
||||
addedTime: number;
|
||||
retries: number;
|
||||
filePath: string;
|
||||
addedTime: number;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
fileRecord: FileRecord;
|
||||
timer: NodeJS.Timeout;
|
||||
fileRecord: FileRecord;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
class CleanupQueue {
|
||||
private tasks: Map<string, CleanupTask> = new Map();
|
||||
private readonly MAX_RETRIES = 3;
|
||||
private isProcessing: boolean = false;
|
||||
private pendingOperations: Array<() => void> = [];
|
||||
private tasks: Map<string, CleanupTask> = new Map();
|
||||
private readonly MAX_RETRIES = 3;
|
||||
private isProcessing: boolean = false;
|
||||
private pendingOperations: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
/**
|
||||
* 执行队列中的待处理操作,确保异步安全
|
||||
*/
|
||||
private executeNextOperation(): void {
|
||||
if (this.pendingOperations.length === 0) {
|
||||
this.isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessing = true;
|
||||
const operation = this.pendingOperations.shift();
|
||||
operation?.();
|
||||
|
||||
// 使用 setImmediate 允许事件循环继续,防止阻塞
|
||||
setImmediate(() => this.executeNextOperation());
|
||||
private executeNextOperation (): void {
|
||||
if (this.pendingOperations.length === 0) {
|
||||
this.isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
this.isProcessing = true;
|
||||
const operation = this.pendingOperations.shift();
|
||||
operation?.();
|
||||
|
||||
// 使用 setImmediate 允许事件循环继续,防止阻塞
|
||||
setImmediate(() => this.executeNextOperation());
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全执行操作,防止竞态条件
|
||||
* @param operation 要执行的操作
|
||||
*/
|
||||
private safeExecute(operation: () => void): void {
|
||||
this.pendingOperations.push(operation);
|
||||
if (!this.isProcessing) {
|
||||
this.executeNextOperation();
|
||||
}
|
||||
private safeExecute (operation: () => void): void {
|
||||
this.pendingOperations.push(operation);
|
||||
if (!this.isProcessing) {
|
||||
this.executeNextOperation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param filePath 文件路径
|
||||
* @returns 文件是否存在
|
||||
*/
|
||||
private fileExists(filePath: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch (error) {
|
||||
//console.log(`检查文件存在出错: ${filePath}`, error);
|
||||
return false;
|
||||
}
|
||||
private fileExists (filePath: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch (_error) {
|
||||
// console.log(`检查文件存在出错: ${filePath}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 添加文件到清理队列
|
||||
* @param filePath 文件路径
|
||||
* @param cleanupDelay 清理延迟时间(毫秒)
|
||||
*/
|
||||
addFile(filePath: string, cleanupDelay: number): void {
|
||||
this.safeExecute(() => {
|
||||
// 如果文件已在队列中,取消原来的计时器
|
||||
if (this.tasks.has(filePath)) {
|
||||
this.cancelCleanup(filePath);
|
||||
}
|
||||
addFile (filePath: string, cleanupDelay: number): void {
|
||||
this.safeExecute(() => {
|
||||
// 如果文件已在队列中,取消原来的计时器
|
||||
if (this.tasks.has(filePath)) {
|
||||
this.cancelCleanup(filePath);
|
||||
}
|
||||
|
||||
// 创建新的文件记录
|
||||
const fileRecord: FileRecord = {
|
||||
filePath,
|
||||
addedTime: Date.now(),
|
||||
retries: 0
|
||||
};
|
||||
// 创建新的文件记录
|
||||
const fileRecord: FileRecord = {
|
||||
filePath,
|
||||
addedTime: Date.now(),
|
||||
retries: 0,
|
||||
};
|
||||
|
||||
// 设置计时器
|
||||
const timer = setTimeout(() => {
|
||||
this.cleanupFile(fileRecord, cleanupDelay);
|
||||
}, cleanupDelay);
|
||||
// 设置计时器
|
||||
const timer = setTimeout(() => {
|
||||
this.cleanupFile(fileRecord, cleanupDelay);
|
||||
}, cleanupDelay);
|
||||
|
||||
// 添加到任务队列
|
||||
this.tasks.set(filePath, { fileRecord, timer });
|
||||
});
|
||||
}
|
||||
// 添加到任务队列
|
||||
this.tasks.set(filePath, { fileRecord, timer });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 批量添加文件到清理队列
|
||||
* @param filePaths 文件路径数组
|
||||
* @param cleanupDelay 清理延迟时间(毫秒)
|
||||
*/
|
||||
addFiles(filePaths: string[], cleanupDelay: number): void {
|
||||
this.safeExecute(() => {
|
||||
for (const filePath of filePaths) {
|
||||
// 内部直接处理,不通过 safeExecute 以保证批量操作的原子性
|
||||
if (this.tasks.has(filePath)) {
|
||||
// 取消已有的计时器,但不使用 cancelCleanup 方法以避免重复的安全检查
|
||||
const existingTask = this.tasks.get(filePath);
|
||||
if (existingTask) {
|
||||
clearTimeout(existingTask.timer);
|
||||
}
|
||||
}
|
||||
addFiles (filePaths: string[], cleanupDelay: number): void {
|
||||
this.safeExecute(() => {
|
||||
for (const filePath of filePaths) {
|
||||
// 内部直接处理,不通过 safeExecute 以保证批量操作的原子性
|
||||
if (this.tasks.has(filePath)) {
|
||||
// 取消已有的计时器,但不使用 cancelCleanup 方法以避免重复的安全检查
|
||||
const existingTask = this.tasks.get(filePath);
|
||||
if (existingTask) {
|
||||
clearTimeout(existingTask.timer);
|
||||
}
|
||||
}
|
||||
|
||||
const fileRecord: FileRecord = {
|
||||
filePath,
|
||||
addedTime: Date.now(),
|
||||
retries: 0
|
||||
};
|
||||
const fileRecord: FileRecord = {
|
||||
filePath,
|
||||
addedTime: Date.now(),
|
||||
retries: 0,
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.cleanupFile(fileRecord, cleanupDelay);
|
||||
}, cleanupDelay);
|
||||
const timer = setTimeout(() => {
|
||||
this.cleanupFile(fileRecord, cleanupDelay);
|
||||
}, cleanupDelay);
|
||||
|
||||
this.tasks.set(filePath, { fileRecord, timer });
|
||||
}
|
||||
});
|
||||
}
|
||||
this.tasks.set(filePath, { fileRecord, timer });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 清理文件
|
||||
* @param record 文件记录
|
||||
* @param delay 延迟时间,用于重试
|
||||
*/
|
||||
private cleanupFile(record: FileRecord, delay: number): void {
|
||||
this.safeExecute(() => {
|
||||
// 首先检查文件是否存在,不存在则视为清理成功
|
||||
if (!this.fileExists(record.filePath)) {
|
||||
//console.log(`文件已不存在,跳过清理: ${record.filePath}`);
|
||||
this.tasks.delete(record.filePath);
|
||||
return;
|
||||
}
|
||||
private cleanupFile (record: FileRecord, delay: number): void {
|
||||
this.safeExecute(() => {
|
||||
// 首先检查文件是否存在,不存在则视为清理成功
|
||||
if (!this.fileExists(record.filePath)) {
|
||||
// console.log(`文件已不存在,跳过清理: ${record.filePath}`);
|
||||
this.tasks.delete(record.filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试删除文件
|
||||
fs.unlinkSync(record.filePath);
|
||||
// 删除成功,从队列中移除任务
|
||||
this.tasks.delete(record.filePath);
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
try {
|
||||
// 尝试删除文件
|
||||
fs.unlinkSync(record.filePath);
|
||||
// 删除成功,从队列中移除任务
|
||||
this.tasks.delete(record.filePath);
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
|
||||
// 明确处理文件不存在的情况
|
||||
if (err.code === 'ENOENT') {
|
||||
//console.log(`文件在删除时不存在,视为清理成功: ${record.filePath}`);
|
||||
this.tasks.delete(record.filePath);
|
||||
return;
|
||||
}
|
||||
// 明确处理文件不存在的情况
|
||||
if (err.code === 'ENOENT') {
|
||||
// console.log(`文件在删除时不存在,视为清理成功: ${record.filePath}`);
|
||||
this.tasks.delete(record.filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 文件没有访问权限等情况
|
||||
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
||||
//console.error(`没有权限删除文件: ${record.filePath}`, err);
|
||||
}
|
||||
// 文件没有访问权限等情况
|
||||
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
||||
// console.error(`没有权限删除文件: ${record.filePath}`, err);
|
||||
}
|
||||
|
||||
// 其他删除失败情况,考虑重试
|
||||
if (record.retries < this.MAX_RETRIES - 1) {
|
||||
// 还有重试机会,增加重试次数
|
||||
record.retries++;
|
||||
//console.log(`清理文件失败,将重试(${record.retries}/${this.MAX_RETRIES}): ${record.filePath}`);
|
||||
// 其他删除失败情况,考虑重试
|
||||
if (record.retries < this.MAX_RETRIES - 1) {
|
||||
// 还有重试机会,增加重试次数
|
||||
record.retries++;
|
||||
// console.log(`清理文件失败,将重试(${record.retries}/${this.MAX_RETRIES}): ${record.filePath}`);
|
||||
|
||||
// 设置相同的延迟时间再次尝试
|
||||
const timer = setTimeout(() => {
|
||||
this.cleanupFile(record, delay);
|
||||
}, delay);
|
||||
// 设置相同的延迟时间再次尝试
|
||||
const timer = setTimeout(() => {
|
||||
this.cleanupFile(record, delay);
|
||||
}, delay);
|
||||
|
||||
// 更新任务
|
||||
this.tasks.set(record.filePath, { fileRecord: record, timer });
|
||||
} else {
|
||||
// 已达到最大重试次数,从队列中移除任务
|
||||
this.tasks.delete(record.filePath);
|
||||
//console.error(`清理文件失败,已达最大重试次数(${this.MAX_RETRIES}): ${record.filePath}`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// 更新任务
|
||||
this.tasks.set(record.filePath, { fileRecord: record, timer });
|
||||
} else {
|
||||
// 已达到最大重试次数,从队列中移除任务
|
||||
this.tasks.delete(record.filePath);
|
||||
// console.error(`清理文件失败,已达最大重试次数(${this.MAX_RETRIES}): ${record.filePath}`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 取消文件的清理任务
|
||||
* @param filePath 文件路径
|
||||
* @returns 是否成功取消
|
||||
*/
|
||||
cancelCleanup(filePath: string): boolean {
|
||||
let cancelled = false;
|
||||
this.safeExecute(() => {
|
||||
const task = this.tasks.get(filePath);
|
||||
if (task) {
|
||||
clearTimeout(task.timer);
|
||||
this.tasks.delete(filePath);
|
||||
cancelled = true;
|
||||
}
|
||||
});
|
||||
return cancelled;
|
||||
}
|
||||
cancelCleanup (filePath: string): boolean {
|
||||
let cancelled = false;
|
||||
this.safeExecute(() => {
|
||||
const task = this.tasks.get(filePath);
|
||||
if (task) {
|
||||
clearTimeout(task.timer);
|
||||
this.tasks.delete(filePath);
|
||||
cancelled = true;
|
||||
}
|
||||
});
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取队列中的文件数量
|
||||
* @returns 文件数量
|
||||
*/
|
||||
getQueueSize(): number {
|
||||
return this.tasks.size;
|
||||
}
|
||||
getQueueSize (): number {
|
||||
return this.tasks.size;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取所有待清理的文件
|
||||
* @returns 文件路径数组
|
||||
*/
|
||||
getPendingFiles(): string[] {
|
||||
return Array.from(this.tasks.keys());
|
||||
}
|
||||
getPendingFiles (): string[] {
|
||||
return Array.from(this.tasks.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 清空所有清理任务
|
||||
*/
|
||||
clearAll(): void {
|
||||
this.safeExecute(() => {
|
||||
// 取消所有定时器
|
||||
for (const task of this.tasks.values()) {
|
||||
clearTimeout(task.timer);
|
||||
}
|
||||
this.tasks.clear();
|
||||
//console.log('已清空所有清理任务');
|
||||
});
|
||||
}
|
||||
clearAll (): void {
|
||||
this.safeExecute(() => {
|
||||
// 取消所有定时器
|
||||
for (const task of this.tasks.values()) {
|
||||
clearTimeout(task.timer);
|
||||
}
|
||||
this.tasks.clear();
|
||||
// console.log('已清空所有清理任务');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanTaskQueue = new CleanupQueue();
|
||||
export const cleanTaskQueue = new CleanupQueue();
|
||||
|
||||
@@ -5,70 +5,70 @@ import json5 from 'json5';
|
||||
import Ajv, { AnySchema, ValidateFunction } from 'ajv';
|
||||
|
||||
export abstract class ConfigBase<T> {
|
||||
name: string;
|
||||
core: NapCatCore;
|
||||
configPath: string;
|
||||
configData: T = {} as T;
|
||||
ajv: Ajv;
|
||||
validate: ValidateFunction<T>;
|
||||
name: string;
|
||||
core: NapCatCore;
|
||||
configPath: string;
|
||||
configData: T = {} as T;
|
||||
ajv: Ajv;
|
||||
validate: ValidateFunction<T>;
|
||||
|
||||
protected constructor(name: string, core: NapCatCore, configPath: string, ConfigSchema: AnySchema) {
|
||||
this.name = name;
|
||||
this.core = core;
|
||||
this.configPath = configPath;
|
||||
this.ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
this.validate = this.ajv.compile<T>(ConfigSchema);
|
||||
fs.mkdirSync(this.configPath, { recursive: true });
|
||||
this.read();
|
||||
}
|
||||
protected constructor (name: string, core: NapCatCore, configPath: string, ConfigSchema: AnySchema) {
|
||||
this.name = name;
|
||||
this.core = core;
|
||||
this.configPath = configPath;
|
||||
this.ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
this.validate = this.ajv.compile<T>(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);
|
||||
}
|
||||
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);
|
||||
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 {
|
||||
let newConfigData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
this.validate(newConfigData);
|
||||
this.configData = newConfigData;
|
||||
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
|
||||
return this.configData;
|
||||
} catch (e: unknown) {
|
||||
this.handleError(e, '读取配置文件时发生错误');
|
||||
return {} as T;
|
||||
}
|
||||
private loadConfig (configPath: string): T {
|
||||
try {
|
||||
const newConfigData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
this.validate(newConfigData);
|
||||
this.configData = newConfigData;
|
||||
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);
|
||||
this.validate(newConfigData);
|
||||
this.configData = newConfigData;
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2));
|
||||
} catch (e: unknown) {
|
||||
this.handleError(e, `保存配置文件 ${configPath} 时发生错误:`);
|
||||
}
|
||||
save (newConfigData: T = this.configData): void {
|
||||
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||
this.validate(newConfigData);
|
||||
this.configData = newConfigData;
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,17 @@ import { pipeline } from 'stream/promises';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { LogWrapper } from './log';
|
||||
|
||||
const downloadOri = "https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip"
|
||||
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
|
||||
'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,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -26,49 +26,49 @@ const urls = [
|
||||
* @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);
|
||||
});
|
||||
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) {
|
||||
// 忽略错误
|
||||
}
|
||||
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;
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 下载文件
|
||||
@@ -76,68 +76,67 @@ async function findAvailableUrl(): Promise<string | null> {
|
||||
* @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);
|
||||
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;
|
||||
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 (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();
|
||||
// 如果有内容长度和进度回调,则添加数据监听
|
||||
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();
|
||||
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);
|
||||
// 只在以下条件触发回调:
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
req.on('error', () => {
|
||||
file.close();
|
||||
fs.unlink(destPath, () => { });
|
||||
resolve(false);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,61 +145,57 @@ async function downloadFile(url: string, destPath: string, progressCallback?: (p
|
||||
* @param zipPath 压缩文件路径
|
||||
* @param extractDir 解压目标路径
|
||||
*/
|
||||
async function extractBinDirectory(zipPath: string, extractDir: string): Promise<void> {
|
||||
try {
|
||||
// 确保目标目录存在
|
||||
if (!fs.existsSync(extractDir)) {
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
}
|
||||
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 });
|
||||
// 解压文件
|
||||
const zipStream = new compressing.zip.UncompressStream({ source: zipPath });
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// 监听条目事件
|
||||
zipStream.on('entry', (header, stream, next) => {
|
||||
// 获取文件路径
|
||||
const filePath = header.name;
|
||||
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);
|
||||
// 匹配内层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);
|
||||
// 创建写入流
|
||||
const writeStream = fs.createWriteStream(targetPath);
|
||||
|
||||
// 将流管道连接到文件
|
||||
stream.pipe(writeStream);
|
||||
// 将流管道连接到文件
|
||||
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();
|
||||
});
|
||||
// 监听写入完成事件
|
||||
writeStream.on('finish', () => {
|
||||
next();
|
||||
});
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
writeStream.on('error', () => {
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
// 跳过不需要的文件
|
||||
stream.resume();
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
zipStream.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
zipStream.on('finish', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,82 +204,82 @@ async function extractBinDirectory(zipPath: string, extractDir: string): Promise
|
||||
* @param tempDir 临时文件目录,默认为系统临时目录
|
||||
* @returns 返回ffmpeg可执行文件的路径,如果失败则返回null
|
||||
*/
|
||||
export async function downloadFFmpeg(
|
||||
destDir?: string,
|
||||
tempDir?: string,
|
||||
progressCallback?: (percent: number, stage: string) => void
|
||||
export async function downloadFFmpeg (
|
||||
destDir?: string,
|
||||
tempDir?: string,
|
||||
progressCallback?: (percent: number, stage: string) => void
|
||||
): Promise<string | null> {
|
||||
// 仅限Windows
|
||||
if (os.platform() !== 'win32') {
|
||||
return 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');
|
||||
}
|
||||
);
|
||||
|
||||
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 (!downloaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
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 {
|
||||
// 直接解压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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,73 +287,73 @@ export async function downloadFFmpeg(
|
||||
* @param executable 可执行文件名
|
||||
* @returns 如果找到返回完整路径,否则返回null
|
||||
*/
|
||||
function findExecutableInPath(executable: string): string | null {
|
||||
// 仅适用于Windows系统
|
||||
if (os.platform() !== 'win32') return null;
|
||||
function findExecutableInPath (executable: string): string | null {
|
||||
// 仅适用于Windows系统
|
||||
if (os.platform() !== 'win32') return null;
|
||||
|
||||
// 获取PATH环境变量
|
||||
const pathEnv = process.env['PATH'] || '';
|
||||
const pathDirs = pathEnv.split(';');
|
||||
// 获取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;
|
||||
}
|
||||
// 检查每个目录
|
||||
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;
|
||||
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) {
|
||||
let 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
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadFFmpegIfNotExists (log: LogWrapper) {
|
||||
// 仅限Windows
|
||||
if (os.platform() !== 'win32') {
|
||||
return {
|
||||
path: path.join(currentPath, 'ffmpeg'),
|
||||
reset: true
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,273 +1,272 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NodeIQQNTWrapperSession } from '@/core/wrapper';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ListenerNamingMapping, ServiceNamingMapping } from '@/core';
|
||||
|
||||
interface InternalMapKey {
|
||||
timeout: number;
|
||||
createtime: number;
|
||||
func: (...arg: any[]) => any;
|
||||
checker: ((...args: any[]) => boolean) | undefined;
|
||||
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
|
||||
{
|
||||
[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}
|
||||
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;
|
||||
}
|
||||
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`;
|
||||
// eslint-disable-next-line
|
||||
// @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 = undefined;
|
||||
|
||||
function sendDataCallback() {
|
||||
if (complete == 0) {
|
||||
reject(new Error(' ListenerName:' + listenerAndMethod + ' timeout'));
|
||||
} else {
|
||||
resolve(retData!);
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutRef = setTimeout(sendDataCallback, timeout);
|
||||
const eventCallback = {
|
||||
timeout: timeout,
|
||||
createtime: Date.now(),
|
||||
checker: checker,
|
||||
func: (...args: Parameters<ListenerType>) => {
|
||||
complete++;
|
||||
retData = args;
|
||||
if (complete >= waitTimes) {
|
||||
clearTimeout(timeoutRef);
|
||||
sendDataCallback();
|
||||
}
|
||||
},
|
||||
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();
|
||||
};
|
||||
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);
|
||||
});
|
||||
}
|
||||
// 如果方法存在,正常返回
|
||||
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;
|
||||
}
|
||||
|
||||
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 = undefined;
|
||||
let retEvent: any = {};
|
||||
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`;
|
||||
|
||||
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!]);
|
||||
}
|
||||
// @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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const ListenerNameList = listenerAndMethod.split('/');
|
||||
const ListenerMainName = ListenerNameList[0]??'';
|
||||
const ListenerSubName = ListenerNameList[1]??'';
|
||||
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);
|
||||
}
|
||||
|
||||
return new Promise<[EventRet: Awaited<ReturnType<EventType>>, ...Parameters<ListenerType>]>(
|
||||
(resolve, reject) => {
|
||||
const timeoutRef = setTimeout(() => sendDataCallback(resolve, reject), timeout);
|
||||
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;
|
||||
|
||||
const eventCallback = {
|
||||
timeout: 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);
|
||||
function sendDataCallback () {
|
||||
if (complete === 0) {
|
||||
reject(new Error(' ListenerName:' + listenerAndMethod + ' timeout'));
|
||||
} else {
|
||||
resolve(retData!);
|
||||
}
|
||||
}
|
||||
|
||||
const eventResult = this.createEventFunction(serviceAndMethod)!(...(args));
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,41 +2,41 @@ type Handler<T> = () => T | Promise<T>;
|
||||
type Checker<T> = (result: T) => T | Promise<T>;
|
||||
|
||||
export class Fallback<T> {
|
||||
private handlers: Handler<T>[] = [];
|
||||
private checker: Checker<T>;
|
||||
private handlers: Handler<T>[] = [];
|
||||
private checker: Checker<T>;
|
||||
|
||||
constructor(checker?: Checker<T>) {
|
||||
this.checker = checker || (async (result: T) => result);
|
||||
}
|
||||
constructor (checker?: Checker<T>) {
|
||||
this.checker = checker || (async (result: T) => result);
|
||||
}
|
||||
|
||||
add(handler: Handler<T>): this {
|
||||
this.handlers.push(handler);
|
||||
return this;
|
||||
}
|
||||
add (handler: Handler<T>): this {
|
||||
this.handlers.push(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
// 执行处理程序链
|
||||
async run(): Promise<T> {
|
||||
const errors: Error[] = [];
|
||||
for (const handler of this.handlers) {
|
||||
try {
|
||||
const result = await handler();
|
||||
const data = await this.checker(result);
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
// 执行处理程序链
|
||||
async run (): Promise<T> {
|
||||
const errors: Error[] = [];
|
||||
for (const handler of this.handlers) {
|
||||
try {
|
||||
const result = await handler();
|
||||
const data = await this.checker(result);
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
throw new AggregateError(errors, 'All handlers failed');
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
throw new AggregateError(errors, 'All handlers failed');
|
||||
}
|
||||
}
|
||||
export class FallbackUtil {
|
||||
static boolchecker<T>(value: T, condition: boolean): T {
|
||||
if (condition) {
|
||||
return value;
|
||||
} else {
|
||||
throw new Error('Condition is false, throwing error');
|
||||
}
|
||||
static boolchecker<T>(value: T, condition: boolean): T {
|
||||
if (condition) {
|
||||
return value;
|
||||
} else {
|
||||
throw new Error('Condition is false, throwing error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,120 +12,119 @@ import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
|
||||
* FFmpeg 适配器工厂
|
||||
*/
|
||||
export class FFmpegAdapterFactory {
|
||||
private static instance: IFFmpegAdapter | null = null;
|
||||
private static initPromise: Promise<IFFmpegAdapter> | null = null;
|
||||
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;
|
||||
}
|
||||
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> {
|
||||
private static async initialize (
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string,
|
||||
ffprobePath: string,
|
||||
binaryPath?: string
|
||||
): Promise<IFFmpegAdapter> {
|
||||
// 1. 优先尝试使用 Native Addon
|
||||
if (binaryPath) {
|
||||
const addonAdapter = new FFmpegAddonAdapter(binaryPath);
|
||||
|
||||
// 1. 优先尝试使用 Native Addon
|
||||
if (binaryPath) {
|
||||
const addonAdapter = new FFmpegAddonAdapter(binaryPath);
|
||||
logger.log('[FFmpeg] 检查 Native Addon 可用性...');
|
||||
if (await addonAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] ✓ 使用 Native Addon 适配器');
|
||||
return addonAdapter;
|
||||
}
|
||||
|
||||
logger.log('[FFmpeg] 检查 Native Addon 可用性...');
|
||||
if (await addonAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] ✓ 使用 Native Addon 适配器');
|
||||
return addonAdapter;
|
||||
}
|
||||
|
||||
logger.log('[FFmpeg] Native Addon 不可用,尝试使用命令行工具');
|
||||
} else {
|
||||
logger.log('[FFmpeg] 未提供 binaryPath,跳过 Native Addon 检测');
|
||||
}
|
||||
|
||||
// 2. 降级到 execFile 实现
|
||||
const execAdapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath, binaryPath, logger);
|
||||
|
||||
logger.log(`[FFmpeg] 检查命令行工具可用性: ${ffmpegPath}`);
|
||||
if (await execAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] 使用命令行工具适配器 ✓');
|
||||
return execAdapter;
|
||||
}
|
||||
|
||||
// 3. 都不可用,返回 execAdapter 但会在使用时报错
|
||||
logger.logError('[FFmpeg] 警告: FFmpeg 不可用,将使用命令行适配器但可能失败');
|
||||
return execAdapter;
|
||||
logger.log('[FFmpeg] Native Addon 不可用,尝试使用命令行工具');
|
||||
} else {
|
||||
logger.log('[FFmpeg] 未提供 binaryPath,跳过 Native Addon 检测');
|
||||
}
|
||||
|
||||
/**
|
||||
// 2. 降级到 execFile 实现
|
||||
const execAdapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath, binaryPath, logger);
|
||||
|
||||
logger.log(`[FFmpeg] 检查命令行工具可用性: ${ffmpegPath}`);
|
||||
if (await execAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] 使用命令行工具适配器 ✓');
|
||||
return execAdapter;
|
||||
}
|
||||
|
||||
// 3. 都不可用,返回 execAdapter 但会在使用时报错
|
||||
logger.logError('[FFmpeg] 警告: FFmpeg 不可用,将使用命令行适配器但可能失败');
|
||||
return execAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置适配器(用于测试或重新初始化)
|
||||
*/
|
||||
static reset(): void {
|
||||
this.instance = null;
|
||||
this.initPromise = null;
|
||||
}
|
||||
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<void> {
|
||||
// 如果当前使用的是 Exec 适配器,更新路径
|
||||
if (this.instance && this.instance instanceof FFmpegExecAdapter) {
|
||||
logger.log(`[FFmpeg] 更新 FFmpeg 路径: ${ffmpegPath}`);
|
||||
this.instance.setFFmpegPath(ffmpegPath);
|
||||
this.instance.setFFprobePath(ffprobePath);
|
||||
static async updateFFmpegPath (
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string,
|
||||
ffprobePath: string
|
||||
): Promise<void> {
|
||||
// 如果当前使用的是 Exec 适配器,更新路径
|
||||
if (this.instance && this.instance instanceof FFmpegExecAdapter) {
|
||||
logger.log(`[FFmpeg] 更新 FFmpeg 路径: ${ffmpegPath}`);
|
||||
this.instance.setFFmpegPath(ffmpegPath);
|
||||
this.instance.setFFprobePath(ffprobePath);
|
||||
|
||||
// 验证新路径是否可用
|
||||
if (await this.instance.isAvailable()) {
|
||||
logger.log('[FFmpeg] 新路径验证成功 ✓');
|
||||
} else {
|
||||
logger.logError('[FFmpeg] 警告: 新 FFmpeg 路径不可用');
|
||||
}
|
||||
}
|
||||
// 验证新路径是否可用
|
||||
if (await this.instance.isAvailable()) {
|
||||
logger.log('[FFmpeg] 新路径验证成功 ✓');
|
||||
} else {
|
||||
logger.logError('[FFmpeg] 警告: 新 FFmpeg 路径不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取当前适配器(不初始化)
|
||||
*/
|
||||
static getCurrentAdapter(): IFFmpegAdapter | null {
|
||||
return this.instance;
|
||||
}
|
||||
static getCurrentAdapter (): IFFmpegAdapter | null {
|
||||
return this.instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,62 +7,62 @@
|
||||
* 视频信息结果
|
||||
*/
|
||||
export interface VideoInfoResult {
|
||||
/** 视频宽度(像素) */
|
||||
width: number;
|
||||
/** 视频高度(像素) */
|
||||
height: number;
|
||||
/** 视频时长(秒) */
|
||||
duration: number;
|
||||
/** 容器格式 */
|
||||
format: string;
|
||||
/** 缩略图 Buffer */
|
||||
thumbnail?: Buffer;
|
||||
/** 视频宽度(像素) */
|
||||
width: number;
|
||||
/** 视频高度(像素) */
|
||||
height: number;
|
||||
/** 视频时长(秒) */
|
||||
duration: number;
|
||||
/** 容器格式 */
|
||||
format: string;
|
||||
/** 缩略图 Buffer */
|
||||
thumbnail?: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg 适配器接口
|
||||
*/
|
||||
export interface IFFmpegAdapter {
|
||||
/** 适配器名称 */
|
||||
readonly name: string;
|
||||
/** 适配器名称 */
|
||||
readonly name: string;
|
||||
|
||||
/** 是否可用 */
|
||||
isAvailable(): Promise<boolean>;
|
||||
/** 是否可用 */
|
||||
isAvailable(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取视频信息(包含缩略图)
|
||||
* @param videoPath 视频文件路径
|
||||
* @returns 视频信息
|
||||
*/
|
||||
getVideoInfo(videoPath: string): Promise<VideoInfoResult>;
|
||||
getVideoInfo(videoPath: string): Promise<VideoInfoResult>;
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取音视频文件时长
|
||||
* @param filePath 文件路径
|
||||
* @returns 时长(秒)
|
||||
*/
|
||||
getDuration(filePath: string): Promise<number>;
|
||||
getDuration(filePath: string): Promise<number>;
|
||||
|
||||
/**
|
||||
/**
|
||||
* 转换音频为 PCM 格式
|
||||
* @param filePath 输入文件路径
|
||||
* @param pcmPath 输出 PCM 文件路径
|
||||
* @returns PCM 数据 Buffer
|
||||
*/
|
||||
convertToPCM(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }>;
|
||||
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>;
|
||||
convertFile(inputFile: string, outputFile: string, format: string): Promise<void>;
|
||||
|
||||
/**
|
||||
/**
|
||||
* 提取视频缩略图
|
||||
* @param videoPath 视频文件路径
|
||||
* @param thumbnailPath 缩略图输出路径
|
||||
*/
|
||||
extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void>;
|
||||
extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -15,108 +15,108 @@ import { dlopen } from 'node:process';
|
||||
* 获取 Native Addon 路径
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
|
||||
*/
|
||||
function getAddonPath(binaryPath: string): string {
|
||||
const platformName = platform();
|
||||
const archName = arch();
|
||||
function getAddonPath (binaryPath: string): string {
|
||||
const platformName = platform();
|
||||
const archName = arch();
|
||||
|
||||
let addonFileName: string = process.platform + '.' + process.arch;
|
||||
let addonPath = path.join(binaryPath, "./native/ffmpeg/", `ffmpegAddon.${addonFileName}.node`);
|
||||
if (!existsSync(addonPath)) {
|
||||
throw new Error(`Unsupported platform: ${platformName} ${archName} - Addon not found at ${addonPath}`);
|
||||
}
|
||||
return addonPath;
|
||||
const addonFileName: string = process.platform + '.' + process.arch;
|
||||
const addonPath = path.join(binaryPath, './native/ffmpeg/', `ffmpegAddon.${addonFileName}.node`);
|
||||
if (!existsSync(addonPath)) {
|
||||
throw new Error(`Unsupported platform: ${platformName} ${archName} - Addon not found at ${addonPath}`);
|
||||
}
|
||||
return addonPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg Native Addon 适配器实现
|
||||
*/
|
||||
export class FFmpegAddonAdapter implements IFFmpegAdapter {
|
||||
public readonly name = 'FFmpegAddon';
|
||||
private addon: FFmpeg | null = null;
|
||||
private binaryPath: string;
|
||||
public readonly name = 'FFmpegAddon';
|
||||
private addon: FFmpeg | null = null;
|
||||
private binaryPath: string;
|
||||
|
||||
constructor(binaryPath: string) {
|
||||
this.binaryPath = binaryPath;
|
||||
}
|
||||
constructor (binaryPath: string) {
|
||||
this.binaryPath = binaryPath;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 检查 Addon 是否可用
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
let 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;
|
||||
}
|
||||
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;
|
||||
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, 'bmp24');
|
||||
async getVideoInfo (videoPath: string): Promise<VideoInfoResult> {
|
||||
const addon = this.ensureAddon();
|
||||
const info = await addon.getVideoInfo(videoPath, 'bmp24');
|
||||
|
||||
return {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
duration: info.duration,
|
||||
format: info.format,
|
||||
thumbnail: info.image,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
duration: info.duration,
|
||||
format: info.format,
|
||||
thumbnail: info.image,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取时长
|
||||
*/
|
||||
async getDuration(filePath: string): Promise<number> {
|
||||
const addon = this.ensureAddon();
|
||||
return addon.getDuration(filePath);
|
||||
}
|
||||
async getDuration (filePath: string): Promise<number> {
|
||||
const addon = this.ensureAddon();
|
||||
return addon.getDuration(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 转换为 PCM
|
||||
*/
|
||||
async convertToPCM(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }> {
|
||||
const addon = this.ensureAddon();
|
||||
const result = await addon.decodeAudioToPCM(filePath, pcmPath, 24000);
|
||||
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;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 转换文件
|
||||
*/
|
||||
async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
|
||||
if (format === 'silk' || format === 'ntsilk') {
|
||||
// 使用 Addon 的 NTSILK 转换
|
||||
await addon.convertToNTSilkTct(inputFile, outputFile);
|
||||
} else {
|
||||
throw new Error(`Format '${format}' is not supported by FFmpeg Addon`);
|
||||
}
|
||||
if (format === 'silk' || format === 'ntsilk') {
|
||||
// 使用 Addon 的 NTSILK 转换
|
||||
await addon.convertToNTSilkTct(inputFile, outputFile);
|
||||
} else {
|
||||
throw new Error(`Format '${format}' is not supported by FFmpeg Addon`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 提取缩略图
|
||||
*/
|
||||
async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
const info = await addon.getVideoInfo(videoPath);
|
||||
async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
const info = await addon.getVideoInfo(videoPath);
|
||||
|
||||
// 将缩略图写入文件
|
||||
await writeFile(thumbnailPath, info.image);
|
||||
}
|
||||
// 将缩略图写入文件
|
||||
await writeFile(thumbnailPath, info.image);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -12,60 +12,60 @@
|
||||
* Video information result object
|
||||
*/
|
||||
export interface VideoInfo {
|
||||
/** Video width in pixels */
|
||||
width: number;
|
||||
/** Video width in pixels */
|
||||
width: number;
|
||||
|
||||
/** Video height in pixels */
|
||||
height: number;
|
||||
/** Video height in pixels */
|
||||
height: number;
|
||||
|
||||
/** Video duration in seconds */
|
||||
duration: number;
|
||||
/** Video duration in seconds */
|
||||
duration: number;
|
||||
|
||||
/** Container format name (e.g., "mp4", "mkv", "avi") */
|
||||
format: string;
|
||||
/** Container format name (e.g., "mp4", "mkv", "avi") */
|
||||
format: string;
|
||||
|
||||
/** Video codec name (e.g., "h264", "hevc", "vp9") */
|
||||
videoCodec: string;
|
||||
/** Video codec name (e.g., "h264", "hevc", "vp9") */
|
||||
videoCodec: string;
|
||||
|
||||
/** First frame thumbnail as BMP image buffer */
|
||||
image: Buffer;
|
||||
/** First frame thumbnail as BMP image buffer */
|
||||
image: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio PCM decoding result object
|
||||
*/
|
||||
export interface AudioPCMResult {
|
||||
/** PCM audio data as 16-bit signed integer samples */
|
||||
pcm: Buffer;
|
||||
/** PCM audio data as 16-bit signed integer samples */
|
||||
pcm: Buffer;
|
||||
|
||||
/** Sample rate in Hz (e.g., 44100, 48000, 24000) */
|
||||
sampleRate: number;
|
||||
/** Sample rate in Hz (e.g., 44100, 48000, 24000) */
|
||||
sampleRate: number;
|
||||
|
||||
/** Number of audio channels (1 for mono, 2 for stereo) */
|
||||
channels: number;
|
||||
/** Number of audio channels (1 for mono, 2 for stereo) */
|
||||
channels: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg interface providing all audio/video processing methods
|
||||
*/
|
||||
export interface FFmpeg {
|
||||
/**
|
||||
/**
|
||||
* Get video information including resolution, duration, format, codec and first frame thumbnail
|
||||
*/
|
||||
getVideoInfo(filePath: string, format?: 'bmp' | 'bmp24'): Promise<VideoInfo>;
|
||||
getVideoInfo(filePath: string, format?: 'bmp' | 'bmp24'): Promise<VideoInfo>;
|
||||
|
||||
/**
|
||||
/**
|
||||
* Get duration of audio or video file in seconds
|
||||
*/
|
||||
getDuration(filePath: string): Promise<number>;
|
||||
getDuration(filePath: string): Promise<number>;
|
||||
|
||||
/**
|
||||
/**
|
||||
* Convert audio file to NTSILK format (WeChat voice message format)
|
||||
*/
|
||||
convertToNTSilkTct(inputPath: string, outputPath: string): Promise<void>;
|
||||
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 }>;
|
||||
}
|
||||
decodeAudioToPCM(filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean, sampleRate: number }>;
|
||||
}
|
||||
|
||||
@@ -18,227 +18,227 @@ const execFileAsync = promisify(execFile);
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
function ensureDirExists(filePath: string): void {
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
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';
|
||||
private downloadAttempted = false;
|
||||
|
||||
constructor(
|
||||
private ffmpegPath: string = 'ffmpeg',
|
||||
private ffprobePath: string = 'ffprobe',
|
||||
private binaryPath?: string,
|
||||
private logger?: LogWrapper
|
||||
) {}
|
||||
public readonly name = 'FFmpegExec';
|
||||
private downloadAttempted = false;
|
||||
|
||||
/**
|
||||
constructor (
|
||||
private ffmpegPath: string = 'ffmpeg',
|
||||
private ffprobePath: string = 'ffprobe',
|
||||
private binaryPath?: string,
|
||||
private logger?: LogWrapper
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 检查 FFmpeg 是否可用,如果不可用则尝试下载
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
// 首先检查当前路径
|
||||
try {
|
||||
await execFileAsync(this.ffmpegPath, ['-version']);
|
||||
return true;
|
||||
} catch {
|
||||
// 如果失败且未尝试下载,尝试下载
|
||||
if (!this.downloadAttempted && this.binaryPath && this.logger) {
|
||||
this.downloadAttempted = true;
|
||||
|
||||
if (process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.log('[FFmpeg] 未找到可用的 FFmpeg,尝试自动下载...');
|
||||
const result = await downloadFFmpegIfNotExists(this.logger);
|
||||
|
||||
if (result.path && result.reset) {
|
||||
// 更新路径
|
||||
if (process.platform === 'win32') {
|
||||
this.ffmpegPath = join(result.path, 'ffmpeg.exe');
|
||||
this.ffprobePath = join(result.path, 'ffprobe.exe');
|
||||
this.logger.log('[FFmpeg] 已更新路径:', this.ffmpegPath);
|
||||
|
||||
// 再次检查
|
||||
try {
|
||||
await execFileAsync(this.ffmpegPath, ['-version']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async isAvailable (): Promise<boolean> {
|
||||
// 首先检查当前路径
|
||||
try {
|
||||
await execFileAsync(this.ffmpegPath, ['-version']);
|
||||
return true;
|
||||
} catch {
|
||||
// 如果失败且未尝试下载,尝试下载
|
||||
if (!this.downloadAttempted && this.binaryPath && this.logger) {
|
||||
this.downloadAttempted = true;
|
||||
|
||||
/**
|
||||
if (process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.log('[FFmpeg] 未找到可用的 FFmpeg,尝试自动下载...');
|
||||
const result = await downloadFFmpegIfNotExists(this.logger);
|
||||
|
||||
if (result.path && result.reset) {
|
||||
// 更新路径
|
||||
if (process.platform === 'win32') {
|
||||
this.ffmpegPath = join(result.path, 'ffmpeg.exe');
|
||||
this.ffprobePath = join(result.path, 'ffprobe.exe');
|
||||
this.logger.log('[FFmpeg] 已更新路径:', this.ffmpegPath);
|
||||
|
||||
// 再次检查
|
||||
try {
|
||||
await execFileAsync(this.ffmpegPath, ['-version']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 FFmpeg 路径
|
||||
*/
|
||||
setFFmpegPath(ffmpegPath: string): void {
|
||||
this.ffmpegPath = ffmpegPath;
|
||||
}
|
||||
setFFmpegPath (ffmpegPath: string): void {
|
||||
this.ffmpegPath = ffmpegPath;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 设置 FFprobe 路径
|
||||
*/
|
||||
setFFprobePath(ffprobePath: string): void {
|
||||
this.ffprobePath = ffprobePath;
|
||||
}
|
||||
setFFprobePath (ffprobePath: string): void {
|
||||
this.ffprobePath = ffprobePath;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取视频信息
|
||||
*/
|
||||
async getVideoInfo(videoPath: string): Promise<VideoInfoResult> {
|
||||
// 获取文件大小和类型
|
||||
const [fileType, duration] = await Promise.all([
|
||||
fileTypeFromFile(videoPath).catch(() => null),
|
||||
this.getDuration(videoPath)
|
||||
]);
|
||||
async getVideoInfo (videoPath: string): Promise<VideoInfoResult> {
|
||||
// 获取文件大小和类型
|
||||
const [fileType, duration] = await Promise.all([
|
||||
fileTypeFromFile(videoPath).catch(() => null),
|
||||
this.getDuration(videoPath),
|
||||
]);
|
||||
|
||||
// 创建临时缩略图路径
|
||||
const thumbnailPath = `${videoPath}.thumbnail.bmp`;
|
||||
let width = 100;
|
||||
let height = 100;
|
||||
let thumbnail: Buffer | undefined;
|
||||
// 创建临时缩略图路径
|
||||
const thumbnailPath = `${videoPath}.thumbnail.bmp`;
|
||||
let width = 100;
|
||||
let height = 100;
|
||||
let thumbnail: Buffer | undefined;
|
||||
|
||||
try {
|
||||
await this.extractThumbnail(videoPath, thumbnailPath);
|
||||
|
||||
// 获取图片尺寸
|
||||
const dimensions = await imageSizeFallBack(thumbnailPath);
|
||||
width = dimensions.width ?? 100;
|
||||
height = dimensions.height ?? 100;
|
||||
|
||||
// 读取缩略图
|
||||
if (existsSync(thumbnailPath)) {
|
||||
thumbnail = readFileSync(thumbnailPath);
|
||||
}
|
||||
} catch (error) {
|
||||
// 使用默认值
|
||||
}
|
||||
try {
|
||||
await this.extractThumbnail(videoPath, thumbnailPath);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
duration,
|
||||
format: fileType?.ext ?? 'mp4',
|
||||
thumbnail,
|
||||
};
|
||||
// 获取图片尺寸
|
||||
const dimensions = await imageSizeFallBack(thumbnailPath);
|
||||
width = dimensions.width ?? 100;
|
||||
height = dimensions.height ?? 100;
|
||||
|
||||
// 读取缩略图
|
||||
if (existsSync(thumbnailPath)) {
|
||||
thumbnail = readFileSync(thumbnailPath);
|
||||
}
|
||||
} catch (_error) {
|
||||
// 使用默认值
|
||||
}
|
||||
|
||||
/**
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
duration,
|
||||
format: fileType?.ext ?? 'mp4',
|
||||
thumbnail,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时长
|
||||
*/
|
||||
async getDuration(filePath: string): Promise<number> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(this.ffprobePath, [
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
filePath
|
||||
]);
|
||||
async getDuration (filePath: string): Promise<number> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(this.ffprobePath, [
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
filePath,
|
||||
]);
|
||||
|
||||
const duration = parseFloat(stdout.trim());
|
||||
return isNaN(duration) ? 60 : duration;
|
||||
} catch {
|
||||
return 60; // 默认时长
|
||||
}
|
||||
const duration = parseFloat(stdout.trim());
|
||||
return isNaN(duration) ? 60 : duration;
|
||||
} catch {
|
||||
return 60; // 默认时长
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 转换为 PCM
|
||||
*/
|
||||
async convertToPCM(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }> {
|
||||
try {
|
||||
ensureDirExists(pcmPath);
|
||||
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
|
||||
]);
|
||||
await execFileAsync(this.ffmpegPath, [
|
||||
'-y',
|
||||
'-i', filePath,
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-f', 's16le',
|
||||
pcmPath,
|
||||
]);
|
||||
|
||||
if (!existsSync(pcmPath)) {
|
||||
throw new Error('转换PCM失败,输出文件不存在');
|
||||
}
|
||||
if (!existsSync(pcmPath)) {
|
||||
throw new Error('转换PCM失败,输出文件不存在');
|
||||
}
|
||||
|
||||
return { result: true, sampleRate: 24000 };
|
||||
} catch (error: any) {
|
||||
throw new Error(`FFmpeg处理转换出错: ${error.message}`);
|
||||
}
|
||||
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);
|
||||
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
try {
|
||||
ensureDirExists(outputFile);
|
||||
|
||||
const params = format === 'amr'
|
||||
? [
|
||||
'-f', 's16le',
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-i', inputFile,
|
||||
'-ar', '8000',
|
||||
'-b:a', '12.2k',
|
||||
'-y',
|
||||
outputFile
|
||||
]
|
||||
: [
|
||||
'-f', 's16le',
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-i', inputFile,
|
||||
'-y',
|
||||
outputFile
|
||||
];
|
||||
const params = format === 'amr'
|
||||
? [
|
||||
'-f', 's16le',
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-i', inputFile,
|
||||
'-ar', '8000',
|
||||
'-b:a', '12.2k',
|
||||
'-y',
|
||||
outputFile,
|
||||
]
|
||||
: [
|
||||
'-f', 's16le',
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-i', inputFile,
|
||||
'-y',
|
||||
outputFile,
|
||||
];
|
||||
|
||||
await execFileAsync(this.ffmpegPath, params);
|
||||
await execFileAsync(this.ffmpegPath, params);
|
||||
|
||||
if (!existsSync(outputFile)) {
|
||||
throw new Error('转换失败,输出文件不存在');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting file:', error);
|
||||
throw new Error(`文件转换失败: ${(error as Error).message}`);
|
||||
}
|
||||
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);
|
||||
async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
try {
|
||||
ensureDirExists(thumbnailPath);
|
||||
|
||||
const { stderr } = await execFileAsync(this.ffmpegPath, [
|
||||
'-i', videoPath,
|
||||
'-ss', '00:00:01.000',
|
||||
'-vframes', '1',
|
||||
'-y', // 覆盖输出文件
|
||||
thumbnailPath
|
||||
]);
|
||||
const { stderr } = await execFileAsync(this.ffmpegPath, [
|
||||
'-i', videoPath,
|
||||
'-ss', '00:00:01.000',
|
||||
'-vframes', '1',
|
||||
'-y', // 覆盖输出文件
|
||||
thumbnailPath,
|
||||
]);
|
||||
|
||||
if (!existsSync(thumbnailPath)) {
|
||||
throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting thumbnail:', error);
|
||||
throw new Error(`提取缩略图失败: ${(error as Error).message}`);
|
||||
}
|
||||
if (!existsSync(thumbnailPath)) {
|
||||
throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting thumbnail:', error);
|
||||
throw new Error(`提取缩略图失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,137 +8,137 @@ import { FFmpegAdapterFactory } from './ffmpeg-adapter-factory';
|
||||
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
|
||||
|
||||
const getFFmpegPath = (tool: string, binaryPath?: string): string => {
|
||||
if (process.platform === 'win32' && binaryPath) {
|
||||
const exeName = `${tool}.exe`;
|
||||
const localPath = path.join(binaryPath, 'ffmpeg', exeName);
|
||||
const isLocalExeExists = existsSync(localPath);
|
||||
return isLocalExeExists ? localPath : exeName;
|
||||
}
|
||||
return tool;
|
||||
if (process.platform === 'win32' && binaryPath) {
|
||||
const exeName = `${tool}.exe`;
|
||||
const localPath = path.join(binaryPath, 'ffmpeg', exeName);
|
||||
const isLocalExeExists = existsSync(localPath);
|
||||
return isLocalExeExists ? localPath : exeName;
|
||||
}
|
||||
return tool;
|
||||
};
|
||||
|
||||
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 adapter: IFFmpegAdapter | null = null;
|
||||
private static initialized = false;
|
||||
|
||||
/**
|
||||
/**
|
||||
* 初始化 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);
|
||||
|
||||
// 立即初始化适配器(会触发自动下载等逻辑)
|
||||
this.adapter = await FFmpegAdapterFactory.getAdapter(
|
||||
logger,
|
||||
FFMPEG_CMD,
|
||||
FFPROBE_CMD,
|
||||
binaryPath
|
||||
);
|
||||
|
||||
this.initialized = true;
|
||||
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);
|
||||
|
||||
// 立即初始化适配器(会触发自动下载等逻辑)
|
||||
this.adapter = await FFmpegAdapterFactory.getAdapter(
|
||||
logger,
|
||||
FFMPEG_CMD,
|
||||
FFPROBE_CMD,
|
||||
binaryPath
|
||||
);
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 FFmpeg 适配器
|
||||
*/
|
||||
private static async getAdapter(): Promise<IFFmpegAdapter> {
|
||||
if (!this.adapter) {
|
||||
throw new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
|
||||
}
|
||||
return this.adapter;
|
||||
private static async getAdapter (): Promise<IFFmpegAdapter> {
|
||||
if (!this.adapter) {
|
||||
throw new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
|
||||
}
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 设置 FFmpeg 路径并更新适配器
|
||||
* @deprecated 建议使用 init() 方法初始化
|
||||
*/
|
||||
public static async setFfmpegPath(ffmpegPath: string, logger: LogWrapper): Promise<void> {
|
||||
if (platform() === 'win32') {
|
||||
FFMPEG_CMD = path.join(ffmpegPath, 'ffmpeg.exe');
|
||||
FFPROBE_CMD = path.join(ffmpegPath, 'ffprobe.exe');
|
||||
logger.log('[Check] ffmpeg:', FFMPEG_CMD);
|
||||
logger.log('[Check] ffprobe:', FFPROBE_CMD);
|
||||
public static async setFfmpegPath (ffmpegPath: string, logger: LogWrapper): Promise<void> {
|
||||
if (platform() === 'win32') {
|
||||
FFMPEG_CMD = path.join(ffmpegPath, 'ffmpeg.exe');
|
||||
FFPROBE_CMD = path.join(ffmpegPath, 'ffprobe.exe');
|
||||
logger.log('[Check] ffmpeg:', FFMPEG_CMD);
|
||||
logger.log('[Check] ffprobe:', FFPROBE_CMD);
|
||||
|
||||
// 更新适配器路径
|
||||
await FFmpegAdapterFactory.updateFFmpegPath(logger, FFMPEG_CMD, FFPROBE_CMD);
|
||||
}
|
||||
// 更新适配器路径
|
||||
await FFmpegAdapterFactory.updateFFmpegPath(logger, FFMPEG_CMD, FFPROBE_CMD);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 提取视频缩略图
|
||||
*/
|
||||
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.extractThumbnail(videoPath, thumbnailPath);
|
||||
}
|
||||
public static async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.extractThumbnail(videoPath, thumbnailPath);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 转换音频文件
|
||||
*/
|
||||
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.convertFile(inputFile, outputFile, format);
|
||||
}
|
||||
public static async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.convertFile(inputFile, outputFile, format);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 转换为 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 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();
|
||||
public static async getVideoInfo (videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
||||
const adapter = await this.getAdapter();
|
||||
|
||||
try {
|
||||
// 获取文件大小
|
||||
const fileSize = statSync(videoPath).size;
|
||||
try {
|
||||
// 获取文件大小
|
||||
const fileSize = statSync(videoPath).size;
|
||||
|
||||
// 使用适配器获取视频信息
|
||||
const videoInfo = await adapter.getVideoInfo(videoPath);
|
||||
// 使用适配器获取视频信息
|
||||
const videoInfo = await adapter.getVideoInfo(videoPath);
|
||||
|
||||
// 如果提供了缩略图路径且适配器返回了缩略图,保存到指定路径
|
||||
if (thumbnailPath && videoInfo.thumbnail) {
|
||||
writeFileSync(thumbnailPath, videoInfo.thumbnail);
|
||||
}
|
||||
// 如果提供了缩略图路径且适配器返回了缩略图,保存到指定路径
|
||||
if (thumbnailPath && videoInfo.thumbnail) {
|
||||
writeFileSync(thumbnailPath, videoInfo.thumbnail);
|
||||
}
|
||||
|
||||
const result: VideoInfo = {
|
||||
width: videoInfo.width,
|
||||
height: videoInfo.height,
|
||||
time: videoInfo.duration,
|
||||
format: videoInfo.format,
|
||||
size: fileSize,
|
||||
filePath: videoPath
|
||||
};
|
||||
const result: VideoInfo = {
|
||||
width: videoInfo.width,
|
||||
height: videoInfo.height,
|
||||
time: videoInfo.duration,
|
||||
format: videoInfo.format,
|
||||
size: fileSize,
|
||||
filePath: videoPath,
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// 降级处理:返回默认值
|
||||
const fileType = await fileTypeFromFile(videoPath).catch(() => null);
|
||||
const fileSize = statSync(videoPath).size;
|
||||
return result;
|
||||
} catch (_error) {
|
||||
// 降级处理:返回默认值
|
||||
const fileType = await fileTypeFromFile(videoPath).catch(() => null);
|
||||
const fileSize = statSync(videoPath).size;
|
||||
|
||||
return {
|
||||
width: 100,
|
||||
height: 100,
|
||||
time: 60,
|
||||
format: fileType?.ext ?? 'mp4',
|
||||
size: fileSize,
|
||||
filePath: videoPath
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: 100,
|
||||
height: 100,
|
||||
time: 60,
|
||||
format: fileType?.ext ?? 'mp4',
|
||||
size: fileSize,
|
||||
filePath: videoPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,120 +2,120 @@ import { Peer } from '@/core';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
class TimeBasedCache<K, V> {
|
||||
private cache = new Map<K, { value: V, timestamp: number, frequency: number }>();
|
||||
private keyList = new Set<K>();
|
||||
private operationCount = 0;
|
||||
private cache = new Map<K, { value: V, timestamp: number, frequency: number }>();
|
||||
private keyList = new Set<K>();
|
||||
private operationCount = 0;
|
||||
|
||||
constructor(private maxCapacity: number, private ttl: number = 30 * 1000 * 60, private cleanupCount: number = 10) {}
|
||||
constructor (private maxCapacity: number, private ttl: number = 30 * 1000 * 60, private cleanupCount: number = 10) {}
|
||||
|
||||
public put(key: K, value: V): void {
|
||||
const timestamp = Date.now();
|
||||
const cacheEntry = { value, timestamp, frequency: 1 };
|
||||
this.cache.set(key, cacheEntry);
|
||||
this.keyList.add(key);
|
||||
this.operationCount++;
|
||||
if (this.keyList.size > this.maxCapacity) this.evict();
|
||||
if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
|
||||
public put (key: K, value: V): void {
|
||||
const timestamp = Date.now();
|
||||
const cacheEntry = { value, timestamp, frequency: 1 };
|
||||
this.cache.set(key, cacheEntry);
|
||||
this.keyList.add(key);
|
||||
this.operationCount++;
|
||||
if (this.keyList.size > this.maxCapacity) this.evict();
|
||||
if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
|
||||
}
|
||||
|
||||
public get (key: K): V | undefined {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && Date.now() - entry.timestamp < this.ttl) {
|
||||
entry.timestamp = Date.now();
|
||||
entry.frequency++;
|
||||
this.operationCount++;
|
||||
if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
|
||||
return entry.value;
|
||||
} else {
|
||||
this.deleteKey(key);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public get(key: K): V | undefined {
|
||||
private cleanup (count: number): void {
|
||||
const currentTime = Date.now();
|
||||
let cleaned = 0;
|
||||
for (const key of this.keyList) {
|
||||
if (cleaned >= count) break;
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && currentTime - entry.timestamp >= this.ttl) {
|
||||
this.deleteKey(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
this.operationCount = 0; // 重置操作计数器
|
||||
}
|
||||
|
||||
private deleteKey (key: K): void {
|
||||
this.cache.delete(key);
|
||||
this.keyList.delete(key);
|
||||
}
|
||||
|
||||
private evict (): void {
|
||||
while (this.keyList.size > this.maxCapacity) {
|
||||
let oldestKey: K | undefined;
|
||||
let minFrequency = Infinity;
|
||||
for (const key of this.keyList) {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && Date.now() - entry.timestamp < this.ttl) {
|
||||
entry.timestamp = Date.now();
|
||||
entry.frequency++;
|
||||
this.operationCount++;
|
||||
if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
|
||||
return entry.value;
|
||||
} else {
|
||||
this.deleteKey(key);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private cleanup(count: number): void {
|
||||
const currentTime = Date.now();
|
||||
let cleaned = 0;
|
||||
for (const key of this.keyList) {
|
||||
if (cleaned >= count) break;
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && currentTime - entry.timestamp >= this.ttl) {
|
||||
this.deleteKey(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
this.operationCount = 0; // 重置操作计数器
|
||||
}
|
||||
|
||||
private deleteKey(key: K): void {
|
||||
this.cache.delete(key);
|
||||
this.keyList.delete(key);
|
||||
}
|
||||
|
||||
private evict(): void {
|
||||
while (this.keyList.size > this.maxCapacity) {
|
||||
let oldestKey: K | undefined;
|
||||
let minFrequency = Infinity;
|
||||
for (const key of this.keyList) {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && entry.frequency < minFrequency) {
|
||||
minFrequency = entry.frequency;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey !== undefined) this.deleteKey(oldestKey);
|
||||
if (entry && entry.frequency < minFrequency) {
|
||||
minFrequency = entry.frequency;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey !== undefined) this.deleteKey(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FileUUIDData {
|
||||
peer: Peer;
|
||||
modelId?: string;
|
||||
fileId?: string;
|
||||
msgId?: string;
|
||||
elementId?: string;
|
||||
fileUUID?: string;
|
||||
peer: Peer;
|
||||
modelId?: string;
|
||||
fileId?: string;
|
||||
msgId?: string;
|
||||
elementId?: string;
|
||||
fileUUID?: string;
|
||||
}
|
||||
|
||||
class FileUUIDManager {
|
||||
private cache: TimeBasedCache<string, FileUUIDData>;
|
||||
private cache: TimeBasedCache<string, FileUUIDData>;
|
||||
|
||||
constructor(ttl: number) {
|
||||
this.cache = new TimeBasedCache<string, FileUUIDData>(5000, ttl);
|
||||
}
|
||||
constructor (ttl: number) {
|
||||
this.cache = new TimeBasedCache<string, FileUUIDData>(5000, ttl);
|
||||
}
|
||||
|
||||
public encode(data: FileUUIDData, endString: string = '', customUUID?: string): string {
|
||||
const uuid = customUUID ? customUUID : randomUUID().replace(/-/g, '') + endString;
|
||||
this.cache.put(uuid, data);
|
||||
return uuid;
|
||||
}
|
||||
public encode (data: FileUUIDData, endString: string = '', customUUID?: string): string {
|
||||
const uuid = customUUID || randomUUID().replace(/-/g, '') + endString;
|
||||
this.cache.put(uuid, data);
|
||||
return uuid;
|
||||
}
|
||||
|
||||
public decode(uuid: string): FileUUIDData | undefined {
|
||||
return this.cache.get(uuid);
|
||||
}
|
||||
public decode (uuid: string): FileUUIDData | undefined {
|
||||
return this.cache.get(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
export class FileNapCatOneBotUUIDWrap {
|
||||
private manager: FileUUIDManager;
|
||||
private manager: FileUUIDManager;
|
||||
|
||||
constructor(ttl: number = 86400000) {
|
||||
this.manager = new FileUUIDManager(ttl);
|
||||
}
|
||||
constructor (ttl: number = 86400000) {
|
||||
this.manager = new FileUUIDManager(ttl);
|
||||
}
|
||||
|
||||
public encodeModelId(peer: Peer, modelId: string, fileId: string, fileUUID: string = '', endString: string = '', customUUID?: string): string {
|
||||
return this.manager.encode({ peer, modelId, fileId, fileUUID }, endString, customUUID);
|
||||
}
|
||||
public encodeModelId (peer: Peer, modelId: string, fileId: string, fileUUID: string = '', endString: string = '', customUUID?: string): string {
|
||||
return this.manager.encode({ peer, modelId, fileId, fileUUID }, endString, customUUID);
|
||||
}
|
||||
|
||||
public decodeModelId(uuid: string): FileUUIDData | undefined {
|
||||
return this.manager.decode(uuid);
|
||||
}
|
||||
public decodeModelId (uuid: string): FileUUIDData | undefined {
|
||||
return this.manager.decode(uuid);
|
||||
}
|
||||
|
||||
public encode(peer: Peer, msgId: string, elementId: string, fileUUID: string = '', customUUID?: string): string {
|
||||
return this.manager.encode({ peer, msgId, elementId, fileUUID }, '', customUUID);
|
||||
}
|
||||
public encode (peer: Peer, msgId: string, elementId: string, fileUUID: string = '', customUUID?: string): string {
|
||||
return this.manager.encode({ peer, msgId, elementId, fileUUID }, '', customUUID);
|
||||
}
|
||||
|
||||
public decode(uuid: string): FileUUIDData | undefined {
|
||||
return this.manager.decode(uuid);
|
||||
}
|
||||
public decode (uuid: string): FileUUIDData | undefined {
|
||||
return this.manager.decode(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
export const FileNapCatOneBotUUID = new FileNapCatOneBotUUIDWrap();
|
||||
export const FileNapCatOneBotUUID = new FileNapCatOneBotUUIDWrap();
|
||||
|
||||
@@ -5,205 +5,205 @@ import path from 'node:path';
|
||||
import { solveProblem } from '@/common/helper';
|
||||
|
||||
export interface HttpDownloadOptions {
|
||||
url: string;
|
||||
headers?: Record<string, string> | string;
|
||||
url: string;
|
||||
headers?: Record<string, string> | string;
|
||||
}
|
||||
|
||||
type Uri2LocalRes = {
|
||||
success: boolean,
|
||||
errMsg: string,
|
||||
fileName: string,
|
||||
path: string
|
||||
success: boolean,
|
||||
errMsg: string,
|
||||
fileName: string,
|
||||
path: string;
|
||||
};
|
||||
|
||||
// 定义一个异步函数来检查文件是否存在
|
||||
export function checkFileExist (path: string, timeout: number = 3000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
function check () {
|
||||
if (fs.existsSync(path)) {
|
||||
resolve();
|
||||
} else if (Date.now() - startTime > timeout) {
|
||||
reject(new Error(`文件不存在: ${path}`));
|
||||
} else {
|
||||
setTimeout(check, 100);
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
// 定义一个异步函数来检查文件是否存在
|
||||
export function checkFileExist(path: string, timeout: number = 3000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
function check() {
|
||||
if (fs.existsSync(path)) {
|
||||
resolve();
|
||||
} else if (Date.now() - startTime > timeout) {
|
||||
reject(new Error(`文件不存在: ${path}`));
|
||||
} else {
|
||||
setTimeout(check, 100);
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
// 定义一个异步函数来检查文件是否存在
|
||||
export async function checkFileExistV2(path: string, timeout: number = 3000): Promise<void> {
|
||||
// 使用 Promise.race 来同时进行文件状态检查和超时计时
|
||||
// Promise.race 会返回第一个解决(resolve)或拒绝(reject)的 Promise
|
||||
await Promise.race([
|
||||
checkFile(path),
|
||||
timeoutPromise(timeout, `文件不存在: ${path}`),
|
||||
]);
|
||||
export async function checkFileExistV2 (path: string, timeout: number = 3000): Promise<void> {
|
||||
// 使用 Promise.race 来同时进行文件状态检查和超时计时
|
||||
// Promise.race 会返回第一个解决(resolve)或拒绝(reject)的 Promise
|
||||
await Promise.race([
|
||||
checkFile(path),
|
||||
timeoutPromise(timeout, `文件不存在: ${path}`),
|
||||
]);
|
||||
}
|
||||
|
||||
// 转换超时时间至 Promise
|
||||
function timeoutPromise(timeout: number, errorMsg: string): Promise<void> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(errorMsg));
|
||||
}, timeout);
|
||||
});
|
||||
function timeoutPromise (timeout: number, errorMsg: string): Promise<void> {
|
||||
return new Promise((_resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(errorMsg));
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
// 异步检查文件是否存在
|
||||
async function checkFile(path: string): Promise<void> {
|
||||
try {
|
||||
await stat(path);
|
||||
} catch (error: unknown) {
|
||||
if ((error as Error & { code: string }).code === 'ENOENT') {
|
||||
// 如果文件不存在,则抛出一个错误
|
||||
throw new Error(`文件不存在: ${path}`);
|
||||
} else {
|
||||
// 对于 stat 调用的其他错误,重新抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// 如果文件存在,则无需做任何事情,Promise 解决(resolve)自身
|
||||
}
|
||||
|
||||
export function calculateFileMD5(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 创建一个流式读取器
|
||||
const stream = fs.createReadStream(filePath);
|
||||
const hash = crypto.createHash('md5');
|
||||
|
||||
stream.on('data', (data) => {
|
||||
// 当读取到数据时,更新哈希对象的状态
|
||||
hash.update(data);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
// 文件读取完成,计算哈希
|
||||
const md5 = hash.digest('hex');
|
||||
resolve(md5);
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
// 处理可能的读取错误
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function tryDownload(options: string | HttpDownloadOptions, useReferer: boolean = false): Promise<Response> {
|
||||
let url: string;
|
||||
let headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36',
|
||||
};
|
||||
if (typeof options === 'string') {
|
||||
url = options;
|
||||
headers['Host'] = new URL(url).hostname;
|
||||
async function checkFile (path: string): Promise<void> {
|
||||
try {
|
||||
await stat(path);
|
||||
} catch (error: unknown) {
|
||||
if ((error as Error & { code: string; }).code === 'ENOENT') {
|
||||
// 如果文件不存在,则抛出一个错误
|
||||
throw new Error(`文件不存在: ${path}`);
|
||||
} else {
|
||||
url = options.url;
|
||||
if (options.headers) {
|
||||
if (typeof options.headers === 'string') {
|
||||
headers = JSON.parse(options.headers);
|
||||
} else {
|
||||
headers = options.headers;
|
||||
}
|
||||
}
|
||||
// 对于 stat 调用的其他错误,重新抛出
|
||||
throw error;
|
||||
}
|
||||
if (useReferer && !headers['Referer']) {
|
||||
headers['Referer'] = url;
|
||||
}
|
||||
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
|
||||
if (err.cause) {
|
||||
throw err.cause;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
return fetchRes;
|
||||
}
|
||||
// 如果文件存在,则无需做任何事情,Promise 解决(resolve)自身
|
||||
}
|
||||
|
||||
export async function httpDownload(options: string | HttpDownloadOptions): Promise<Buffer> {
|
||||
const useReferer = typeof options === 'string';
|
||||
let resp = await tryDownload(options);
|
||||
if (resp.status === 403 && useReferer) {
|
||||
resp = await tryDownload(options, true);
|
||||
export function calculateFileMD5 (filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 创建一个流式读取器
|
||||
const stream = fs.createReadStream(filePath);
|
||||
const hash = crypto.createHash('md5');
|
||||
|
||||
stream.on('data', (data) => {
|
||||
// 当读取到数据时,更新哈希对象的状态
|
||||
hash.update(data);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
// 文件读取完成,计算哈希
|
||||
const md5 = hash.digest('hex');
|
||||
resolve(md5);
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
// 处理可能的读取错误
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function tryDownload (options: string | HttpDownloadOptions, useReferer: boolean = false): Promise<Response> {
|
||||
let url: string;
|
||||
let headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36',
|
||||
};
|
||||
if (typeof options === 'string') {
|
||||
url = options;
|
||||
headers['Host'] = new URL(url).hostname;
|
||||
} else {
|
||||
url = options.url;
|
||||
if (options.headers) {
|
||||
if (typeof options.headers === 'string') {
|
||||
headers = JSON.parse(options.headers);
|
||||
} else {
|
||||
headers = options.headers;
|
||||
}
|
||||
}
|
||||
if (!resp.ok) throw new Error(`下载文件失败: ${resp.statusText}`);
|
||||
const blob = await resp.blob();
|
||||
const buffer = await blob.arrayBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
if (useReferer && !headers['Referer']) {
|
||||
headers['Referer'] = url;
|
||||
}
|
||||
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
|
||||
if (err.cause) {
|
||||
throw err.cause;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
return fetchRes;
|
||||
}
|
||||
|
||||
export async function httpDownload (options: string | HttpDownloadOptions): Promise<Buffer> {
|
||||
const useReferer = typeof options === 'string';
|
||||
let resp = await tryDownload(options);
|
||||
if (resp.status === 403 && useReferer) {
|
||||
resp = await tryDownload(options, true);
|
||||
}
|
||||
if (!resp.ok) throw new Error(`下载文件失败: ${resp.statusText}`);
|
||||
const blob = await resp.blob();
|
||||
const buffer = await blob.arrayBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
export enum FileUriType {
|
||||
Unknown = 0,
|
||||
Local = 1,
|
||||
Remote = 2,
|
||||
Base64 = 3
|
||||
Unknown = 0,
|
||||
Local = 1,
|
||||
Remote = 2,
|
||||
Base64 = 3,
|
||||
}
|
||||
|
||||
export async function checkUriType(Uri: string) {
|
||||
const LocalFileRet = await solveProblem((uri: string) => {
|
||||
if (fs.existsSync(path.normalize(uri))) {
|
||||
return { Uri: path.normalize(uri), Type: FileUriType.Local };
|
||||
}
|
||||
return undefined;
|
||||
}, Uri);
|
||||
if (LocalFileRet) return LocalFileRet;
|
||||
const OtherFileRet = await solveProblem((uri: string) => {
|
||||
// 再判断是否是Http
|
||||
if (uri.startsWith('http:') || uri.startsWith('https:')) {
|
||||
return { Uri: uri, Type: FileUriType.Remote };
|
||||
}
|
||||
// 再判断是否是Base64
|
||||
if (uri.startsWith('base64:')) {
|
||||
return { Uri: uri, Type: FileUriType.Base64 };
|
||||
}
|
||||
// 默认file://
|
||||
if (uri.startsWith('file:')) {
|
||||
const filePath: string = decodeURIComponent(uri.startsWith('file:///') && process.platform === 'win32' ? uri.slice(8) : uri.slice(7));
|
||||
return { Uri: filePath, Type: FileUriType.Local };
|
||||
}
|
||||
if (uri.startsWith('data:')) {
|
||||
const data = uri.split(',')[1];
|
||||
if (data) return { Uri: data, Type: FileUriType.Base64 };
|
||||
}
|
||||
return;
|
||||
}, Uri);
|
||||
if (OtherFileRet) return OtherFileRet;
|
||||
|
||||
return { Uri: Uri, Type: FileUriType.Unknown };
|
||||
}
|
||||
|
||||
export async function uriToLocalFile(dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>): Promise<Uri2LocalRes> {
|
||||
const { Uri: HandledUri, Type: UriType } = await checkUriType(uri);
|
||||
|
||||
const filePath = path.join(dir, filename);
|
||||
|
||||
switch (UriType) {
|
||||
case FileUriType.Local: {
|
||||
const fileExt = path.extname(HandledUri);
|
||||
const localFileName = path.basename(HandledUri, fileExt) + fileExt;
|
||||
const tempFilePath = path.join(dir, filename + fileExt);
|
||||
fs.copyFileSync(HandledUri, tempFilePath);
|
||||
return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath };
|
||||
}
|
||||
|
||||
case FileUriType.Remote: {
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
|
||||
case FileUriType.Base64: {
|
||||
const base64 = HandledUri.replace(/^base64:\/\//, '');
|
||||
const base64Buffer = Buffer.from(base64, 'base64');
|
||||
fs.writeFileSync(filePath, base64Buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' };
|
||||
export async function checkUriType (Uri: string) {
|
||||
const LocalFileRet = await solveProblem((uri: string) => {
|
||||
if (fs.existsSync(path.normalize(uri))) {
|
||||
return { Uri: path.normalize(uri), Type: FileUriType.Local };
|
||||
}
|
||||
return undefined;
|
||||
}, Uri);
|
||||
if (LocalFileRet) return LocalFileRet;
|
||||
const OtherFileRet = await solveProblem((uri: string) => {
|
||||
// 再判断是否是Http
|
||||
if (uri.startsWith('http:') || uri.startsWith('https:')) {
|
||||
return { Uri: uri, Type: FileUriType.Remote };
|
||||
}
|
||||
// 再判断是否是Base64
|
||||
if (uri.startsWith('base64:')) {
|
||||
return { Uri: uri, Type: FileUriType.Base64 };
|
||||
}
|
||||
// 默认file://
|
||||
if (uri.startsWith('file:')) {
|
||||
const filePath: string = decodeURIComponent(uri.startsWith('file:///') && process.platform === 'win32' ? uri.slice(8) : uri.slice(7));
|
||||
return { Uri: filePath, Type: FileUriType.Local };
|
||||
}
|
||||
if (uri.startsWith('data:')) {
|
||||
const data = uri.split(',')[1];
|
||||
if (data) return { Uri: data, Type: FileUriType.Base64 };
|
||||
}
|
||||
return undefined;
|
||||
}, Uri);
|
||||
if (OtherFileRet) return OtherFileRet;
|
||||
|
||||
return { Uri, Type: FileUriType.Unknown };
|
||||
}
|
||||
|
||||
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>): Promise<Uri2LocalRes> {
|
||||
const { Uri: HandledUri, Type: UriType } = await checkUriType(uri);
|
||||
|
||||
const filePath = path.join(dir, filename);
|
||||
|
||||
switch (UriType) {
|
||||
case FileUriType.Local: {
|
||||
const fileExt = path.extname(HandledUri);
|
||||
const localFileName = path.basename(HandledUri, fileExt) + fileExt;
|
||||
const tempFilePath = path.join(dir, filename + fileExt);
|
||||
fs.copyFileSync(HandledUri, tempFilePath);
|
||||
return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath };
|
||||
}
|
||||
|
||||
case FileUriType.Remote: {
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
|
||||
case FileUriType.Base64: {
|
||||
const base64 = HandledUri.replace(/^base64:\/\//, '');
|
||||
const base64Buffer = Buffer.from(base64, 'base64');
|
||||
fs.writeFileSync(filePath, base64Buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,113 +2,115 @@ import * as crypto from 'node:crypto';
|
||||
import { PacketMsg } from '@/core/packet/message/message';
|
||||
|
||||
interface ForwardMsgJson {
|
||||
app: string
|
||||
config: ForwardMsgJsonConfig,
|
||||
desc: string,
|
||||
extra: ForwardMsgJsonExtra,
|
||||
meta: ForwardMsgJsonMeta,
|
||||
prompt: string,
|
||||
ver: string,
|
||||
view: string
|
||||
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
|
||||
autosize: number,
|
||||
forward: number,
|
||||
round: number,
|
||||
type: string,
|
||||
width: number
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonExtra {
|
||||
filename: string,
|
||||
tsum: number,
|
||||
filename: string,
|
||||
tsum: number,
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMeta {
|
||||
detail: ForwardMsgJsonMetaDetail
|
||||
detail: ForwardMsgJsonMetaDetail
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMetaDetail {
|
||||
news: {
|
||||
text: string
|
||||
}[],
|
||||
resid: string,
|
||||
source: string,
|
||||
summary: string,
|
||||
uniseq: string
|
||||
news: {
|
||||
text: string
|
||||
}[],
|
||||
resid: string,
|
||||
source: string,
|
||||
summary: string,
|
||||
uniseq: string
|
||||
}
|
||||
|
||||
interface ForwardAdaptMsg {
|
||||
senderName?: string;
|
||||
isGroupMsg?: boolean;
|
||||
msg?: ForwardAdaptMsgElement[];
|
||||
senderName?: string;
|
||||
isGroupMsg?: boolean;
|
||||
msg?: ForwardAdaptMsgElement[];
|
||||
}
|
||||
|
||||
interface ForwardAdaptMsgElement {
|
||||
preview?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export class ForwardMsgBuilder {
|
||||
private static build(resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||
const id = 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',
|
||||
};
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||
const id = 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 fromResId (resId: string): ForwardMsgJson {
|
||||
return this.build(resId, []);
|
||||
}
|
||||
|
||||
static fromPacketMsg(resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: 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);
|
||||
}
|
||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,280 +1,280 @@
|
||||
export interface ResourceConfig<T extends any[], R> {
|
||||
/** 资源获取函数 */
|
||||
resourceFn: (...args: T) => Promise<R>;
|
||||
/** 失败后禁用时间(毫秒),默认 30 秒 */
|
||||
disableTime?: number;
|
||||
/** 最大重试次数,默认 3 次 */
|
||||
maxRetries?: number;
|
||||
/** 主动测试间隔(毫秒),默认 60 秒 */
|
||||
healthCheckInterval?: number;
|
||||
/** 最大健康检查失败次数,超过后永久禁用,默认 5 次 */
|
||||
maxHealthCheckFailures?: number;
|
||||
/** 健康检查函数,如果提供则优先使用此函数进行健康检查 */
|
||||
healthCheckFn?: (...args: T) => Promise<boolean>;
|
||||
/** 测试参数(用于健康检查) */
|
||||
testArgs?: T;
|
||||
/** 资源获取函数 */
|
||||
resourceFn: (...args: T) => Promise<R>;
|
||||
/** 失败后禁用时间(毫秒),默认 30 秒 */
|
||||
disableTime?: number;
|
||||
/** 最大重试次数,默认 3 次 */
|
||||
maxRetries?: number;
|
||||
/** 主动测试间隔(毫秒),默认 60 秒 */
|
||||
healthCheckInterval?: number;
|
||||
/** 最大健康检查失败次数,超过后永久禁用,默认 5 次 */
|
||||
maxHealthCheckFailures?: number;
|
||||
/** 健康检查函数,如果提供则优先使用此函数进行健康检查 */
|
||||
healthCheckFn?: (...args: T) => Promise<boolean>;
|
||||
/** 测试参数(用于健康检查) */
|
||||
testArgs?: T;
|
||||
}
|
||||
|
||||
interface ResourceTypeState {
|
||||
/** 资源配置 */
|
||||
config: {
|
||||
resourceFn: (...args: any[]) => Promise<any>;
|
||||
healthCheckFn?: (...args: any[]) => Promise<boolean>;
|
||||
disableTime: number;
|
||||
maxRetries: number;
|
||||
healthCheckInterval: number;
|
||||
maxHealthCheckFailures: number;
|
||||
testArgs?: any[];
|
||||
};
|
||||
/** 是否启用 */
|
||||
isEnabled: boolean;
|
||||
/** 禁用截止时间 */
|
||||
disableUntil: number;
|
||||
/** 当前重试次数 */
|
||||
currentRetries: number;
|
||||
/** 健康检查失败次数 */
|
||||
healthCheckFailureCount: number;
|
||||
/** 是否永久禁用 */
|
||||
isPermanentlyDisabled: boolean;
|
||||
/** 上次健康检查时间 */
|
||||
lastHealthCheckTime: number;
|
||||
/** 成功次数统计 */
|
||||
successCount: number;
|
||||
/** 失败次数统计 */
|
||||
failureCount: number;
|
||||
/** 资源配置 */
|
||||
config: {
|
||||
resourceFn: (...args: any[]) => Promise<any>;
|
||||
healthCheckFn?: (...args: any[]) => Promise<boolean>;
|
||||
disableTime: number;
|
||||
maxRetries: number;
|
||||
healthCheckInterval: number;
|
||||
maxHealthCheckFailures: number;
|
||||
testArgs?: any[];
|
||||
};
|
||||
/** 是否启用 */
|
||||
isEnabled: boolean;
|
||||
/** 禁用截止时间 */
|
||||
disableUntil: number;
|
||||
/** 当前重试次数 */
|
||||
currentRetries: number;
|
||||
/** 健康检查失败次数 */
|
||||
healthCheckFailureCount: number;
|
||||
/** 是否永久禁用 */
|
||||
isPermanentlyDisabled: boolean;
|
||||
/** 上次健康检查时间 */
|
||||
lastHealthCheckTime: number;
|
||||
/** 成功次数统计 */
|
||||
successCount: number;
|
||||
/** 失败次数统计 */
|
||||
failureCount: number;
|
||||
}
|
||||
|
||||
export class ResourceManager {
|
||||
private resourceTypes = new Map<string, ResourceTypeState>();
|
||||
private destroyed = false;
|
||||
private resourceTypes = new Map<string, ResourceTypeState>();
|
||||
private destroyed = false;
|
||||
|
||||
/**
|
||||
/**
|
||||
* 调用资源(自动注册或复用已有配置)
|
||||
*/
|
||||
async callResource<T extends any[], R>(
|
||||
type: string,
|
||||
config: ResourceConfig<T, R>,
|
||||
...args: T
|
||||
): Promise<R> {
|
||||
if (this.destroyed) {
|
||||
throw new Error('ResourceManager has been destroyed');
|
||||
}
|
||||
|
||||
// 获取或创建资源类型状态
|
||||
let state = this.resourceTypes.get(type);
|
||||
|
||||
if (!state) {
|
||||
// 首次注册
|
||||
state = {
|
||||
config: {
|
||||
resourceFn: config.resourceFn as (...args: any[]) => Promise<any>,
|
||||
healthCheckFn: config.healthCheckFn as ((...args: any[]) => Promise<boolean>) | undefined,
|
||||
disableTime: config.disableTime ?? 30000,
|
||||
maxRetries: config.maxRetries ?? 3,
|
||||
healthCheckInterval: config.healthCheckInterval ?? 60000,
|
||||
maxHealthCheckFailures: config.maxHealthCheckFailures ?? 20,
|
||||
testArgs: config.testArgs as any[] | undefined,
|
||||
},
|
||||
isEnabled: true,
|
||||
disableUntil: 0,
|
||||
currentRetries: 0,
|
||||
healthCheckFailureCount: 0,
|
||||
isPermanentlyDisabled: false,
|
||||
lastHealthCheckTime: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
};
|
||||
this.resourceTypes.set(type, state);
|
||||
}
|
||||
|
||||
// 在调用前检查是否需要进行健康检查
|
||||
await this.checkAndPerformHealthCheck(state);
|
||||
|
||||
// 检查资源状态
|
||||
if (state.isPermanentlyDisabled) {
|
||||
throw new Error(`Resource type '${type}' is permanently disabled (success: ${state.successCount}, failure: ${state.failureCount})`);
|
||||
}
|
||||
|
||||
if (!this.isResourceAvailable(type)) {
|
||||
const disableUntilDate = new Date(state.disableUntil).toISOString();
|
||||
throw new Error(`Resource type '${type}' is currently disabled until ${disableUntilDate} (success: ${state.successCount}, failure: ${state.failureCount})`);
|
||||
}
|
||||
|
||||
// 调用资源
|
||||
try {
|
||||
const result = await config.resourceFn(...args);
|
||||
this.onResourceSuccess(state);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.onResourceFailure(state);
|
||||
throw error;
|
||||
}
|
||||
async callResource<T extends any[], R>(
|
||||
type: string,
|
||||
config: ResourceConfig<T, R>,
|
||||
...args: T
|
||||
): Promise<R> {
|
||||
if (this.destroyed) {
|
||||
throw new Error('ResourceManager has been destroyed');
|
||||
}
|
||||
|
||||
/**
|
||||
// 获取或创建资源类型状态
|
||||
let state = this.resourceTypes.get(type);
|
||||
|
||||
if (!state) {
|
||||
// 首次注册
|
||||
state = {
|
||||
config: {
|
||||
resourceFn: config.resourceFn as (...args: any[]) => Promise<any>,
|
||||
healthCheckFn: config.healthCheckFn as ((...args: any[]) => Promise<boolean>) | undefined,
|
||||
disableTime: config.disableTime ?? 30000,
|
||||
maxRetries: config.maxRetries ?? 3,
|
||||
healthCheckInterval: config.healthCheckInterval ?? 60000,
|
||||
maxHealthCheckFailures: config.maxHealthCheckFailures ?? 20,
|
||||
testArgs: config.testArgs as any[] | undefined,
|
||||
},
|
||||
isEnabled: true,
|
||||
disableUntil: 0,
|
||||
currentRetries: 0,
|
||||
healthCheckFailureCount: 0,
|
||||
isPermanentlyDisabled: false,
|
||||
lastHealthCheckTime: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
};
|
||||
this.resourceTypes.set(type, state);
|
||||
}
|
||||
|
||||
// 在调用前检查是否需要进行健康检查
|
||||
await this.checkAndPerformHealthCheck(state);
|
||||
|
||||
// 检查资源状态
|
||||
if (state.isPermanentlyDisabled) {
|
||||
throw new Error(`Resource type '${type}' is permanently disabled (success: ${state.successCount}, failure: ${state.failureCount})`);
|
||||
}
|
||||
|
||||
if (!this.isResourceAvailable(type)) {
|
||||
const disableUntilDate = new Date(state.disableUntil).toISOString();
|
||||
throw new Error(`Resource type '${type}' is currently disabled until ${disableUntilDate} (success: ${state.successCount}, failure: ${state.failureCount})`);
|
||||
}
|
||||
|
||||
// 调用资源
|
||||
try {
|
||||
const result = await config.resourceFn(...args);
|
||||
this.onResourceSuccess(state);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.onResourceFailure(state);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查资源类型是否可用
|
||||
*/
|
||||
isResourceAvailable(type: string): boolean {
|
||||
const state = this.resourceTypes.get(type);
|
||||
if (!state) {
|
||||
return true; // 未注册的资源类型视为可用
|
||||
}
|
||||
|
||||
if (state.isPermanentlyDisabled || !state.isEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() >= state.disableUntil;
|
||||
isResourceAvailable (type: string): boolean {
|
||||
const state = this.resourceTypes.get(type);
|
||||
if (!state) {
|
||||
return true; // 未注册的资源类型视为可用
|
||||
}
|
||||
|
||||
/**
|
||||
if (state.isPermanentlyDisabled || !state.isEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() >= state.disableUntil;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源类型统计信息
|
||||
*/
|
||||
getResourceStats(type: string): { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean } | null {
|
||||
const state = this.resourceTypes.get(type);
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
successCount: state.successCount,
|
||||
failureCount: state.failureCount,
|
||||
isEnabled: state.isEnabled,
|
||||
isPermanentlyDisabled: state.isPermanentlyDisabled,
|
||||
};
|
||||
getResourceStats (type: string): { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean } | null {
|
||||
const state = this.resourceTypes.get(type);
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
return {
|
||||
successCount: state.successCount,
|
||||
failureCount: state.failureCount,
|
||||
isEnabled: state.isEnabled,
|
||||
isPermanentlyDisabled: state.isPermanentlyDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有资源类型统计
|
||||
*/
|
||||
getAllResourceStats(): Map<string, { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean }> {
|
||||
const stats = new Map();
|
||||
for (const [type, state] of this.resourceTypes) {
|
||||
stats.set(type, {
|
||||
successCount: state.successCount,
|
||||
failureCount: state.failureCount,
|
||||
isEnabled: state.isEnabled,
|
||||
isPermanentlyDisabled: state.isPermanentlyDisabled,
|
||||
});
|
||||
}
|
||||
return stats;
|
||||
getAllResourceStats (): Map<string, { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean }> {
|
||||
const stats = new Map();
|
||||
for (const [type, state] of this.resourceTypes) {
|
||||
stats.set(type, {
|
||||
successCount: state.successCount,
|
||||
failureCount: state.failureCount,
|
||||
isEnabled: state.isEnabled,
|
||||
isPermanentlyDisabled: state.isPermanentlyDisabled,
|
||||
});
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 注销资源类型
|
||||
*/
|
||||
unregister(type: string): boolean {
|
||||
return this.resourceTypes.delete(type);
|
||||
}
|
||||
unregister (type: string): boolean {
|
||||
return this.resourceTypes.delete(type);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 销毁管理器
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resourceTypes.clear();
|
||||
this.destroyed = true;
|
||||
destroy (): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
this.resourceTypes.clear();
|
||||
this.destroyed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并执行健康检查(如果需要)
|
||||
*/
|
||||
private async checkAndPerformHealthCheck(state: ResourceTypeState): Promise<void> {
|
||||
// 如果资源可用或已永久禁用,无需健康检查
|
||||
if (state.isEnabled && Date.now() >= state.disableUntil) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.isPermanentlyDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// 检查是否还在禁用期内
|
||||
if (now < state.disableUntil) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要进行健康检查(根据间隔时间)
|
||||
if (now - state.lastHealthCheckTime < state.config.healthCheckInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行健康检查
|
||||
await this.performHealthCheck(state);
|
||||
private async checkAndPerformHealthCheck (state: ResourceTypeState): Promise<void> {
|
||||
// 如果资源可用或已永久禁用,无需健康检查
|
||||
if (state.isEnabled && Date.now() >= state.disableUntil) {
|
||||
return;
|
||||
}
|
||||
|
||||
private async performHealthCheck(state: ResourceTypeState): Promise<void> {
|
||||
state.lastHealthCheckTime = Date.now();
|
||||
|
||||
try {
|
||||
let healthCheckResult: boolean;
|
||||
|
||||
if (state.config.healthCheckFn) {
|
||||
const testArgs = state.config.testArgs || [];
|
||||
healthCheckResult = await state.config.healthCheckFn(...testArgs);
|
||||
} else {
|
||||
const testArgs = state.config.testArgs || [];
|
||||
await state.config.resourceFn(...testArgs);
|
||||
healthCheckResult = true;
|
||||
}
|
||||
|
||||
if (healthCheckResult) {
|
||||
// 健康检查成功,重新启用
|
||||
state.isEnabled = true;
|
||||
state.disableUntil = 0;
|
||||
state.currentRetries = 0;
|
||||
state.healthCheckFailureCount = 0;
|
||||
} else {
|
||||
throw new Error('Health check function returned false');
|
||||
}
|
||||
} catch {
|
||||
// 健康检查失败,增加失败计数
|
||||
state.healthCheckFailureCount++;
|
||||
|
||||
// 检查是否达到最大健康检查失败次数
|
||||
if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures) {
|
||||
// 永久禁用资源
|
||||
state.isPermanentlyDisabled = true;
|
||||
state.disableUntil = 0;
|
||||
} else {
|
||||
// 继续禁用一段时间
|
||||
state.disableUntil = Date.now() + state.config.disableTime;
|
||||
}
|
||||
}
|
||||
if (state.isPermanentlyDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
private onResourceSuccess(state: ResourceTypeState): void {
|
||||
state.currentRetries = 0;
|
||||
const now = Date.now();
|
||||
|
||||
// 检查是否还在禁用期内
|
||||
if (now < state.disableUntil) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要进行健康检查(根据间隔时间)
|
||||
if (now - state.lastHealthCheckTime < state.config.healthCheckInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行健康检查
|
||||
await this.performHealthCheck(state);
|
||||
}
|
||||
|
||||
private async performHealthCheck (state: ResourceTypeState): Promise<void> {
|
||||
state.lastHealthCheckTime = Date.now();
|
||||
|
||||
try {
|
||||
let healthCheckResult: boolean;
|
||||
|
||||
if (state.config.healthCheckFn) {
|
||||
const testArgs = state.config.testArgs || [];
|
||||
healthCheckResult = await state.config.healthCheckFn(...testArgs);
|
||||
} else {
|
||||
const testArgs = state.config.testArgs || [];
|
||||
await state.config.resourceFn(...testArgs);
|
||||
healthCheckResult = true;
|
||||
}
|
||||
|
||||
if (healthCheckResult) {
|
||||
// 健康检查成功,重新启用
|
||||
state.isEnabled = true;
|
||||
state.disableUntil = 0;
|
||||
state.currentRetries = 0;
|
||||
state.healthCheckFailureCount = 0;
|
||||
state.successCount++;
|
||||
}
|
||||
} else {
|
||||
throw new Error('Health check function returned false');
|
||||
}
|
||||
} catch {
|
||||
// 健康检查失败,增加失败计数
|
||||
state.healthCheckFailureCount++;
|
||||
|
||||
private onResourceFailure(state: ResourceTypeState): void {
|
||||
state.currentRetries++;
|
||||
state.failureCount++;
|
||||
|
||||
// 如果重试次数达到上限,禁用资源
|
||||
if (state.currentRetries >= state.config.maxRetries) {
|
||||
state.disableUntil = Date.now() + state.config.disableTime;
|
||||
state.currentRetries = 0;
|
||||
}
|
||||
// 检查是否达到最大健康检查失败次数
|
||||
if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures) {
|
||||
// 永久禁用资源
|
||||
state.isPermanentlyDisabled = true;
|
||||
state.disableUntil = 0;
|
||||
} else {
|
||||
// 继续禁用一段时间
|
||||
state.disableUntil = Date.now() + state.config.disableTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onResourceSuccess (state: ResourceTypeState): void {
|
||||
state.currentRetries = 0;
|
||||
state.disableUntil = 0;
|
||||
state.healthCheckFailureCount = 0;
|
||||
state.successCount++;
|
||||
}
|
||||
|
||||
private onResourceFailure (state: ResourceTypeState): void {
|
||||
state.currentRetries++;
|
||||
state.failureCount++;
|
||||
|
||||
// 如果重试次数达到上限,禁用资源
|
||||
if (state.currentRetries >= state.config.maxRetries) {
|
||||
state.disableUntil = Date.now() + state.config.disableTime;
|
||||
state.currentRetries = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局实例
|
||||
export const resourceManager = new ResourceManager();
|
||||
|
||||
// 便捷函数
|
||||
export async function registerResource<T extends any[], R>(
|
||||
type: string,
|
||||
config: ResourceConfig<T, R>,
|
||||
...args: T
|
||||
export async function registerResource<T extends any[], R> (
|
||||
type: string,
|
||||
config: ResourceConfig<T, R>,
|
||||
...args: T
|
||||
): Promise<R> {
|
||||
return resourceManager.callResource(type, config, ...args);
|
||||
}
|
||||
return resourceManager.callResource(type, config, ...args);
|
||||
}
|
||||
|
||||
@@ -1,209 +1,208 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import path from 'node:path';
|
||||
import fs from 'fs';
|
||||
import os from 'node:os';
|
||||
import { QQLevel } from '@/core';
|
||||
import { QQVersionConfigType } from './types';
|
||||
|
||||
export async function solveProblem<T extends (...arg: any[]) => any>(func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
|
||||
return new Promise<ReturnType<T> | undefined>((resolve) => {
|
||||
try {
|
||||
const result = func(...args);
|
||||
resolve(result);
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
export async function solveProblem<T extends (...arg: any[]) => any> (func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
|
||||
return new Promise<ReturnType<T> | undefined>((resolve) => {
|
||||
try {
|
||||
const result = func(...args);
|
||||
resolve(result);
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function solveAsyncProblem<T extends (...args: any[]) => Promise<any>> (func: T, ...args: Parameters<T>): Promise<Awaited<ReturnType<T>> | undefined> {
|
||||
return new Promise<Awaited<ReturnType<T>> | undefined>((resolve) => {
|
||||
func(...args).then((result) => {
|
||||
resolve(result);
|
||||
}).catch(() => {
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function sleep (ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function PromiseTimer<T> (promise: Promise<T>, ms: number): Promise<T> {
|
||||
const timeoutPromise = new Promise<T>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error('PromiseTimer: Operation timed out')), ms)
|
||||
);
|
||||
return Promise.race([promise, timeoutPromise]);
|
||||
}
|
||||
|
||||
export async function runAllWithTimeout<T> (tasks: Promise<T>[], timeout: number): Promise<T[]> {
|
||||
const wrappedTasks = tasks.map((task) =>
|
||||
PromiseTimer(task, timeout).then(
|
||||
(result) => ({ status: 'fulfilled', value: result }),
|
||||
(error) => ({ status: 'rejected', reason: error })
|
||||
)
|
||||
);
|
||||
const results = await Promise.all(wrappedTasks);
|
||||
return results
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => (result as { status: 'fulfilled'; value: T; }).value);
|
||||
}
|
||||
|
||||
export function isNull (value: any) {
|
||||
return value === undefined || value === null;
|
||||
}
|
||||
|
||||
export function isNumeric (str: string) {
|
||||
return /^\d+$/.test(str);
|
||||
}
|
||||
|
||||
export function truncateString (obj: any, maxLength = 500) {
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (typeof obj[key] === 'string') {
|
||||
// 如果是字符串且超过指定长度,则截断
|
||||
if (obj[key].length > maxLength) {
|
||||
obj[key] = obj[key].substring(0, maxLength) + '...';
|
||||
}
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
// 如果是对象或数组,则递归调用
|
||||
truncateString(obj[key], maxLength);
|
||||
}
|
||||
});
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export async function solveAsyncProblem<T extends (...args: any[]) => Promise<any>>(func: T, ...args: Parameters<T>): Promise<Awaited<ReturnType<T>> | undefined> {
|
||||
return new Promise<Awaited<ReturnType<T>> | undefined>((resolve) => {
|
||||
func(...args).then((result) => {
|
||||
resolve(result);
|
||||
}).catch(() => {
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
export function isEqual (obj1: any, obj2: any) {
|
||||
if (obj1 === obj2) return true;
|
||||
if (obj1 == null || obj2 == null) return false;
|
||||
if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return obj1 === obj2;
|
||||
|
||||
const keys1 = Object.keys(obj1);
|
||||
const keys2 = Object.keys(obj2);
|
||||
|
||||
if (keys1.length !== keys2.length) return false;
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!isEqual(obj1[key], obj2[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function PromiseTimer<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
const timeoutPromise = new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('PromiseTimer: Operation timed out')), ms),
|
||||
);
|
||||
return Promise.race([promise, timeoutPromise]);
|
||||
}
|
||||
|
||||
export async function runAllWithTimeout<T>(tasks: Promise<T>[], timeout: number): Promise<T[]> {
|
||||
const wrappedTasks = tasks.map((task) =>
|
||||
PromiseTimer(task, timeout).then(
|
||||
(result) => ({ status: 'fulfilled', value: result }),
|
||||
(error) => ({ status: 'rejected', reason: error }),
|
||||
),
|
||||
);
|
||||
const results = await Promise.all(wrappedTasks);
|
||||
return results
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => (result as { status: 'fulfilled'; value: T }).value);
|
||||
}
|
||||
|
||||
export function isNull(value: any) {
|
||||
return value === undefined || value === null;
|
||||
}
|
||||
|
||||
export function isNumeric(str: string) {
|
||||
return /^\d+$/.test(str);
|
||||
}
|
||||
|
||||
export function truncateString(obj: any, maxLength = 500) {
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (typeof obj[key] === 'string') {
|
||||
// 如果是字符串且超过指定长度,则截断
|
||||
if (obj[key].length > maxLength) {
|
||||
obj[key] = obj[key].substring(0, maxLength) + '...';
|
||||
}
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
// 如果是对象或数组,则递归调用
|
||||
truncateString(obj[key], maxLength);
|
||||
}
|
||||
});
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function isEqual(obj1: any, obj2: any) {
|
||||
if (obj1 === obj2) return true;
|
||||
if (obj1 == null || obj2 == null) return false;
|
||||
if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return obj1 === obj2;
|
||||
|
||||
const keys1 = Object.keys(obj1);
|
||||
const keys2 = Object.keys(obj2);
|
||||
|
||||
if (keys1.length !== keys2.length) return false;
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!isEqual(obj1[key], obj2[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getDefaultQQVersionConfigInfo(): QQVersionConfigType {
|
||||
if (os.platform() === 'linux') {
|
||||
return {
|
||||
baseVersion: '3.2.12.28060',
|
||||
curVersion: '3.2.12.28060',
|
||||
prevVersion: '',
|
||||
onErrorVersions: [],
|
||||
buildId: '27254',
|
||||
};
|
||||
}
|
||||
if (os.platform() === 'darwin') {
|
||||
return {
|
||||
baseVersion: '6.9.53.28060',
|
||||
curVersion: '6.9.53.28060',
|
||||
prevVersion: '',
|
||||
onErrorVersions: [],
|
||||
buildId: '28060',
|
||||
};
|
||||
}
|
||||
export function getDefaultQQVersionConfigInfo (): QQVersionConfigType {
|
||||
if (os.platform() === 'linux') {
|
||||
return {
|
||||
baseVersion: '9.9.15-28131',
|
||||
curVersion: '9.9.15-28131',
|
||||
prevVersion: '',
|
||||
onErrorVersions: [],
|
||||
buildId: '28131',
|
||||
baseVersion: '3.2.12.28060',
|
||||
curVersion: '3.2.12.28060',
|
||||
prevVersion: '',
|
||||
onErrorVersions: [],
|
||||
buildId: '27254',
|
||||
};
|
||||
}
|
||||
if (os.platform() === 'darwin') {
|
||||
return {
|
||||
baseVersion: '6.9.53.28060',
|
||||
curVersion: '6.9.53.28060',
|
||||
prevVersion: '',
|
||||
onErrorVersions: [],
|
||||
buildId: '28060',
|
||||
};
|
||||
}
|
||||
return {
|
||||
baseVersion: '9.9.15-28131',
|
||||
curVersion: '9.9.15-28131',
|
||||
prevVersion: '',
|
||||
onErrorVersions: [],
|
||||
buildId: '28131',
|
||||
};
|
||||
}
|
||||
|
||||
export function getQQPackageInfoPath(exePath: string = '', version?: string): string {
|
||||
let packagePath;
|
||||
if (os.platform() === 'darwin') {
|
||||
packagePath = path.join(path.dirname(exePath), '..', 'Resources', 'app', 'package.json');
|
||||
} else if (os.platform() === 'linux') {
|
||||
packagePath = path.join(path.dirname(exePath), './resources/app/package.json');
|
||||
} else {
|
||||
packagePath = path.join(path.dirname(exePath), './versions/' + version + '/resources/app/package.json');
|
||||
}
|
||||
//下面是老版本兼容 未来去掉
|
||||
if (!fs.existsSync(packagePath)) {
|
||||
packagePath = path.join(path.dirname(exePath), './resources/app/versions/' + version + '/package.json');
|
||||
}
|
||||
return packagePath;
|
||||
export function getQQPackageInfoPath (exePath: string = '', version?: string): string {
|
||||
let packagePath;
|
||||
if (os.platform() === 'darwin') {
|
||||
packagePath = path.join(path.dirname(exePath), '..', 'Resources', 'app', 'package.json');
|
||||
} else if (os.platform() === 'linux') {
|
||||
packagePath = path.join(path.dirname(exePath), './resources/app/package.json');
|
||||
} else {
|
||||
packagePath = path.join(path.dirname(exePath), './versions/' + version + '/resources/app/package.json');
|
||||
}
|
||||
// 下面是老版本兼容 未来去掉
|
||||
if (!fs.existsSync(packagePath)) {
|
||||
packagePath = path.join(path.dirname(exePath), './resources/app/versions/' + version + '/package.json');
|
||||
}
|
||||
return packagePath;
|
||||
}
|
||||
|
||||
export function getQQVersionConfigPath(exePath: string = ''): string | undefined {
|
||||
let configVersionInfoPath;
|
||||
if (os.platform() === 'win32') {
|
||||
configVersionInfoPath = path.join(path.dirname(exePath), 'versions', 'config.json');
|
||||
} else if (os.platform() === 'darwin') {
|
||||
const userPath = os.homedir();
|
||||
const appDataPath = path.resolve(userPath, './Library/Application Support/QQ');
|
||||
configVersionInfoPath = path.resolve(appDataPath, './versions/config.json');
|
||||
} else {
|
||||
const userPath = os.homedir();
|
||||
const appDataPath = path.resolve(userPath, './.config/QQ');
|
||||
configVersionInfoPath = path.resolve(appDataPath, './versions/config.json');
|
||||
}
|
||||
if (typeof configVersionInfoPath !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
//老版本兼容 未来去掉
|
||||
if (!fs.existsSync(configVersionInfoPath)) {
|
||||
configVersionInfoPath = path.join(path.dirname(exePath), './resources/app/versions/config.json');
|
||||
}
|
||||
if (!fs.existsSync(configVersionInfoPath)) {
|
||||
return undefined;
|
||||
}
|
||||
return configVersionInfoPath;
|
||||
}
|
||||
|
||||
export function calcQQLevel(level?: QQLevel) {
|
||||
if (!level) return 0;
|
||||
//const { penguinNum, crownNum, sunNum, moonNum, starNum } = level;
|
||||
const { crownNum, sunNum, moonNum, starNum } = level
|
||||
//没补类型
|
||||
return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum;
|
||||
}
|
||||
|
||||
export function stringifyWithBigInt(obj: any) {
|
||||
return JSON.stringify(obj, (_key, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAppidFromMajor(nodeMajor: string): string | undefined {
|
||||
const hexSequence = 'A4 09 00 00 00 35';
|
||||
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');
|
||||
const filePath = path.resolve(nodeMajor);
|
||||
const fileContent = fs.readFileSync(filePath);
|
||||
|
||||
let searchPosition = 0;
|
||||
while (true) {
|
||||
const index = fileContent.indexOf(sequenceBytes, searchPosition);
|
||||
if (index === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const start = index + sequenceBytes.length - 1;
|
||||
const end = fileContent.indexOf(0x00, start);
|
||||
if (end === -1) {
|
||||
break;
|
||||
}
|
||||
const content = fileContent.subarray(start, end);
|
||||
if (!content.every(byte => byte === 0x00)) {
|
||||
try {
|
||||
return content.toString('utf-8');
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
searchPosition = end + 1;
|
||||
}
|
||||
|
||||
export function getQQVersionConfigPath (exePath: string = ''): string | undefined {
|
||||
let configVersionInfoPath;
|
||||
if (os.platform() === 'win32') {
|
||||
configVersionInfoPath = path.join(path.dirname(exePath), 'versions', 'config.json');
|
||||
} else if (os.platform() === 'darwin') {
|
||||
const userPath = os.homedir();
|
||||
const appDataPath = path.resolve(userPath, './Library/Application Support/QQ');
|
||||
configVersionInfoPath = path.resolve(appDataPath, './versions/config.json');
|
||||
} else {
|
||||
const userPath = os.homedir();
|
||||
const appDataPath = path.resolve(userPath, './.config/QQ');
|
||||
configVersionInfoPath = path.resolve(appDataPath, './versions/config.json');
|
||||
}
|
||||
if (typeof configVersionInfoPath !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
// 老版本兼容 未来去掉
|
||||
if (!fs.existsSync(configVersionInfoPath)) {
|
||||
configVersionInfoPath = path.join(path.dirname(exePath), './resources/app/versions/config.json');
|
||||
}
|
||||
if (!fs.existsSync(configVersionInfoPath)) {
|
||||
return undefined;
|
||||
}
|
||||
return configVersionInfoPath;
|
||||
}
|
||||
|
||||
export function calcQQLevel (level?: QQLevel) {
|
||||
if (!level) return 0;
|
||||
// const { penguinNum, crownNum, sunNum, moonNum, starNum } = level;
|
||||
const { crownNum, sunNum, moonNum, starNum } = level;
|
||||
// 没补类型
|
||||
return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum;
|
||||
}
|
||||
|
||||
export function stringifyWithBigInt (obj: any) {
|
||||
return JSON.stringify(obj, (_key, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAppidFromMajor (nodeMajor: string): string | undefined {
|
||||
const hexSequence = 'A4 09 00 00 00 35';
|
||||
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');
|
||||
const filePath = path.resolve(nodeMajor);
|
||||
const fileContent = fs.readFileSync(filePath);
|
||||
|
||||
let searchPosition = 0;
|
||||
while (true) {
|
||||
const index = fileContent.indexOf(sequenceBytes, searchPosition);
|
||||
if (index === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const start = index + sequenceBytes.length - 1;
|
||||
const end = fileContent.indexOf(0x00, start);
|
||||
if (end === -1) {
|
||||
break;
|
||||
}
|
||||
const content = fileContent.subarray(start, end);
|
||||
if (!content.every(byte => byte === 0x00)) {
|
||||
try {
|
||||
return content.toString('utf-8');
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
searchPosition = end + 1;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import winston, { format, transports } from 'winston';
|
||||
import { truncateString } from '@/common/helper';
|
||||
import path from 'node:path';
|
||||
@@ -6,315 +5,317 @@ import fs from 'node:fs/promises';
|
||||
import { NTMsgAtType, ChatType, ElementType, MessageElement, RawMessage, SelfInfo } from '@/core';
|
||||
import EventEmitter from 'node:events';
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
FATAL = 'fatal',
|
||||
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}`;
|
||||
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[] = [];
|
||||
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);
|
||||
subscribe (listener: LogListener) {
|
||||
for (const history of Subscription.history) {
|
||||
try {
|
||||
listener(history);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
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);
|
||||
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 {
|
||||
fileLogEnabled = true;
|
||||
consoleLogEnabled = true;
|
||||
logger: winston.Logger;
|
||||
fileLogEnabled = true;
|
||||
consoleLogEnabled = true;
|
||||
logger: winston.Logger;
|
||||
|
||||
constructor(logDir: string) {
|
||||
const filename = `${getFormattedTimestamp()}.log`;
|
||||
const logPath = path.join(logDir, filename);
|
||||
constructor (logDir: string) {
|
||||
const filename = `${getFormattedTimestamp()}.log`;
|
||||
const 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.File({
|
||||
filename: logPath,
|
||||
level: 'debug',
|
||||
maxsize: 5 * 1024 * 1024, // 5MB
|
||||
maxFiles: 5,
|
||||
}),
|
||||
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.logger.error('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.logger.warn(`File already deleted: ${filePath}`);
|
||||
} else {
|
||||
this.logger.error('Failed to delete old log file', err);
|
||||
}
|
||||
} else {
|
||||
this.logger.info(`Deleted old log file: ${filePath}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
this.logger.error('Failed to get file stats', err);
|
||||
});
|
||||
}
|
||||
|
||||
setFileAndConsoleLogLevel(fileLogLevel: LogLevel, consoleLogLevel: LogLevel) {
|
||||
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;
|
||||
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;
|
||||
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.File({
|
||||
filename: logPath,
|
||||
level: 'debug',
|
||||
maxsize: 5 * 1024 * 1024, // 5MB
|
||||
maxFiles: 5,
|
||||
}),
|
||||
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}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
_log(level: LogLevel, ...args: any[]) {
|
||||
const message = this.formatMsg(args);
|
||||
if (this.consoleLogEnabled && this.fileLogEnabled) {
|
||||
this.logger.log(level, message);
|
||||
} else if (this.consoleLogEnabled) {
|
||||
this.logger.log(level, message);
|
||||
} else if (this.fileLogEnabled) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
this.logger.log(level, message.replace(/\x1B[@-_][0-?]*[ -/]*[@-~]/g, ''));
|
||||
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.logger.error('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.logger.warn(`File already deleted: ${filePath}`);
|
||||
} else {
|
||||
this.logger.error('Failed to delete old log file', err);
|
||||
}
|
||||
} else {
|
||||
this.logger.info(`Deleted old log file: ${filePath}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
this.logger.error('Failed to get file stats', err);
|
||||
});
|
||||
}
|
||||
|
||||
setFileAndConsoleLogLevel (fileLogLevel: LogLevel, consoleLogLevel: LogLevel) {
|
||||
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;
|
||||
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))));
|
||||
}
|
||||
logSubscription.notify(JSON.stringify({ level, message }));
|
||||
return msgItem;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
_log (level: LogLevel, ...args: any[]) {
|
||||
const message = this.formatMsg(args);
|
||||
if (this.consoleLogEnabled && this.fileLogEnabled) {
|
||||
this.logger.log(level, message);
|
||||
} else if (this.consoleLogEnabled) {
|
||||
this.logger.log(level, message);
|
||||
} else if (this.fileLogEnabled) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
this.logger.log(level, message.replace(/\x1B[@-_][0-?]*[ -/]*[@-~]/g, ''));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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)}`);
|
||||
}
|
||||
this.log(`${isSelfSent ? '发送 ->' : '接收 <-'} ${rawMessageToText(msg)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function rawMessageToText(msg: RawMessage, recursiveLevel = 0): string {
|
||||
if (recursiveLevel > 2) {
|
||||
return '...';
|
||||
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})]`);
|
||||
}
|
||||
|
||||
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})`);
|
||||
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));
|
||||
}
|
||||
for (const element of msg.elements) {
|
||||
tokens.push(msgElementToText(element, msg, recursiveLevel));
|
||||
}
|
||||
|
||||
return tokens.join(' ');
|
||||
return tokens.join(' ');
|
||||
}
|
||||
|
||||
function msgElementToText(element: MessageElement, msg: RawMessage, recursiveLevel: number): string {
|
||||
if (element.textElement) {
|
||||
return textElementToText(element.textElement);
|
||||
}
|
||||
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.replyElement) {
|
||||
return replyElementToText(element.replyElement, msg, recursiveLevel);
|
||||
}
|
||||
|
||||
if (element.picElement) {
|
||||
return '[图片]';
|
||||
}
|
||||
if (element.picElement) {
|
||||
return '[图片]';
|
||||
}
|
||||
|
||||
if (element.fileElement) {
|
||||
return `[文件 ${element.fileElement.fileName}]`;
|
||||
}
|
||||
if (element.fileElement) {
|
||||
return `[文件 ${element.fileElement.fileName}]`;
|
||||
}
|
||||
|
||||
if (element.videoElement) {
|
||||
return '[视频]';
|
||||
}
|
||||
if (element.videoElement) {
|
||||
return '[视频]';
|
||||
}
|
||||
|
||||
if (element.pttElement) {
|
||||
return `[语音 ${element.pttElement.duration}s]`;
|
||||
}
|
||||
if (element.pttElement) {
|
||||
return `[语音 ${element.pttElement.duration}s]`;
|
||||
}
|
||||
|
||||
if (element.arkElement) {
|
||||
return '[卡片消息]';
|
||||
}
|
||||
if (element.arkElement) {
|
||||
return '[卡片消息]';
|
||||
}
|
||||
|
||||
if (element.faceElement) {
|
||||
return `[表情 ${element.faceElement.faceText ?? ''}]`;
|
||||
}
|
||||
if (element.faceElement) {
|
||||
return `[表情 ${element.faceElement.faceText ?? ''}]`;
|
||||
}
|
||||
|
||||
if (element.marketFaceElement) {
|
||||
return element.marketFaceElement.faceName;
|
||||
}
|
||||
if (element.marketFaceElement) {
|
||||
return element.marketFaceElement.faceName;
|
||||
}
|
||||
|
||||
if (element.markdownElement) {
|
||||
return '[Markdown 消息]';
|
||||
}
|
||||
if (element.markdownElement) {
|
||||
return '[Markdown 消息]';
|
||||
}
|
||||
|
||||
if (element.multiForwardMsgElement) {
|
||||
return '[转发消息]';
|
||||
}
|
||||
if (element.multiForwardMsgElement) {
|
||||
return '[转发消息]';
|
||||
}
|
||||
|
||||
if (element.elementType === ElementType.GreyTip) {
|
||||
return '[灰条消息]';
|
||||
}
|
||||
if (element.elementType === ElementType.GreyTip) {
|
||||
return '[灰条消息]';
|
||||
}
|
||||
|
||||
return `[未实现 (ElementType = ${element.elementType})]`;
|
||||
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 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})`
|
||||
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})`
|
||||
}]`;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
export class LRUCache<K, V> {
|
||||
private capacity: number;
|
||||
public cache: Map<K, V>;
|
||||
private capacity: number;
|
||||
public cache: Map<K, V>;
|
||||
|
||||
constructor(capacity: number) {
|
||||
this.capacity = capacity;
|
||||
this.cache = new Map<K, V>();
|
||||
}
|
||||
constructor (capacity: number) {
|
||||
this.capacity = capacity;
|
||||
this.cache = new Map<K, V>();
|
||||
}
|
||||
|
||||
public get(key: K): V | undefined {
|
||||
const value = this.cache.get(key);
|
||||
if (value !== undefined) {
|
||||
// Move the accessed key to the end to mark it as most recently used
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
return value;
|
||||
public get (key: K): V | undefined {
|
||||
const value = this.cache.get(key);
|
||||
if (value !== undefined) {
|
||||
// Move the accessed key to the end to mark it as most recently used
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public put(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
// If the key already exists, move it to the end to mark it as most recently used
|
||||
this.cache.delete(key);
|
||||
} else if (this.cache.size >= this.capacity) {
|
||||
// If the cache is full, remove the least recently used key (the first one in the map)
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
this.cache.set(key, value);
|
||||
public put (key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
// If the key already exists, move it to the end to mark it as most recently used
|
||||
this.cache.delete(key);
|
||||
} else if (this.cache.size >= this.capacity) {
|
||||
// If the cache is full, remove the least recently used key (the first one in the map)
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
public resetCapacity(newCapacity: number): void {
|
||||
this.capacity = newCapacity;
|
||||
while (this.cache.size > this.capacity) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
|
||||
public resetCapacity (newCapacity: number): void {
|
||||
this.capacity = newCapacity;
|
||||
while (this.cache.size > this.capacity) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,142 +2,141 @@ import { Peer } from '@/core';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export class LimitedHashTable<K, V> {
|
||||
private readonly keyToValue: Map<K, V> = new Map();
|
||||
private readonly valueToKey: Map<V, K> = new Map();
|
||||
private maxSize: number;
|
||||
private readonly keyToValue: Map<K, V> = new Map();
|
||||
private readonly valueToKey: Map<V, K> = new Map();
|
||||
private maxSize: number;
|
||||
|
||||
constructor(maxSize: number) {
|
||||
this.maxSize = maxSize;
|
||||
constructor (maxSize: number) {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
resize (count: number) {
|
||||
this.maxSize = count;
|
||||
}
|
||||
|
||||
set (key: K, value: V): void {
|
||||
this.keyToValue.set(key, value);
|
||||
this.valueToKey.set(value, key);
|
||||
while (this.keyToValue.size !== this.valueToKey.size) {
|
||||
this.keyToValue.clear();
|
||||
this.valueToKey.clear();
|
||||
}
|
||||
|
||||
resize(count: number) {
|
||||
this.maxSize = count;
|
||||
while (this.keyToValue.size > this.maxSize || this.valueToKey.size > this.maxSize) {
|
||||
const oldestKey = this.keyToValue.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
this.valueToKey.delete(this.keyToValue.get(oldestKey) as V);
|
||||
this.keyToValue.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
this.keyToValue.set(key, value);
|
||||
this.valueToKey.set(value, key);
|
||||
while (this.keyToValue.size !== this.valueToKey.size) {
|
||||
this.keyToValue.clear();
|
||||
this.valueToKey.clear();
|
||||
}
|
||||
while (this.keyToValue.size > this.maxSize || this.valueToKey.size > this.maxSize) {
|
||||
const oldestKey = this.keyToValue.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
this.valueToKey.delete(this.keyToValue.get(oldestKey) as V);
|
||||
this.keyToValue.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
getValue (key: K): V | undefined {
|
||||
return this.keyToValue.get(key);
|
||||
}
|
||||
|
||||
getKey (value: V): K | undefined {
|
||||
return this.valueToKey.get(value);
|
||||
}
|
||||
|
||||
deleteByValue (value: V): void {
|
||||
const key = this.valueToKey.get(value);
|
||||
if (key !== undefined) {
|
||||
this.keyToValue.delete(key);
|
||||
this.valueToKey.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
getValue(key: K): V | undefined {
|
||||
return this.keyToValue.get(key);
|
||||
deleteByKey (key: K): void {
|
||||
const value = this.keyToValue.get(key);
|
||||
if (value !== undefined) {
|
||||
this.keyToValue.delete(key);
|
||||
this.valueToKey.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
getKey(value: V): K | undefined {
|
||||
return this.valueToKey.get(value);
|
||||
getKeyList (): K[] {
|
||||
return Array.from(this.keyToValue.keys());
|
||||
}
|
||||
|
||||
// 获取最近刚写入的几个值
|
||||
getHeads (size: number): { key: K; value: V }[] | undefined {
|
||||
const keyList = this.getKeyList();
|
||||
if (keyList.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
deleteByValue(value: V): void {
|
||||
const key = this.valueToKey.get(value);
|
||||
if (key !== undefined) {
|
||||
this.keyToValue.delete(key);
|
||||
this.valueToKey.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
deleteByKey(key: K): void {
|
||||
const value = this.keyToValue.get(key);
|
||||
if (value !== undefined) {
|
||||
this.keyToValue.delete(key);
|
||||
this.valueToKey.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
getKeyList(): K[] {
|
||||
return Array.from(this.keyToValue.keys());
|
||||
}
|
||||
|
||||
//获取最近刚写入的几个值
|
||||
getHeads(size: number): { key: K; value: V }[] | undefined {
|
||||
const keyList = this.getKeyList();
|
||||
if (keyList.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const result: { key: K; value: V }[] = [];
|
||||
const listSize = Math.min(size, keyList.length);
|
||||
for (let i = 0; i < listSize; i++) {
|
||||
const key = keyList[listSize - i];
|
||||
if (key !== undefined) {
|
||||
result.push({ key, value: this.keyToValue.get(key)! });
|
||||
}
|
||||
|
||||
}
|
||||
return result;
|
||||
const result: { key: K; value: V }[] = [];
|
||||
const listSize = Math.min(size, keyList.length);
|
||||
for (let i = 0; i < listSize; i++) {
|
||||
const key = keyList[listSize - i];
|
||||
if (key !== undefined) {
|
||||
result.push({ key, value: this.keyToValue.get(key)! });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class MessageUniqueWrapper {
|
||||
private readonly msgDataMap: LimitedHashTable<string, number>;
|
||||
private readonly msgIdMap: LimitedHashTable<string, number>;
|
||||
private readonly msgDataMap: LimitedHashTable<string, number>;
|
||||
private readonly msgIdMap: LimitedHashTable<string, number>;
|
||||
|
||||
constructor(maxMap: number = 5000) {
|
||||
this.msgIdMap = new LimitedHashTable<string, number>(maxMap);
|
||||
this.msgDataMap = new LimitedHashTable<string, number>(maxMap);
|
||||
}
|
||||
constructor (maxMap: number = 5000) {
|
||||
this.msgIdMap = new LimitedHashTable<string, number>(maxMap);
|
||||
this.msgDataMap = new LimitedHashTable<string, number>(maxMap);
|
||||
}
|
||||
|
||||
getRecentMsgIds(Peer: Peer, size: number): string[] {
|
||||
const heads = this.msgIdMap.getHeads(size);
|
||||
if (!heads) {
|
||||
return [];
|
||||
}
|
||||
const data = heads.map((t) => MessageUnique.getMsgIdAndPeerByShortId(t.value));
|
||||
const ret = data.filter((t) => t?.Peer.chatType === Peer.chatType && t?.Peer.peerUid === Peer.peerUid);
|
||||
return ret.map((t) => t?.MsgId).filter((t) => t !== undefined);
|
||||
getRecentMsgIds (Peer: Peer, size: number): string[] {
|
||||
const heads = this.msgIdMap.getHeads(size);
|
||||
if (!heads) {
|
||||
return [];
|
||||
}
|
||||
const data = heads.map((t) => MessageUnique.getMsgIdAndPeerByShortId(t.value));
|
||||
const ret = data.filter((t) => t?.Peer.chatType === Peer.chatType && t?.Peer.peerUid === Peer.peerUid);
|
||||
return ret.map((t) => t?.MsgId).filter((t) => t !== undefined);
|
||||
}
|
||||
|
||||
createUniqueMsgId(peer: Peer, msgId: string) {
|
||||
const key = `${msgId}|${peer.chatType}|${peer.peerUid}`;
|
||||
const hash = crypto.createHash('md5').update(key).digest();
|
||||
if (hash[0]) {
|
||||
//设置第一个bit为0 保证shortId为正数
|
||||
hash[0] &= 0x7f;
|
||||
}
|
||||
const shortId = hash.readInt32BE(0);
|
||||
//减少性能损耗
|
||||
this.msgIdMap.set(msgId, shortId);
|
||||
this.msgDataMap.set(key, shortId);
|
||||
return shortId;
|
||||
createUniqueMsgId (peer: Peer, msgId: string) {
|
||||
const key = `${msgId}|${peer.chatType}|${peer.peerUid}`;
|
||||
const hash = crypto.createHash('md5').update(key).digest();
|
||||
if (hash[0]) {
|
||||
// 设置第一个bit为0 保证shortId为正数
|
||||
hash[0] &= 0x7f;
|
||||
}
|
||||
const shortId = hash.readInt32BE(0);
|
||||
// 减少性能损耗
|
||||
this.msgIdMap.set(msgId, shortId);
|
||||
this.msgDataMap.set(key, shortId);
|
||||
return shortId;
|
||||
}
|
||||
|
||||
getMsgIdAndPeerByShortId(shortId: number): { MsgId: string; Peer: Peer } | undefined {
|
||||
const data = this.msgDataMap.getKey(shortId);
|
||||
if (data) {
|
||||
const [msgId, chatTypeStr, peerUid] = data.split('|');
|
||||
const peer: Peer = {
|
||||
chatType: parseInt(chatTypeStr ?? '0'),
|
||||
peerUid: peerUid ?? '',
|
||||
guildId: '',
|
||||
};
|
||||
return { MsgId: msgId ?? '0', Peer: peer };
|
||||
}
|
||||
return undefined;
|
||||
getMsgIdAndPeerByShortId (shortId: number): { MsgId: string; Peer: Peer } | undefined {
|
||||
const data = this.msgDataMap.getKey(shortId);
|
||||
if (data) {
|
||||
const [msgId, chatTypeStr, peerUid] = data.split('|');
|
||||
const peer: Peer = {
|
||||
chatType: parseInt(chatTypeStr ?? '0'),
|
||||
peerUid: peerUid ?? '',
|
||||
guildId: '',
|
||||
};
|
||||
return { MsgId: msgId ?? '0', Peer: peer };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getShortIdByMsgId(msgId: string): number | undefined {
|
||||
return this.msgIdMap.getValue(msgId);
|
||||
}
|
||||
getShortIdByMsgId (msgId: string): number | undefined {
|
||||
return this.msgIdMap.getValue(msgId);
|
||||
}
|
||||
|
||||
getPeerByMsgId(msgId: string) {
|
||||
const shortId = this.msgIdMap.getValue(msgId);
|
||||
if (!shortId) return undefined;
|
||||
return this.getMsgIdAndPeerByShortId(shortId);
|
||||
}
|
||||
getPeerByMsgId (msgId: string) {
|
||||
const shortId = this.msgIdMap.getValue(msgId);
|
||||
if (!shortId) return undefined;
|
||||
return this.getMsgIdAndPeerByShortId(shortId);
|
||||
}
|
||||
|
||||
resize(maxSize: number): void {
|
||||
this.msgIdMap.resize(maxSize);
|
||||
this.msgDataMap.resize(maxSize);
|
||||
}
|
||||
resize (maxSize: number): void {
|
||||
this.msgIdMap.resize(maxSize);
|
||||
this.msgDataMap.resize(maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageUnique: MessageUniqueWrapper = new MessageUniqueWrapper();
|
||||
|
||||
@@ -4,38 +4,38 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
|
||||
export class NapCatPathWrapper {
|
||||
binaryPath: string;
|
||||
logsPath: string;
|
||||
configPath: string;
|
||||
cachePath: string;
|
||||
staticPath: string;
|
||||
pluginPath: string;
|
||||
binaryPath: string;
|
||||
logsPath: string;
|
||||
configPath: string;
|
||||
cachePath: string;
|
||||
staticPath: string;
|
||||
pluginPath: string;
|
||||
|
||||
constructor(mainPath: string = dirname(fileURLToPath(import.meta.url))) {
|
||||
this.binaryPath = mainPath;
|
||||
let writePath: string;
|
||||
constructor (mainPath: string = dirname(fileURLToPath(import.meta.url))) {
|
||||
this.binaryPath = mainPath;
|
||||
let writePath: string;
|
||||
|
||||
if (process.env['NAPCAT_WORKDIR']) {
|
||||
writePath = process.env['NAPCAT_WORKDIR'];
|
||||
} else if (os.platform() === 'darwin') {
|
||||
writePath = path.join(os.homedir(), 'Library', 'Application Support', 'QQ', 'NapCat');
|
||||
} else {
|
||||
writePath = this.binaryPath;
|
||||
}
|
||||
|
||||
this.logsPath = path.join(writePath, 'logs');
|
||||
this.configPath = path.join(writePath, 'config');
|
||||
this.pluginPath = path.join(writePath, 'plugins');//dynamic load
|
||||
this.cachePath = path.join(writePath, 'cache');
|
||||
this.staticPath = path.join(this.binaryPath, 'static');
|
||||
if (!fs.existsSync(this.logsPath)) {
|
||||
fs.mkdirSync(this.logsPath, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.configPath)) {
|
||||
fs.mkdirSync(this.configPath, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.cachePath)) {
|
||||
fs.mkdirSync(this.cachePath, { recursive: true });
|
||||
}
|
||||
if (process.env['NAPCAT_WORKDIR']) {
|
||||
writePath = process.env['NAPCAT_WORKDIR'];
|
||||
} else if (os.platform() === 'darwin') {
|
||||
writePath = path.join(os.homedir(), 'Library', 'Application Support', 'QQ', 'NapCat');
|
||||
} else {
|
||||
writePath = this.binaryPath;
|
||||
}
|
||||
|
||||
this.logsPath = path.join(writePath, 'logs');
|
||||
this.configPath = path.join(writePath, 'config');
|
||||
this.pluginPath = path.join(writePath, 'plugins');// dynamic load
|
||||
this.cachePath = path.join(writePath, 'cache');
|
||||
this.staticPath = path.join(this.binaryPath, 'static');
|
||||
if (!fs.existsSync(this.logsPath)) {
|
||||
fs.mkdirSync(this.logsPath, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.configPath)) {
|
||||
fs.mkdirSync(this.configPath, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.cachePath)) {
|
||||
fs.mkdirSync(this.cachePath, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,290 +6,291 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface FunctionStats {
|
||||
name: string;
|
||||
callCount: number;
|
||||
totalTime: number;
|
||||
averageTime: number;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
fileName?: string;
|
||||
lineNumber?: number;
|
||||
name: string;
|
||||
callCount: number;
|
||||
totalTime: number;
|
||||
averageTime: number;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
fileName?: string;
|
||||
lineNumber?: number;
|
||||
}
|
||||
|
||||
export class PerformanceMonitor {
|
||||
private static instance: PerformanceMonitor;
|
||||
private stats = new Map<string, FunctionStats>();
|
||||
private startTimes = new Map<string, number>();
|
||||
private reportInterval: NodeJS.Timeout | null = null;
|
||||
private static instance: PerformanceMonitor;
|
||||
private stats = new Map<string, FunctionStats>();
|
||||
private startTimes = new Map<string, number>();
|
||||
private reportInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
static getInstance(): PerformanceMonitor {
|
||||
if (!PerformanceMonitor.instance) {
|
||||
PerformanceMonitor.instance = new PerformanceMonitor();
|
||||
// 启动定时统计报告
|
||||
PerformanceMonitor.instance.startPeriodicReport();
|
||||
}
|
||||
return PerformanceMonitor.instance;
|
||||
static getInstance (): PerformanceMonitor {
|
||||
if (!PerformanceMonitor.instance) {
|
||||
PerformanceMonitor.instance = new PerformanceMonitor();
|
||||
// 启动定时统计报告
|
||||
PerformanceMonitor.instance.startPeriodicReport();
|
||||
}
|
||||
return PerformanceMonitor.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 开始定时统计报告 (每60秒)
|
||||
*/
|
||||
private startPeriodicReport(): void {
|
||||
if (this.reportInterval) {
|
||||
clearInterval(this.reportInterval);
|
||||
}
|
||||
|
||||
this.reportInterval = setInterval(() => {
|
||||
if (this.stats.size > 0) {
|
||||
this.printPeriodicReport();
|
||||
this.writeDetailedLogToFile();
|
||||
}
|
||||
}, 60000); // 60秒
|
||||
private startPeriodicReport (): void {
|
||||
if (this.reportInterval) {
|
||||
clearInterval(this.reportInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
this.reportInterval = setInterval(() => {
|
||||
if (this.stats.size > 0) {
|
||||
this.printPeriodicReport();
|
||||
this.writeDetailedLogToFile();
|
||||
}
|
||||
}, 60000); // 60秒
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定时统计报告
|
||||
*/
|
||||
stopPeriodicReport(): void {
|
||||
if (this.reportInterval) {
|
||||
clearInterval(this.reportInterval);
|
||||
this.reportInterval = null;
|
||||
}
|
||||
stopPeriodicReport (): void {
|
||||
if (this.reportInterval) {
|
||||
clearInterval(this.reportInterval);
|
||||
this.reportInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 打印定时统计报告 (简化版本)
|
||||
*/
|
||||
private printPeriodicReport(): void {
|
||||
const now = new Date().toLocaleString();
|
||||
console.log(`\n=== 性能监控定时报告 [${now}] ===`);
|
||||
private printPeriodicReport (): void {
|
||||
const now = new Date().toLocaleString();
|
||||
console.log(`\n=== 性能监控定时报告 [${now}] ===`);
|
||||
|
||||
const totalFunctions = this.stats.size;
|
||||
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
|
||||
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
|
||||
const totalFunctions = this.stats.size;
|
||||
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
|
||||
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
|
||||
|
||||
console.log(`📊 总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms`);
|
||||
console.log(`📊 总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms`);
|
||||
|
||||
// 显示Top 5最活跃的函数
|
||||
console.log('\n🔥 最活跃函数 (Top 5):');
|
||||
this.getTopByCallCount(5).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
|
||||
});
|
||||
// 显示Top 5最活跃的函数
|
||||
console.log('\n🔥 最活跃函数 (Top 5):');
|
||||
this.getTopByCallCount(5).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
// 显示Top 5最耗时的函数
|
||||
console.log('\n⏱️ 最耗时函数 (Top 5):');
|
||||
this.getTopByTotalTime(5).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms`);
|
||||
});
|
||||
// 显示Top 5最耗时的函数
|
||||
console.log('\n⏱️ 最耗时函数 (Top 5):');
|
||||
this.getTopByTotalTime(5).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
console.log('===============================\n');
|
||||
}
|
||||
console.log('===============================\n');
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 将详细统计数据写入日志文件
|
||||
*/
|
||||
private writeDetailedLogToFile(): void {
|
||||
try {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
|
||||
const timeStr = now.toTimeString().split(' ')[0]?.replace(/:/g, '-') || 'unknown-time';
|
||||
const timestamp = `${dateStr}_${timeStr}`;
|
||||
const fileName = `${timestamp}.log.txt`;
|
||||
const logPath = path.join(process.cwd(), 'logs', fileName);
|
||||
private writeDetailedLogToFile (): void {
|
||||
try {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
|
||||
const timeStr = now.toTimeString().split(' ')[0]?.replace(/:/g, '-') || 'unknown-time';
|
||||
const timestamp = `${dateStr}_${timeStr}`;
|
||||
const fileName = `${timestamp}.log.txt`;
|
||||
const logPath = path.join(process.cwd(), 'logs', fileName);
|
||||
|
||||
// 确保logs目录存在
|
||||
const logsDir = path.dirname(logPath);
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
// 确保logs目录存在
|
||||
const logsDir = path.dirname(logPath);
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const totalFunctions = this.stats.size;
|
||||
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
|
||||
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
|
||||
const totalFunctions = this.stats.size;
|
||||
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
|
||||
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
|
||||
|
||||
let logContent = '';
|
||||
logContent += `=== 性能监控详细报告 ===\n`;
|
||||
logContent += `生成时间: ${now.toLocaleString()}\n`;
|
||||
logContent += `统计周期: 60秒\n`;
|
||||
logContent += `总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms\n\n`;
|
||||
let logContent = '';
|
||||
logContent += '=== 性能监控详细报告 ===\n';
|
||||
logContent += `生成时间: ${now.toLocaleString()}\n`;
|
||||
logContent += '统计周期: 60秒\n';
|
||||
logContent += `总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms\n\n`;
|
||||
|
||||
// 详细函数统计
|
||||
logContent += `=== 所有函数详细统计 ===\n`;
|
||||
const allStats = this.getStats().sort((a, b) => b.totalTime - a.totalTime);
|
||||
|
||||
allStats.forEach((stat, index) => {
|
||||
logContent += `${index + 1}. 函数: ${stat.name}\n`;
|
||||
logContent += ` 文件: ${stat.fileName || 'N/A'}\n`;
|
||||
logContent += ` 行号: ${stat.lineNumber || 'N/A'}\n`;
|
||||
logContent += ` 调用次数: ${stat.callCount}\n`;
|
||||
logContent += ` 总耗时: ${stat.totalTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 平均耗时: ${stat.averageTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 最小耗时: ${stat.minTime === Infinity ? 'N/A' : stat.minTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 最大耗时: ${stat.maxTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 性能占比: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
|
||||
logContent += `\n`;
|
||||
});
|
||||
// 详细函数统计
|
||||
logContent += '=== 所有函数详细统计 ===\n';
|
||||
const allStats = this.getStats().sort((a, b) => b.totalTime - a.totalTime);
|
||||
|
||||
// 排行榜统计
|
||||
logContent += `=== 总耗时排行榜 (Top 20) ===\n`;
|
||||
this.getTopByTotalTime(20).forEach((stat, index) => {
|
||||
logContent += `${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
|
||||
});
|
||||
allStats.forEach((stat, index) => {
|
||||
logContent += `${index + 1}. 函数: ${stat.name}\n`;
|
||||
logContent += ` 文件: ${stat.fileName || 'N/A'}\n`;
|
||||
logContent += ` 行号: ${stat.lineNumber || 'N/A'}\n`;
|
||||
logContent += ` 调用次数: ${stat.callCount}\n`;
|
||||
logContent += ` 总耗时: ${stat.totalTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 平均耗时: ${stat.averageTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 最小耗时: ${stat.minTime === Infinity ? 'N/A' : stat.minTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 最大耗时: ${stat.maxTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 性能占比: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
|
||||
logContent += '\n';
|
||||
});
|
||||
|
||||
logContent += `\n=== 调用次数排行榜 (Top 20) ===\n`;
|
||||
this.getTopByCallCount(20).forEach((stat, index) => {
|
||||
logContent += `${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
|
||||
});
|
||||
// 排行榜统计
|
||||
logContent += '=== 总耗时排行榜 (Top 20) ===\n';
|
||||
this.getTopByTotalTime(20).forEach((stat, index) => {
|
||||
logContent += `${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
|
||||
});
|
||||
|
||||
logContent += `\n=== 平均耗时排行榜 (Top 20) ===\n`;
|
||||
this.getTopByAverageTime(20).forEach((stat, index) => {
|
||||
logContent += `${index + 1}. ${stat.name} - 平均: ${stat.averageTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms\n`;
|
||||
});
|
||||
logContent += '\n=== 调用次数排行榜 (Top 20) ===\n';
|
||||
this.getTopByCallCount(20).forEach((stat, index) => {
|
||||
logContent += `${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
|
||||
});
|
||||
|
||||
logContent += `\n=== 性能热点分析 ===\n`;
|
||||
// 找出最耗时的前10个函数
|
||||
const hotSpots = this.getTopByTotalTime(10);
|
||||
hotSpots.forEach((stat, index) => {
|
||||
const efficiency = stat.callCount / stat.totalTime; // 每毫秒的调用次数
|
||||
logContent += `${index + 1}. ${stat.name}\n`;
|
||||
logContent += ` 性能影响: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
|
||||
logContent += ` 调用效率: ${efficiency.toFixed(4)} 调用/ms\n`;
|
||||
logContent += ` 优化建议: ${stat.averageTime > 10 ? '考虑优化此函数的执行效率' :
|
||||
stat.callCount > 1000 ? '考虑减少此函数的调用频率' :
|
||||
'性能表现良好'}\n\n`;
|
||||
});
|
||||
logContent += '\n=== 平均耗时排行榜 (Top 20) ===\n';
|
||||
this.getTopByAverageTime(20).forEach((stat, index) => {
|
||||
logContent += `${index + 1}. ${stat.name} - 平均: ${stat.averageTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms\n`;
|
||||
});
|
||||
|
||||
logContent += `=== 报告结束 ===\n`;
|
||||
logContent += '\n=== 性能热点分析 ===\n';
|
||||
// 找出最耗时的前10个函数
|
||||
const hotSpots = this.getTopByTotalTime(10);
|
||||
hotSpots.forEach((stat, index) => {
|
||||
const efficiency = stat.callCount / stat.totalTime; // 每毫秒的调用次数
|
||||
logContent += `${index + 1}. ${stat.name}\n`;
|
||||
logContent += ` 性能影响: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
|
||||
logContent += ` 调用效率: ${efficiency.toFixed(4)} 调用/ms\n`;
|
||||
logContent += ` 优化建议: ${stat.averageTime > 10
|
||||
? '考虑优化此函数的执行效率'
|
||||
: stat.callCount > 1000
|
||||
? '考虑减少此函数的调用频率'
|
||||
: '性能表现良好'}\n\n`;
|
||||
});
|
||||
|
||||
// 写入文件
|
||||
fs.writeFileSync(logPath, logContent, 'utf8');
|
||||
console.log(`📄 详细性能报告已保存到: ${logPath}`);
|
||||
logContent += '=== 报告结束 ===\n';
|
||||
|
||||
} catch (error) {
|
||||
console.error('写入性能日志文件时出错:', error);
|
||||
}
|
||||
// 写入文件
|
||||
fs.writeFileSync(logPath, logContent, 'utf8');
|
||||
console.log(`📄 详细性能报告已保存到: ${logPath}`);
|
||||
} catch (error) {
|
||||
console.error('写入性能日志文件时出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 开始记录函数调用
|
||||
*/
|
||||
startFunction(functionName: string, fileName?: string, lineNumber?: number): string {
|
||||
const callId = `${functionName}_${Date.now()}_${Math.random()}`;
|
||||
this.startTimes.set(callId, performance.now());
|
||||
startFunction (functionName: string, fileName?: string, lineNumber?: number): string {
|
||||
const callId = `${functionName}_${Date.now()}_${Math.random()}`;
|
||||
this.startTimes.set(callId, performance.now());
|
||||
|
||||
// 初始化或更新统计信息
|
||||
if (!this.stats.has(functionName)) {
|
||||
this.stats.set(functionName, {
|
||||
name: functionName,
|
||||
callCount: 0,
|
||||
totalTime: 0,
|
||||
averageTime: 0,
|
||||
minTime: Infinity,
|
||||
maxTime: 0,
|
||||
fileName,
|
||||
lineNumber
|
||||
});
|
||||
}
|
||||
|
||||
const stat = this.stats.get(functionName)!;
|
||||
stat.callCount++;
|
||||
|
||||
return callId;
|
||||
// 初始化或更新统计信息
|
||||
if (!this.stats.has(functionName)) {
|
||||
this.stats.set(functionName, {
|
||||
name: functionName,
|
||||
callCount: 0,
|
||||
totalTime: 0,
|
||||
averageTime: 0,
|
||||
minTime: Infinity,
|
||||
maxTime: 0,
|
||||
fileName,
|
||||
lineNumber,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
const stat = this.stats.get(functionName)!;
|
||||
stat.callCount++;
|
||||
|
||||
return callId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束记录函数调用
|
||||
*/
|
||||
endFunction(callId: string, functionName: string): void {
|
||||
const startTime = this.startTimes.get(callId);
|
||||
if (!startTime) return;
|
||||
endFunction (callId: string, functionName: string): void {
|
||||
const startTime = this.startTimes.get(callId);
|
||||
if (!startTime) return;
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
this.startTimes.delete(callId);
|
||||
this.startTimes.delete(callId);
|
||||
|
||||
const stat = this.stats.get(functionName);
|
||||
if (!stat) return;
|
||||
const stat = this.stats.get(functionName);
|
||||
if (!stat) return;
|
||||
|
||||
stat.totalTime += duration;
|
||||
stat.averageTime = stat.totalTime / stat.callCount;
|
||||
stat.minTime = Math.min(stat.minTime, duration);
|
||||
stat.maxTime = Math.max(stat.maxTime, duration);
|
||||
}
|
||||
stat.totalTime += duration;
|
||||
stat.averageTime = stat.totalTime / stat.callCount;
|
||||
stat.minTime = Math.min(stat.minTime, duration);
|
||||
stat.maxTime = Math.max(stat.maxTime, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取所有统计信息
|
||||
*/
|
||||
getStats(): FunctionStats[] {
|
||||
return Array.from(this.stats.values());
|
||||
}
|
||||
getStats (): FunctionStats[] {
|
||||
return Array.from(this.stats.values());
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取排行榜 - 按总耗时排序
|
||||
*/
|
||||
getTopByTotalTime(limit = 20): FunctionStats[] {
|
||||
return this.getStats()
|
||||
.sort((a, b) => b.totalTime - a.totalTime)
|
||||
.slice(0, limit);
|
||||
}
|
||||
getTopByTotalTime (limit = 20): FunctionStats[] {
|
||||
return this.getStats()
|
||||
.sort((a, b) => b.totalTime - a.totalTime)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取排行榜 - 按调用次数排序
|
||||
*/
|
||||
getTopByCallCount(limit = 20): FunctionStats[] {
|
||||
return this.getStats()
|
||||
.sort((a, b) => b.callCount - a.callCount)
|
||||
.slice(0, limit);
|
||||
}
|
||||
getTopByCallCount (limit = 20): FunctionStats[] {
|
||||
return this.getStats()
|
||||
.sort((a, b) => b.callCount - a.callCount)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取排行榜 - 按平均耗时排序
|
||||
*/
|
||||
getTopByAverageTime(limit = 20): FunctionStats[] {
|
||||
return this.getStats()
|
||||
.sort((a, b) => b.averageTime - a.averageTime)
|
||||
.slice(0, limit);
|
||||
}
|
||||
getTopByAverageTime (limit = 20): FunctionStats[] {
|
||||
return this.getStats()
|
||||
.sort((a, b) => b.averageTime - a.averageTime)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 清空统计数据
|
||||
*/
|
||||
clear(): void {
|
||||
this.stats.clear();
|
||||
this.startTimes.clear();
|
||||
}
|
||||
clear (): void {
|
||||
this.stats.clear();
|
||||
this.startTimes.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 打印统计报告
|
||||
*/
|
||||
printReport(): void {
|
||||
console.log('\n=== 函数性能监控报告 ===');
|
||||
printReport (): void {
|
||||
console.log('\n=== 函数性能监控报告 ===');
|
||||
|
||||
console.log('\n🔥 总耗时排行榜 (Top 10):');
|
||||
this.getTopByTotalTime(10).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
|
||||
});
|
||||
console.log('\n🔥 总耗时排行榜 (Top 10):');
|
||||
this.getTopByTotalTime(10).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
console.log('\n📈 调用次数排行榜 (Top 10):');
|
||||
this.getTopByCallCount(10).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
|
||||
});
|
||||
console.log('\n📈 调用次数排行榜 (Top 10):');
|
||||
this.getTopByCallCount(10).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
console.log('\n⏱️ 平均耗时排行榜 (Top 10):');
|
||||
this.getTopByAverageTime(10).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 平均耗时: ${stat.averageTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
|
||||
});
|
||||
console.log('\n⏱️ 平均耗时排行榜 (Top 10):');
|
||||
this.getTopByAverageTime(10).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 平均耗时: ${stat.averageTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
console.log('\n========================\n');
|
||||
}
|
||||
console.log('\n========================\n');
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取JSON格式的统计数据
|
||||
*/
|
||||
toJSON(): FunctionStats[] {
|
||||
return this.getStats();
|
||||
}
|
||||
toJSON (): FunctionStats[] {
|
||||
return this.getStats();
|
||||
}
|
||||
}
|
||||
|
||||
// 全局性能监控器实例
|
||||
@@ -297,20 +298,20 @@ export const performanceMonitor = PerformanceMonitor.getInstance();
|
||||
|
||||
// 在进程退出时打印报告并停止定时器
|
||||
if (typeof process !== 'undefined') {
|
||||
process.on('exit', () => {
|
||||
performanceMonitor.stopPeriodicReport();
|
||||
performanceMonitor.printReport();
|
||||
});
|
||||
process.on('exit', () => {
|
||||
performanceMonitor.stopPeriodicReport();
|
||||
performanceMonitor.printReport();
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
performanceMonitor.stopPeriodicReport();
|
||||
performanceMonitor.printReport();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
performanceMonitor.stopPeriodicReport();
|
||||
performanceMonitor.printReport();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
performanceMonitor.stopPeriodicReport();
|
||||
performanceMonitor.printReport();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
performanceMonitor.stopPeriodicReport();
|
||||
performanceMonitor.printReport();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { LogWrapper } from '@/common/log';
|
||||
|
||||
export function proxyHandlerOf(logger: LogWrapper) {
|
||||
return {
|
||||
get(target: any, prop: any, receiver: any) {
|
||||
if (typeof target[prop] === 'undefined') {
|
||||
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
return (..._args: unknown[]) => {
|
||||
logger.logDebug(`${target.constructor.name} has no method ${prop}`);
|
||||
};
|
||||
}
|
||||
// 如果方法存在,正常返回
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
};
|
||||
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));
|
||||
export function proxiedListenerOf<T extends object> (listener: T, logger: LogWrapper) {
|
||||
return new Proxy<T>(listener, proxyHandlerOf(logger));
|
||||
}
|
||||
|
||||
@@ -7,101 +7,100 @@ import { getMajorPath } from '@/core';
|
||||
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from './types';
|
||||
|
||||
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 };
|
||||
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;
|
||||
this.QQVersionConfigPath = getQQVersionConfigPath(this.QQMainPath);
|
||||
constructor (context: { logger: LogWrapper; }) {
|
||||
// 基础目录获取
|
||||
this.context = context;
|
||||
this.QQMainPath = process.execPath;
|
||||
this.QQVersionConfigPath = getQQVersionConfigPath(this.QQMainPath);
|
||||
|
||||
// 基础信息获取 无快更则启用默认模板填充
|
||||
this.isQuickUpdate = !!this.QQVersionConfigPath;
|
||||
this.QQVersionConfig = this.isQuickUpdate
|
||||
? JSON.parse(fs.readFileSync(this.QQVersionConfigPath!).toString())
|
||||
: getDefaultQQVersionConfigInfo();
|
||||
|
||||
//基础信息获取 无快更则启用默认模板填充
|
||||
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;
|
||||
}
|
||||
|
||||
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 appidTbale = AppidTable as unknown as QQAppidTableType;
|
||||
const fullVersion = this.getFullQQVersion();
|
||||
if (fullVersion) {
|
||||
const data = appidTbale[fullVersion];
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
//基础函数
|
||||
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 appidTbale = AppidTable as unknown as QQAppidTableType;
|
||||
const fullVersion = this.getFullQQVersion();
|
||||
if (fullVersion) {
|
||||
const data = appidTbale[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) {
|
||||
const majorPath = getMajorPath(QQVersion);
|
||||
const appid = parseAppidFromMajor(majorPath);
|
||||
return appid;
|
||||
// 通过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) {
|
||||
const majorPath = getMajorPath(QQVersion);
|
||||
const appid = parseAppidFromMajor(majorPath);
|
||||
return appid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +1,115 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
|
||||
export class RequestUtil {
|
||||
// 适用于获取服务器下发cookies时获取,仅GET
|
||||
static async HttpsGetCookies(url: string): Promise<{ [key: string]: string }> {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = client.get(url, (res) => {
|
||||
const cookies: { [key: string]: string } = {};
|
||||
// 适用于获取服务器下发cookies时获取,仅GET
|
||||
static async HttpsGetCookies (url: string): Promise<{ [key: string]: string }> {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = client.get(url, (res) => {
|
||||
const cookies: { [key: string]: string } = {};
|
||||
|
||||
res.on('data', () => { }); // Necessary to consume the stream
|
||||
res.on('end', () => {
|
||||
this.handleRedirect(res, url, cookies)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
if (res.headers['set-cookie']) {
|
||||
this.extractCookies(res.headers['set-cookie'], cookies);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
res.on('data', () => { }); // Necessary to consume the stream
|
||||
res.on('end', () => {
|
||||
this.handleRedirect(res, url, cookies)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
private static async handleRedirect(res: http.IncomingMessage, url: string, cookies: { [key: string]: string }): Promise<{ [key: string]: string }> {
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
if (res.headers.location) {
|
||||
const redirectUrl = new URL(res.headers.location, url);
|
||||
const redirectCookies = await this.HttpsGetCookies(redirectUrl.href);
|
||||
// 合并重定向过程中的cookies
|
||||
return { ...cookies, ...redirectCookies };
|
||||
}
|
||||
if (res.headers['set-cookie']) {
|
||||
this.extractCookies(res.headers['set-cookie'], cookies);
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
});
|
||||
|
||||
private static extractCookies(setCookieHeaders: string[], cookies: { [key: string]: string }) {
|
||||
setCookieHeaders.forEach((cookie) => {
|
||||
const parts = cookie.split(';')[0]?.split('=');
|
||||
if (parts) {
|
||||
const key = parts[0];
|
||||
const value = parts[1];
|
||||
if (key && value && key.length > 0 && value.length > 0) {
|
||||
cookies[key] = value;
|
||||
}
|
||||
}
|
||||
req.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string }): Promise<{ [key: string]: string }> {
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
if (res.headers.location) {
|
||||
const redirectUrl = new URL(res.headers.location, url);
|
||||
const redirectCookies = await this.HttpsGetCookies(redirectUrl.href);
|
||||
// 合并重定向过程中的cookies
|
||||
return { ...cookies, ...redirectCookies };
|
||||
}
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string }) {
|
||||
setCookieHeaders.forEach((cookie) => {
|
||||
const parts = cookie.split(';')[0]?.split('=');
|
||||
if (parts) {
|
||||
const key = parts[0];
|
||||
const value = parts[1];
|
||||
if (key && value && key.length > 0 && value.length > 0) {
|
||||
cookies[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 请求和回复都是JSON data传原始内容 自动编码json
|
||||
static async HttpGetJson<T>(url: string, method: string = 'GET', data?: any, headers: {
|
||||
[key: string]: string
|
||||
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise<T> {
|
||||
const option = new URL(url);
|
||||
const protocol = url.startsWith('https://') ? https : http;
|
||||
const options = {
|
||||
hostname: option.hostname,
|
||||
port: option.port,
|
||||
path: option.pathname + option.search,
|
||||
method,
|
||||
headers,
|
||||
};
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// 'Content-Length': Buffer.byteLength(postData),
|
||||
// },
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = protocol.request(options, (res: http.IncomingMessage) => {
|
||||
let responseBody = '';
|
||||
res.on('data', (chunk: string | Buffer) => {
|
||||
responseBody += chunk.toString();
|
||||
});
|
||||
}
|
||||
|
||||
// 请求和回复都是JSON data传原始内容 自动编码json
|
||||
static async HttpGetJson<T>(url: string, method: string = 'GET', data?: any, headers: {
|
||||
[key: string]: string
|
||||
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise<T> {
|
||||
const option = new URL(url);
|
||||
const protocol = url.startsWith('https://') ? https : http;
|
||||
const options = {
|
||||
hostname: option.hostname,
|
||||
port: option.port,
|
||||
path: option.pathname + option.search,
|
||||
method: method,
|
||||
headers: headers,
|
||||
};
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// 'Content-Length': Buffer.byteLength(postData),
|
||||
// },
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = protocol.request(options, (res: http.IncomingMessage) => {
|
||||
let responseBody = '';
|
||||
res.on('data', (chunk: string | Buffer) => {
|
||||
responseBody += chunk.toString();
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
if (isJsonRet) {
|
||||
const responseJson = JSON.parse(responseBody);
|
||||
resolve(responseJson as T);
|
||||
} else {
|
||||
resolve(responseBody as T);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Unexpected status code: ${res.statusCode}`));
|
||||
}
|
||||
} catch (parseError: unknown) {
|
||||
reject(new Error((parseError as Error).message));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
if (isArgJson) {
|
||||
req.write(JSON.stringify(data));
|
||||
} else {
|
||||
req.write(data);
|
||||
}
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
if (isJsonRet) {
|
||||
const responseJson = JSON.parse(responseBody);
|
||||
resolve(responseJson as T);
|
||||
} else {
|
||||
resolve(responseBody as T);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Unexpected status code: ${res.statusCode}`));
|
||||
}
|
||||
req.end();
|
||||
} catch (parseError: unknown) {
|
||||
reject(new Error((parseError as Error).message));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 请求返回都是原始内容
|
||||
static async HttpGetText(url: string, method: string = 'GET', data?: any, headers: { [key: string]: string } = {}) {
|
||||
return this.HttpGetJson<string>(url, method, data, headers, false, false);
|
||||
}
|
||||
req.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
if (isArgJson) {
|
||||
req.write(JSON.stringify(data));
|
||||
} else {
|
||||
req.write(data);
|
||||
}
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 请求返回都是原始内容
|
||||
static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string } = {}) {
|
||||
return this.HttpGetJson<string>(url, method, data, headers, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
export type StoreValueType = string | number | boolean | object | null;
|
||||
|
||||
export type StoreValue<T extends StoreValueType = StoreValueType> = {
|
||||
value: T;
|
||||
expiresAt?: number;
|
||||
value: T;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
class Store {
|
||||
// 使用Map存储键值对
|
||||
private store: Map<string, StoreValue>;
|
||||
// 定时清理器
|
||||
private cleanerTimer: NodeJS.Timeout;
|
||||
// 用于分批次扫描的游标
|
||||
private scanCursor: number = 0;
|
||||
// 使用Map存储键值对
|
||||
private store: Map<string, StoreValue>;
|
||||
// 定时清理器
|
||||
private cleanerTimer: NodeJS.Timeout;
|
||||
// 用于分批次扫描的游标
|
||||
private scanCursor: number = 0;
|
||||
|
||||
/**
|
||||
/**
|
||||
* Store
|
||||
* @param cleanInterval 清理间隔
|
||||
* @param scanLimit 扫描限制(每次最多检查的键数)
|
||||
*/
|
||||
constructor(
|
||||
cleanInterval: number = 1000, // 默认1秒执行一次
|
||||
private scanLimit: number = 100 // 每次最多检查100个键
|
||||
) {
|
||||
this.store = new Map();
|
||||
this.cleanerTimer = setInterval(() => this.cleanupExpired(), cleanInterval);
|
||||
}
|
||||
constructor (
|
||||
cleanInterval: number = 1000, // 默认1秒执行一次
|
||||
private scanLimit: number = 100 // 每次最多检查100个键
|
||||
) {
|
||||
this.store = new Map();
|
||||
this.cleanerTimer = setInterval(() => this.cleanupExpired(), cleanInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 设置键值对
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
@@ -34,155 +34,155 @@ class Store {
|
||||
* @returns void
|
||||
* @example store.set('key', 'value', 60)
|
||||
*/
|
||||
set<T extends StoreValueType>(key: string, value: T, ttl?: number): void {
|
||||
if (ttl && ttl <= 0) {
|
||||
this.del(key);
|
||||
return;
|
||||
}
|
||||
const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
|
||||
this.store.set(key, { value, expiresAt });
|
||||
set<T extends StoreValueType>(key: string, value: T, ttl?: number): void {
|
||||
if (ttl && ttl <= 0) {
|
||||
this.del(key);
|
||||
return;
|
||||
}
|
||||
const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
|
||||
this.store.set(key, { value, expiresAt });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 清理过期键
|
||||
*/
|
||||
private cleanupExpired(): void {
|
||||
const now = Date.now();
|
||||
const keys = Array.from(this.store.keys());
|
||||
let scanned = 0;
|
||||
private cleanupExpired (): void {
|
||||
const now = Date.now();
|
||||
const keys = Array.from(this.store.keys());
|
||||
let scanned = 0;
|
||||
|
||||
// 分批次扫描
|
||||
while (scanned < this.scanLimit && this.scanCursor < keys.length) {
|
||||
const key = keys[this.scanCursor++];
|
||||
const entry = this.store.get(key!)!;
|
||||
// 分批次扫描
|
||||
while (scanned < this.scanLimit && this.scanCursor < keys.length) {
|
||||
const key = keys[this.scanCursor++];
|
||||
const entry = this.store.get(key!)!;
|
||||
|
||||
if (entry.expiresAt && entry.expiresAt < now) {
|
||||
this.store.delete(key!);
|
||||
}
|
||||
if (entry.expiresAt && entry.expiresAt < now) {
|
||||
this.store.delete(key!);
|
||||
}
|
||||
|
||||
scanned++;
|
||||
}
|
||||
|
||||
// 重置游标(环形扫描)
|
||||
if (this.scanCursor >= keys.length) {
|
||||
this.scanCursor = 0;
|
||||
}
|
||||
scanned++;
|
||||
}
|
||||
|
||||
/**
|
||||
// 重置游标(环形扫描)
|
||||
if (this.scanCursor >= keys.length) {
|
||||
this.scanCursor = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取键值
|
||||
* @param key 键
|
||||
* @returns T | null
|
||||
* @example store.get('key')
|
||||
*/
|
||||
get<T extends StoreValueType>(key: string): T | null {
|
||||
this.checkKeyExpiry(key); // 每次访问都检查
|
||||
const entry = this.store.get(key);
|
||||
return entry ? (entry.value as T) : null;
|
||||
}
|
||||
get<T extends StoreValueType>(key: string): T | null {
|
||||
this.checkKeyExpiry(key); // 每次访问都检查
|
||||
const entry = this.store.get(key);
|
||||
return entry ? (entry.value as T) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 检查键是否过期
|
||||
* @param key 键
|
||||
*/
|
||||
private checkKeyExpiry(key: string): void {
|
||||
const entry = this.store.get(key);
|
||||
if (entry?.expiresAt && entry.expiresAt < Date.now()) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
private checkKeyExpiry (key: string): void {
|
||||
const entry = this.store.get(key);
|
||||
if (entry?.expiresAt && entry.expiresAt < Date.now()) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 检查键是否存在
|
||||
* @param keys 键
|
||||
* @returns number
|
||||
* @example store.exists('key1', 'key2')
|
||||
*/
|
||||
exists(...keys: string[]): number {
|
||||
return keys.filter((key) => {
|
||||
this.checkKeyExpiry(key);
|
||||
return this.store.has(key);
|
||||
}).length;
|
||||
}
|
||||
exists (...keys: string[]): number {
|
||||
return keys.filter((key) => {
|
||||
this.checkKeyExpiry(key);
|
||||
return this.store.has(key);
|
||||
}).length;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 关闭存储器
|
||||
*/
|
||||
shutdown(): void {
|
||||
clearInterval(this.cleanerTimer);
|
||||
this.store.clear();
|
||||
}
|
||||
shutdown (): void {
|
||||
clearInterval(this.cleanerTimer);
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 删除键
|
||||
* @param keys 键
|
||||
* @returns number
|
||||
* @example store.del('key1', 'key2')
|
||||
*/
|
||||
del(...keys: string[]): number {
|
||||
return keys.reduce((count, key) => (this.store.delete(key) ? count + 1 : count), 0);
|
||||
}
|
||||
del (...keys: string[]): number {
|
||||
return keys.reduce((count, key) => (this.store.delete(key) ? count + 1 : count), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 设置键的过期时间
|
||||
* @param key 键
|
||||
* @param seconds 过期时间(秒)
|
||||
* @returns boolean
|
||||
* @example store.expire('key', 60)
|
||||
*/
|
||||
expire(key: string, seconds: number): boolean {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return false;
|
||||
expire (key: string, seconds: number): boolean {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return false;
|
||||
|
||||
entry.expiresAt = Date.now() + seconds * 1000;
|
||||
return true;
|
||||
}
|
||||
entry.expiresAt = Date.now() + seconds * 1000;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 获取键的过期时间
|
||||
* @param key 键
|
||||
* @returns number | null
|
||||
* @example store.ttl('key')
|
||||
*/
|
||||
ttl(key: string): number | null {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return null;
|
||||
ttl (key: string): number | null {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
if (!entry.expiresAt) return -1;
|
||||
const remaining = entry.expiresAt - Date.now();
|
||||
return remaining > 0 ? Math.floor(remaining / 1000) : -2;
|
||||
}
|
||||
if (!entry.expiresAt) return -1;
|
||||
const remaining = entry.expiresAt - Date.now();
|
||||
return remaining > 0 ? Math.floor(remaining / 1000) : -2;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 键值数字递增
|
||||
* @param key 键
|
||||
* @returns number
|
||||
* @example store.incr('key')
|
||||
*/
|
||||
incr(key: string): number {
|
||||
const current = this.get<StoreValueType>(key);
|
||||
incr (key: string): number {
|
||||
const current = this.get<StoreValueType>(key);
|
||||
|
||||
if (current === null) {
|
||||
this.set(key, 1, 60);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let numericValue: number;
|
||||
if (typeof current === 'number') {
|
||||
numericValue = current;
|
||||
} else if (typeof current === 'string') {
|
||||
if (!/^-?\d+$/.test(current)) {
|
||||
throw new Error('ERR value is not an integer');
|
||||
}
|
||||
numericValue = parseInt(current, 10);
|
||||
} else {
|
||||
throw new Error('ERR value is not an integer');
|
||||
}
|
||||
|
||||
const newValue = numericValue + 1;
|
||||
this.set(key, newValue, 60);
|
||||
return newValue;
|
||||
if (current === null) {
|
||||
this.set(key, 1, 60);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let numericValue: number;
|
||||
if (typeof current === 'number') {
|
||||
numericValue = current;
|
||||
} else if (typeof current === 'string') {
|
||||
if (!/^-?\d+$/.test(current)) {
|
||||
throw new Error('ERR value is not an integer');
|
||||
}
|
||||
numericValue = parseInt(current, 10);
|
||||
} else {
|
||||
throw new Error('ERR value is not an integer');
|
||||
}
|
||||
|
||||
const newValue = numericValue + 1;
|
||||
this.set(key, newValue, 60);
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
|
||||
const store = new Store();
|
||||
|
||||
@@ -5,12 +5,11 @@ import path from 'node:path';
|
||||
let osName: string;
|
||||
|
||||
try {
|
||||
osName = os.hostname();
|
||||
osName = os.hostname();
|
||||
} catch {
|
||||
osName = 'NapCat'; // + crypto.randomUUID().substring(0, 4);
|
||||
osName = 'NapCat'; // + crypto.randomUUID().substring(0, 4);
|
||||
}
|
||||
|
||||
|
||||
const homeDir = os.homedir();
|
||||
|
||||
export const systemPlatform = os.platform();
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
//QQVersionType
|
||||
// QQVersionType
|
||||
export type QQPackageInfoType = {
|
||||
version: string;
|
||||
buildVersion: string;
|
||||
platform: string;
|
||||
eleArch: string;
|
||||
}
|
||||
version: string;
|
||||
buildVersion: string;
|
||||
platform: string;
|
||||
eleArch: string;
|
||||
};
|
||||
export type QQVersionConfigType = {
|
||||
baseVersion: string;
|
||||
curVersion: string;
|
||||
prevVersion: string;
|
||||
onErrorVersions: Array<unknown>;
|
||||
buildId: string;
|
||||
}
|
||||
baseVersion: string;
|
||||
curVersion: string;
|
||||
prevVersion: string;
|
||||
onErrorVersions: Array<unknown>;
|
||||
buildId: string;
|
||||
};
|
||||
export type QQAppidTableType = {
|
||||
[key: string]: { appid: string, qua: string };
|
||||
}
|
||||
[key: string]: { appid: string, qua: string };
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,35 +1,34 @@
|
||||
import { Worker } from 'worker_threads';
|
||||
|
||||
export async function runTask<T, R>(workerScript: string, taskData: T): Promise<R> {
|
||||
let worker = new Worker(workerScript);
|
||||
try {
|
||||
return await new Promise<R>((resolve, reject) => {
|
||||
worker.on('message', (result: R) => {
|
||||
if ((result as any)?.log) {
|
||||
console.error('Worker Log--->:', (result as { log: string }).log);
|
||||
}
|
||||
if ((result as any)?.error) {
|
||||
reject(new Error("Worker error: " + (result as { error: string }).error));
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
export async function runTask<T, R> (workerScript: string, taskData: T): Promise<R> {
|
||||
const worker = new Worker(workerScript);
|
||||
try {
|
||||
return await new Promise<R>((resolve, reject) => {
|
||||
worker.on('message', (result: R) => {
|
||||
if ((result as any)?.log) {
|
||||
console.error('Worker Log--->:', (result as { log: string }).log);
|
||||
}
|
||||
if ((result as any)?.error) {
|
||||
reject(new Error('Worker error: ' + (result as { error: string }).error));
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
|
||||
worker.on('error', (error) => {
|
||||
reject(new Error(`Worker error: ${error.message}`));
|
||||
});
|
||||
worker.on('error', (error) => {
|
||||
reject(new Error(`Worker error: ${error.message}`));
|
||||
});
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Worker stopped with exit code ${code}`));
|
||||
}
|
||||
});
|
||||
worker.postMessage(taskData);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Failed to run task: ${(error as Error).message}`);
|
||||
} finally {
|
||||
// Ensure the worker is terminated after the promise is settled
|
||||
worker.terminate();
|
||||
}
|
||||
worker.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Worker stopped with exit code ${code}`));
|
||||
}
|
||||
});
|
||||
worker.postMessage(taskData);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Failed to run task: ${(error as Error).message}`);
|
||||
} finally {
|
||||
// Ensure the worker is terminated after the promise is settled
|
||||
worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user