SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37

26 包 monorepo(TS 全源码):
- snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包
- snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线
- snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI)
- snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports)
- snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时
- 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏)
- 工具件: 解包器/启动器/便携制作器/doctor

GitHub 关联已全量指向 DandanLLab/SnowCatQQ
This commit is contained in:
天天的鸟蛋蛋 committed 2026-11-12 06:27:36 +08:00
commit b9655a5ab1
1048 files changed
+130851

No files matched your search

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