Compare commits

..

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
51fcc587d1 Add peerUid validation in AT message handler
Co-authored-by: sj817 <74231782+sj817@users.noreply.github.com>
2025-10-02 01:30:31 +00:00
copilot-swe-agent[bot]
374441e880 Add null/undefined parameter validation to getGroupMember
Co-authored-by: sj817 <74231782+sj817@users.noreply.github.com>
2025-10-02 01:28:39 +00:00
copilot-swe-agent[bot]
379b6e81fc Initial analysis - identified undefined parameter issue in getGroupMember
Co-authored-by: sj817 <74231782+sj817@users.noreply.github.com>
2025-10-02 01:27:07 +00:00
copilot-swe-agent[bot]
6ba771424b Initial plan 2025-10-02 01:23:10 +00:00
106 changed files with 920 additions and 1919 deletions

Binary file not shown.

View File

@@ -7,7 +7,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
set RetString=%%b
goto :napcat_boot
)
@@ -16,7 +16,7 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa"
)
set "QQPath=%pathWithoutUninstall%QQ.exe"
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQpath%" (
echo provided QQ path is invalid

View File

@@ -7,7 +7,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
set RetString=%%b
goto :napcat_boot
)
@@ -16,7 +16,7 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa"
)
set "QQPath=%pathWithoutUninstall%QQ.exe"
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQpath%" (
echo provided QQ path is invalid

View File

@@ -16,7 +16,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
set RetString=%%b
goto :napcat_boot
)
@@ -25,7 +25,7 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa"
)
set "QQPath=%pathWithoutUninstall%QQ.exe"
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQPath%" (
echo provided QQ path is invalid

View File

@@ -16,7 +16,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
set RetString=%%b
goto :napcat_boot
)
@@ -25,7 +25,7 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa"
)
set "QQPath=%pathWithoutUninstall%QQ.exe"
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQPath%" (
echo provided QQ path is invalid

View File

@@ -1,9 +1,9 @@
{
"name": "qq-chat",
"verHash": "c50d6326",
"version": "9.9.22-40768",
"linuxVersion": "3.2.20-40768",
"linuxVerHash": "ab90fdfa",
"verHash": "cc326038",
"version": "9.9.21-39038",
"linuxVersion": "3.2.19-39038",
"linuxVerHash": "c773cdf7",
"private": true,
"description": "QQ",
"productName": "QQ",
@@ -17,7 +17,7 @@
"qd": "externals/devtools/cli/index.js"
},
"main": "./loadNapCat.js",
"buildVersion": "40768",
"buildVersion": "39038",
"isPureShell": true,
"isByteCodeShell": true,
"platform": "win32",

View File

@@ -4,7 +4,7 @@
"name": "NapCatQQ",
"slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现",
"version": "4.9.4",
"version": "4.8.116",
"icon": "./logo.png",
"authors": [
{

View File

@@ -82,7 +82,6 @@ export default function FileTable({
setPreviewImages([])
setPreviewIndex(0)
setShowImage(false)
setPage(1)
}, [currentPath])
const onPreviewImage = (name: string, images: PreviewImage[]) => {

View File

@@ -171,8 +171,7 @@ const GenericForm = <T extends keyof NetworkConfigType>({
export default GenericForm
export function random_token(length: number) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-_=+[]{}|;:,.<>?'
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-_=+[]{}|;:,.<>?'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))

View File

@@ -1,10 +1,9 @@
import CryptoJS from 'crypto-js'
import { EventSourcePolyfill } from 'event-source-polyfill'
import { LogLevel } from '@/const/enum'
import { serverRequest } from '@/utils/request'
import CryptoJS from "crypto-js";
export interface Log {
level: LogLevel
message: string
@@ -18,7 +17,7 @@ export default class WebUIManager {
}
public static async loginWithToken(token: string) {
const sha256 = CryptoJS.SHA256(token + '.napcat').toString()
const sha256 = CryptoJS.SHA256(token + '.napcat').toString();
const { data } = await serverRequest.post<ServerResponse<AuthResponse>>(
'/auth/login',
{ hash: sha256 }

View File

@@ -182,4 +182,4 @@ const ServerConfigCard = () => {
)
}
export default ServerConfigCard
export default ServerConfigCard

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "napcat",
"version": "4.8.98",
"version": "4.8.116",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "napcat",
"version": "4.8.98",
"version": "4.8.116",
"dependencies": {
"express": "^5.0.0",
"silk-wasm": "^3.6.1",

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.9.4",
"version": "4.8.116",
"scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",

View File

@@ -8,16 +8,16 @@ 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,
'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
];
@@ -354,11 +354,11 @@ export async function downloadFFmpegIfNotExists(log: LogWrapper) {
return {
path: path.join(currentPath, 'ffmpeg'),
reset: true
}
};
}
return {
path: path.join(currentPath, 'ffmpeg'),
reset: true
}
};
}

View File

@@ -1,131 +0,0 @@
/**
* FFmpeg Adapter Factory
* 自动检测并选择最佳的 FFmpeg 适配器
*/
import { LogWrapper } from './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;
}
}

View File

@@ -1,68 +0,0 @@
/**
* 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>;
/**
* 转换音频为 PCM 格式
* @param filePath 输入文件路径
* @param pcmPath 输出 PCM 文件路径
* @returns PCM 数据 Buffer
*/
convertToPCM(filePath: string, pcmPath: string): Promise<Buffer>;
/**
* 转换音频文件
* @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>;
}

View File

@@ -1,129 +0,0 @@
/**
* FFmpeg Native Addon Adapter
* 使用原生 Node.js Addon 实现的 FFmpeg 适配器
*/
import { platform, arch } from 'node:os';
import path from 'node:path';
import { existsSync } 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();
let addonFileName: string = process.platform + '.' + process.arch;
let addonPath = path.join(binaryPath, "./native/ffmpeg/", `${addonFileName}.node`);
if (existsSync(addonPath)) {
throw new Error(`Unsupported platform: ${platformName} ${archName}`);
}
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 addonPath = getAddonPath(this.binaryPath);
if (!existsSync(addonPath)) {
return false;
}
let temp_addon = { exports: {} };
dlopen(temp_addon, addonPath);
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, 'bmp24');
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);
}
/**
* 转换为 PCM
*/
async convertToPCM(filePath: string, pcmPath: string): Promise<Buffer> {
const addon = this.ensureAddon();
const result = await addon.decodeAudioToPCM(filePath);
// 写入文件
await writeFile(pcmPath, result.pcm);
return result.pcm;
}
/**
* 转换文件
*/
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`);
}
}
/**
* 提取缩略图
*/
async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
const addon = this.ensureAddon();
const info = await addon.getVideoInfo(videoPath);
// 将缩略图写入文件
await writeFile(thumbnailPath, info.image);
}
}

View File

@@ -1,71 +0,0 @@
/**
* 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;
/** Container format name (e.g., "mp4", "mkv", "avi") */
format: string;
/** Video codec name (e.g., "h264", "hevc", "vp9") */
videoCodec: string;
/** 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;
/** 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 {
/**
* 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): Promise<AudioPCMResult>;
}

View File

@@ -1,244 +0,0 @@
/**
* FFmpeg Exec Adapter
* 使用 execFile 调用 FFmpeg 命令行工具的适配器实现
*/
import { readFileSync, existsSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { fileTypeFromFile } from 'file-type';
import { imageSizeFallBack } from '@/image-size';
import { downloadFFmpegIfNotExists } from './download-ffmpeg';
import { LogWrapper } from './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)
]);
// 创建临时缩略图路径
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> {
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; // 默认时长
}
}
/**
* 转换为 PCM
*/
async convertToPCM(filePath: string, pcmPath: string): Promise<Buffer> {
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 readFileSync(pcmPath);
} 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}`);
}
}
}

View File

@@ -1,144 +1,195 @@
import { statSync, existsSync, writeFileSync } from 'fs';
import path from 'path';
import { readFileSync, statSync, existsSync, mkdirSync } from 'fs';
import path, { dirname } from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import type { VideoInfo } from './video';
import { fileTypeFromFile } from 'file-type';
import { fileURLToPath } from 'node:url';
import { platform } from 'node:os';
import { LogWrapper } from './log';
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) {
import { imageSizeFallBack } from '@/image-size';
const currentPath = dirname(fileURLToPath(import.meta.url));
const execFileAsync = promisify(execFile);
const getFFmpegPath = (tool: string): string => {
if (process.platform === 'win32') {
const exeName = `${tool}.exe`;
const localPath = path.join(binaryPath, 'ffmpeg', exeName);
const isLocalExeExists = existsSync(localPath);
return isLocalExeExists ? localPath : exeName;
const isLocalExeExists = existsSync(path.join(currentPath, 'ffmpeg', exeName));
return isLocalExeExists ? path.join(currentPath, 'ffmpeg', exeName) : exeName;
}
return tool;
};
export let FFMPEG_CMD = 'ffmpeg';
export let FFPROBE_CMD = 'ffprobe';
export let FFMPEG_CMD = getFFmpegPath('ffmpeg');
export let FFPROBE_CMD = getFFmpegPath('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;
}
/**
* 获取 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;
}
/**
* 设置 FFmpeg 路径并更新适配器
* @deprecated 建议使用 init() 方法初始化
*/
public static async setFfmpegPath(ffmpegPath: string, logger: LogWrapper): Promise<void> {
// 确保目标目录存在
public static setFfmpegPath(ffmpegPath: string,logger:LogWrapper): 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);
}
}
private static ensureDirExists(filePath: string): void {
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
/**
* 提取视频缩略图
*/
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);
}
/**
* 转换为 PCM 格式
*/
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
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;
this.ensureDirExists(thumbnailPath);
// 使用适配器获取视频信息
const videoInfo = await adapter.getVideoInfo(videoPath);
const { stderr } = await execFileAsync(FFMPEG_CMD, [
'-i', videoPath,
'-ss', '00:00:01.000',
'-vframes', '1',
'-y', // 覆盖输出文件
thumbnailPath
]);
// 如果提供了缩略图路径且适配器返回了缩略图,保存到指定路径
if (thumbnailPath && videoInfo.thumbnail) {
writeFileSync(thumbnailPath, videoInfo.thumbnail);
if (!existsSync(thumbnailPath)) {
throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`);
}
} catch (error) {
console.error('Error extracting thumbnail:', error);
throw new Error(`提取缩略图失败: ${(error as Error).message}`);
}
}
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
try {
this.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(FFMPEG_CMD, params);
if (!existsSync(outputFile)) {
throw new Error('转换失败,输出文件不存在');
}
} catch (error) {
console.error('Error converting file:', error);
throw new Error(`文件转换失败: ${(error as Error).message}`);
}
}
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
try {
this.ensureDirExists(pcmPath);
await execFileAsync(FFMPEG_CMD, [
'-y',
'-i', filePath,
'-ar', '24000',
'-ac', '1',
'-f', 's16le',
pcmPath
]);
if (!existsSync(pcmPath)) {
throw new Error('转换PCM失败输出文件不存在');
}
return readFileSync(pcmPath);
} catch (error: any) {
throw new Error(`FFmpeg处理转换出错: ${error.message}`);
}
}
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
try {
// 并行执行获取文件信息和提取缩略图
const [fileInfo, duration] = await Promise.all([
this.getFileInfo(videoPath, thumbnailPath),
this.getVideoDuration(videoPath)
]);
const result: VideoInfo = {
width: videoInfo.width,
height: videoInfo.height,
time: videoInfo.duration,
format: videoInfo.format,
size: fileSize,
width: fileInfo.width,
height: fileInfo.height,
time: duration,
format: fileInfo.format,
size: fileInfo.size,
filePath: videoPath
};
return result;
} catch (error) {
// 降级处理:返回默认值
const fileType = await fileTypeFromFile(videoPath).catch(() => null);
const fileSize = statSync(videoPath).size;
throw error;
}
}
private static async getFileInfo(videoPath: string, thumbnailPath: string): Promise<{
format: string,
size: number,
width: number,
height: number
}> {
// 获取文件大小和类型
const [fileType, fileSize] = await Promise.all([
fileTypeFromFile(videoPath).catch(() => {
return null;
}),
Promise.resolve(statSync(videoPath).size)
]);
try {
await this.extractThumbnail(videoPath, thumbnailPath);
// 获取图片尺寸
const dimensions = await imageSizeFallBack(thumbnailPath);
return {
width: 100,
height: 100,
time: 60,
format: fileType?.ext ?? 'mp4',
size: fileSize,
filePath: videoPath
width: dimensions.width ?? 100,
height: dimensions.height ?? 100
};
} catch (error) {
return {
format: fileType?.ext ?? 'mp4',
size: fileSize,
width: 100,
height: 100
};
}
}
private static async getVideoDuration(videoPath: string): Promise<number> {
try {
// 使用FFprobe获取时长
const { stdout } = await execFileAsync(FFPROBE_CMD, [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
videoPath
]);
const duration = parseFloat(stdout.trim());
return isNaN(duration) ? 60 : duration;
} catch (error) {
return 60; // 默认时长
}
}
}

View File

@@ -182,28 +182,28 @@ export async function uriToLocalFile(dir: string, uri: string, filename: string
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.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.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 };
}
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: '' };
default:
return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' };
}
}

View File

@@ -9,50 +9,41 @@ export interface ResourceConfig<T extends any[], R> {
healthCheckInterval?: number;
/** 最大健康检查失败次数,超过后永久禁用,默认 5 次 */
maxHealthCheckFailures?: number;
/** 健康检查函数,如果提供则优先使用此函数进行健康检查 */
healthCheckFn?: (...args: T) => Promise<boolean>;
/** 资源名称(用于日志) */
name?: string;
/** 测试参数(用于健康检查) */
testArgs?: T;
/** 健康检查函数,如果提供则优先使用此函数进行健康检查 */
healthCheckFn?: (...args: T) => Promise<boolean>;
}
interface ResourceTypeState {
/** 资源配置 */
config: {
resourceFn: (...args: any[]) => Promise<any>;
healthCheckFn?: (...args: any[]) => Promise<boolean>;
disableTime: number;
maxRetries: number;
healthCheckInterval: number;
maxHealthCheckFailures: number;
testArgs?: any[];
};
/** 是否启用 */
interface ResourceState<T extends any[], R> {
config: ResourceConfig<T, R>;
isEnabled: boolean;
/** 禁用截止时间 */
disableUntil: number;
/** 当前重试次数 */
currentRetries: number;
/** 健康检查失败次数 */
healthCheckFailureCount: number;
/** 是否永久禁用 */
isPermanentlyDisabled: boolean;
/** 上次健康检查时间 */
lastError?: Error;
lastHealthCheckTime: number;
/** 成功次数统计 */
successCount: number;
/** 失败次数统计 */
failureCount: number;
registrationKey: string;
}
export class ResourceManager {
private resourceTypes = new Map<string, ResourceTypeState>();
private resources = new Map<string, ResourceState<any, any>>();
private destroyed = false;
private healthCheckTimer?: NodeJS.Timeout;
private readonly HEALTH_CHECK_TASK_INTERVAL = 5000; // 5秒执行一次健康检查任务
constructor() {
this.startHealthCheckTask();
}
/**
* 调用资源(自动注册或复用已有配置
* 注册资源(注册即调用,重复注册只实际注册一次
*/
async callResource<T extends any[], R>(
type: string,
async register<T extends any[], R>(
key: string,
config: ResourceConfig<T, R>,
...args: T
): Promise<R> {
@@ -60,64 +51,81 @@ export class ResourceManager {
throw new Error('ResourceManager has been destroyed');
}
// 获取或创建资源类型状态
let state = this.resourceTypes.get(type);
const registrationKey = this.generateRegistrationKey(key, config);
// 检查是否已经注册
if (this.resources.has(key)) {
const existingState = this.resources.get(key)!;
// 如果是相同的配置,直接调用
if (existingState.registrationKey === registrationKey) {
return this.callResource<T, R>(key, ...args);
}
// 配置不同,清理旧的并重新注册
this.unregister(key);
}
// 创建新的资源状态
const state: ResourceState<T, R> = {
config: {
disableTime: 30000,
maxRetries: 3,
healthCheckInterval: 60000,
maxHealthCheckFailures: 5,
name: key,
...config
},
isEnabled: true,
disableUntil: 0,
currentRetries: 0,
healthCheckFailureCount: 0,
isPermanentlyDisabled: false,
lastHealthCheckTime: 0,
registrationKey
};
this.resources.set(key, state);
// 注册即调用
return await this.callResource<T, R>(key, ...args);
}
/**
* 调用资源
*/
async callResource<T extends any[], R>(key: string, ...args: T): Promise<R> {
const state = this.resources.get(key) as ResourceState<T, R> | undefined;
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);
throw new Error(`Resource ${key} not registered`);
}
// 在调用前检查是否需要进行健康检查
await this.checkAndPerformHealthCheck(state);
// 检查资源状态
if (state.isPermanentlyDisabled) {
throw new Error(`Resource type '${type}' is permanently disabled (success: ${state.successCount}, failure: ${state.failureCount})`);
throw new Error(`Resource ${key} is permanently disabled due to repeated health check failures`);
}
if (!this.isResourceAvailable(type)) {
if (!this.isResourceAvailable(key)) {
const disableUntilDate = new Date(state.disableUntil).toISOString();
throw new Error(`Resource type '${type}' is currently disabled until ${disableUntilDate} (success: ${state.successCount}, failure: ${state.failureCount})`);
throw new Error(`Resource ${key} is currently disabled until ${disableUntilDate}`);
}
// 调用资源
try {
const result = await config.resourceFn(...args);
const result = await state.config.resourceFn(...args);
this.onResourceSuccess(state);
return result;
} catch (error) {
this.onResourceFailure(state);
this.onResourceFailure(state, error as Error);
throw error;
}
}
/**
* 检查资源类型是否可用
* 检查资源是否可用
*/
isResourceAvailable(type: string): boolean {
const state = this.resourceTypes.get(type);
isResourceAvailable(key: string): boolean {
const state = this.resources.get(key);
if (!state) {
return true; // 未注册的资源类型视为可用
return false;
}
if (state.isPermanentlyDisabled || !state.isEnabled) {
@@ -128,97 +136,128 @@ export class ResourceManager {
}
/**
* 获取资源类型统计信息
* 注销资源
*/
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,
};
unregister(key: string): boolean {
return this.resources.delete(key);
}
/**
* 获取所有资源类型统计
*/
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);
}
/**
* 销毁管理器
* 销毁管理器,清理所有资源
*/
destroy(): void {
if (this.destroyed) {
return;
}
this.resourceTypes.clear();
this.stopHealthCheckTask();
this.resources.clear();
this.destroyed = true;
}
/**
* 检查并执行健康检查(如果需要)
*/
private async checkAndPerformHealthCheck(state: ResourceTypeState): Promise<void> {
// 如果资源可用或已永久禁用,无需健康检查
if (state.isEnabled && Date.now() >= state.disableUntil) {
private generateRegistrationKey<T extends any[], R>(key: string, config: ResourceConfig<T, R>): string {
const configStr = JSON.stringify({
name: config.name,
disableTime: config.disableTime,
maxRetries: config.maxRetries,
healthCheckInterval: config.healthCheckInterval,
maxHealthCheckFailures: config.maxHealthCheckFailures,
functionStr: config.resourceFn.toString(),
healthCheckFnStr: config.healthCheckFn?.toString()
});
return `${key}_${this.simpleHash(configStr)}`;
}
private simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(36);
}
private onResourceSuccess<T extends any[], R>(state: ResourceState<T, R>): void {
state.currentRetries = 0;
state.disableUntil = 0;
state.healthCheckFailureCount = 0;
state.lastError = undefined;
}
private onResourceFailure<T extends any[], R>(state: ResourceState<T, R>, error: Error): void {
state.currentRetries++;
state.lastError = error;
// 如果重试次数达到上限,禁用资源
if (state.currentRetries >= state.config.maxRetries!) {
state.disableUntil = Date.now() + state.config.disableTime!;
state.currentRetries = 0;
}
}
private startHealthCheckTask(): void {
if (this.healthCheckTimer) {
return;
}
if (state.isPermanentlyDisabled) {
this.healthCheckTimer = setInterval(() => {
this.runHealthCheckTask();
}, this.HEALTH_CHECK_TASK_INTERVAL);
}
private stopHealthCheckTask(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = undefined;
}
}
private async runHealthCheckTask(): Promise<void> {
if (this.destroyed) {
return;
}
const now = Date.now();
// 检查是否还在禁用期内
if (now < state.disableUntil) {
return;
}
for (const [key, state] of this.resources) {
// 跳过永久禁用或可用的资源
if (state.isPermanentlyDisabled || this.isResourceAvailable(key)) {
continue;
}
// 检查是否需要进行健康检查(根据间隔时间)
if (now - state.lastHealthCheckTime < state.config.healthCheckInterval) {
return;
}
// 跳过还在禁用期内的资源
if (now < state.disableUntil) {
continue;
}
// 执行健康检查
await this.performHealthCheck(state);
// 检查是否需要进行健康检查(根据间隔时间)
const lastHealthCheck = state.lastHealthCheckTime || 0;
const healthCheckInterval = state.config.healthCheckInterval!;
if (now - lastHealthCheck < healthCheckInterval) {
continue;
}
// 执行健康检查
await this.performHealthCheck(state);
}
}
private async performHealthCheck(state: ResourceTypeState): Promise<void> {
private async performHealthCheck<T extends any[], R>(state: ResourceState<T, R>): Promise<void> {
state.lastHealthCheckTime = Date.now();
try {
let healthCheckResult: boolean;
// 如果有专门的健康检查函数,使用它
if (state.config.healthCheckFn) {
const testArgs = state.config.testArgs || [];
const testArgs = state.config.testArgs || [] as unknown as T;
healthCheckResult = await state.config.healthCheckFn(...testArgs);
} else {
const testArgs = state.config.testArgs || [];
// 否则使用原始函数进行检查
const testArgs = state.config.testArgs || [] as unknown as T;
await state.config.resourceFn(...testArgs);
healthCheckResult = true;
}
@@ -229,42 +268,26 @@ export class ResourceManager {
state.disableUntil = 0;
state.currentRetries = 0;
state.healthCheckFailureCount = 0;
state.lastError = undefined;
} else {
throw new Error('Health check function returned false');
}
} catch {
} catch (error) {
// 健康检查失败,增加失败计数
state.healthCheckFailureCount++;
state.lastError = error as Error;
// 检查是否达到最大健康检查失败次数
if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures) {
if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures!) {
// 永久禁用资源
state.isPermanentlyDisabled = true;
state.disableUntil = 0;
} else {
// 继续禁用一段时间
state.disableUntil = Date.now() + state.config.disableTime;
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;
}
}
}
// 创建全局实例
@@ -272,9 +295,34 @@ export const resourceManager = new ResourceManager();
// 便捷函数
export async function registerResource<T extends any[], R>(
type: string,
key: string,
config: ResourceConfig<T, R>,
...args: T
): Promise<R> {
return resourceManager.callResource(type, config, ...args);
}
return resourceManager.register(key, config, ...args);
}
// 使用示例:
/*
await registerResource(
'api-with-health-check',
{
resourceFn: async (id: string) => {
const response = await fetch(`https://api.example.com/data/${id}`);
return response.json();
},
healthCheckFn: async (id: string) => {
try {
const response = await fetch(`https://api.example.com/health`);
return response.ok;
} catch {
return false;
}
},
testArgs: ['health-check-id'],
healthCheckInterval: 30000,
maxHealthCheckFailures: 3
},
'user123'
);
*/

View File

@@ -163,10 +163,8 @@ export function getQQVersionConfigPath(exePath: string = ''): string | undefined
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;
const { crownNum, sunNum, moonNum, starNum } = level;
return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum;
}
export function stringifyWithBigInt(obj: any) {
@@ -206,4 +204,4 @@ export function parseAppidFromMajor(nodeMajor: string): string | undefined {
}
return undefined;
}
}

View File

@@ -108,13 +108,13 @@ export class PerformanceMonitor {
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
let logContent = '';
logContent += `=== 性能监控详细报告 ===\n`;
logContent += '=== 性能监控详细报告 ===\n';
logContent += `生成时间: ${now.toLocaleString()}\n`;
logContent += `统计周期: 60秒\n`;
logContent += '统计周期: 60秒\n';
logContent += `总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms\n\n`;
// 详细函数统计
logContent += `=== 所有函数详细统计 ===\n`;
logContent += '=== 所有函数详细统计 ===\n';
const allStats = this.getStats().sort((a, b) => b.totalTime - a.totalTime);
allStats.forEach((stat, index) => {
@@ -127,26 +127,26 @@ export class PerformanceMonitor {
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';
});
// 排行榜统计
logContent += `=== 总耗时排行榜 (Top 20) ===\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`;
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=== 平均耗时排行榜 (Top 20) ===\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=== 性能热点分析 ===\n`;
logContent += '\n=== 性能热点分析 ===\n';
// 找出最耗时的前10个函数
const hotSpots = this.getTopByTotalTime(10);
hotSpots.forEach((stat, index) => {
@@ -155,11 +155,11 @@ export class PerformanceMonitor {
logContent += ` 性能影响: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
logContent += ` 调用效率: ${efficiency.toFixed(4)} 调用/ms\n`;
logContent += ` 优化建议: ${stat.averageTime > 10 ? '考虑优化此函数的执行效率' :
stat.callCount > 1000 ? '考虑减少此函数的调用频率' :
'性能表现良好'}\n\n`;
stat.callCount > 1000 ? '考虑减少此函数的调用频率' :
'性能表现良好'}\n\n`;
});
logContent += `=== 报告结束 ===\n`;
logContent += '=== 报告结束 ===\n';
// 写入文件
fs.writeFileSync(logPath, logContent, 'utf8');

View File

@@ -39,7 +39,7 @@ export class QQBasicInfoWrapper {
//基础函数
getQQBuildStr() {
return this.QQVersionConfig?.curVersion.split('-')[1] ?? this.QQPackageInfo?.buildVersion;
return this.isQuickUpdate ? this.QQVersionConfig?.buildId : this.QQPackageInfo?.buildVersion;
}
getFullQQVersion() {

View File

@@ -1 +1 @@
export const napCatVersion = '4.9.4';
export const napCatVersion = '4.8.116';

View File

@@ -9,7 +9,7 @@ export async function runTask<T, R>(workerScript: string, taskData: T): Promise<
console.error('Worker Log--->:', (result as { log: string }).log);
}
if ((result as any)?.error) {
reject(new Error("Worker error: " + (result as { error: string }).error));
reject(new Error('Worker error: ' + (result as { error: string }).error));
}
resolve(result);
});

View File

@@ -45,7 +45,7 @@ export class NTQQFileApi {
'http://ss.xingzhige.com/music_card/rkey',
'https://secret-service.bietiaop.com/rkeys',
],
this.context.logger
this.context.logger
);
}
@@ -64,7 +64,7 @@ export class NTQQFileApi {
}
}
async getFileUrl(chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined,timeout: number = 5000) {
async getFileUrl(chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined,timeout: number = 20000) {
if (this.core.apis.PacketApi.packetStatus) {
try {
if (chatType === ChatType.KCHATTYPEGROUP && fileUUID) {
@@ -79,7 +79,7 @@ export class NTQQFileApi {
throw new Error('fileUUID or file10MMd5 is undefined');
}
async getPttUrl(peer: string, fileUUID?: string,timeout: number = 5000) {
async getPttUrl(peer: string, fileUUID?: string,timeout: number = 20000) {
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
let appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
@@ -107,7 +107,7 @@ export class NTQQFileApi {
throw new Error('packet cant get ptt url');
}
async getVideoUrlPacket(peer: string, fileUUID?: string,timeout: number = 5000) {
async getVideoUrlPacket(peer: string, fileUUID?: string,timeout: number = 20000) {
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
let appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
@@ -378,18 +378,18 @@ export class NTQQFileApi {
element.elementType === ElementType.FILE
) {
switch (element.elementType) {
case ElementType.PIC:
case ElementType.PIC:
element.picElement!.sourcePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.VIDEO:
break;
case ElementType.VIDEO:
element.videoElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.PTT:
break;
case ElementType.PTT:
element.pttElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.FILE:
break;
case ElementType.FILE:
element.fileElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
break;
}
elementIndex++;
}

View File

@@ -110,7 +110,7 @@ export class NTQQFriendApi {
time: item.reqTime, // 信息字段
type: 'doubt' //保留字段
};
}))
}));
return requests;
}
}

View File

@@ -49,6 +49,7 @@ export class NTQQGroupApi {
async initApi() {
this.initCache().then().catch(e => this.context.logger.logError(e));
}
async createGrayTip(groupCode: string, tip: string) {
return this.context.session.getMsgService().addLocalJsonGrayTipMsg(
{
@@ -57,13 +58,13 @@ export class NTQQGroupApi {
} as Peer,
{
busiId: 2201,
jsonStr: JSON.stringify({ "align": "center", "items": [{ "txt": tip, "type": "nor" }] }),
jsonStr: JSON.stringify({ 'align': 'center', 'items': [{ 'txt': tip, 'type': 'nor' }] }),
recentAbstract: tip,
isServer: false
},
true,
true
)
);
}
async initCache() {
for (const group of await this.getGroups(true)) {
@@ -246,6 +247,12 @@ export class NTQQGroupApi {
return member;
}
async getGroupMember(groupCode: string | number, memberUinOrUid: string | number) {
// 添加参数验证,防止 undefined/null 导致的崩溃
if (groupCode === undefined || groupCode === null || memberUinOrUid === undefined || memberUinOrUid === null) {
this.context.logger.logError('getGroupMember: 无效的参数', { groupCode, memberUinOrUid });
return undefined;
}
const groupCodeStr = groupCode.toString();
const memberUinOrUidStr = memberUinOrUid.toString();

View File

@@ -1,5 +1,5 @@
import * as os from 'os';
import offset from '@/core/external/napi2native.json';
import offset from '@/core/external/offset.json';
import { InstanceContext, NapCatCore } from '@/core';
import { LogWrapper } from '@/common/log';
import { PacketClientSession } from '@/core/packet/clientSession';

View File

@@ -330,9 +330,9 @@ export class NTQQWebApi {
attach_info: '',
seq: 3331,
request_time_line: {
request_invoke_time: "0"
request_invoke_time: '0'
}
})
});
}
async getAlbumList(gc: string) {
const skey = await this.core.apis.UserApi.getSKey() || '';
@@ -340,7 +340,7 @@ export class NTQQWebApi {
const bkn = this.getBknFromSKey(skey);
const uin = this.core.selfInfo.uin || '10001';
const cookies = `p_uin=o${this.core.selfInfo.uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin} `;
const api = `https://h5.qzone.qq.com/proxy/domain/u.photo.qzone.qq.com/cgi-bin/upp/qun_list_album_v2?`;
const api = 'https://h5.qzone.qq.com/proxy/domain/u.photo.qzone.qq.com/cgi-bin/upp/qun_list_album_v2?';
const params = new URLSearchParams({
random: '7570',
g_tk: bkn,
@@ -450,7 +450,7 @@ export class NTQQWebApi {
attach_info: attach_info,
seq: 0,
request_time_line: {
request_invoke_time: "0"
request_invoke_time: '0'
},
album_id: albumId,
lloc: '',
@@ -466,13 +466,13 @@ export class NTQQWebApi {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
const uin = this.core.selfInfo.uin || '10001';
//16位number数字
const client_key = Date.now() * 1000
const client_key = Date.now() * 1000;
return await this.context.session.getAlbumService().doQunComment(
random_seq, {
map_info: [],
map_bytes_info: [],
map_user_account: []
},
map_info: [],
map_bytes_info: [],
map_user_account: []
},
qunId,
2,
createAlbumMediaFeed(uin, albumId, lloc),
@@ -503,14 +503,14 @@ export class NTQQWebApi {
const uin = this.core.selfInfo.uin || '10001';
return await this.context.session.getAlbumService().doQunLike(
random_seq, {
map_info: [],
map_bytes_info: [],
map_user_account: []
}, {
id: id,
status: 1
},
map_info: [],
map_bytes_info: [],
map_user_account: []
}, {
id: id,
status: 1
},
createAlbumFeedPublish(qunId, uin, albumId, lloc)
)
);
}
}

View File

@@ -27,14 +27,14 @@ export function createAlbumListRequest(
): AlbumListRequest {
return {
qun_id: qunId,
attach_info: "",
attach_info: '',
seq: seq,
request_time_line: {
request_invoke_time: "0"
request_invoke_time: '0'
},
album_id: albumId,
lloc: "",
batch_id: ""
lloc: '',
batch_id: ''
};
}
@@ -75,7 +75,7 @@ export function createAlbumMediaFeed(
): AlbumMediaFeed {
return {
cell_common: {
time: ""
time: ''
},
cell_user_info: {
user: {
@@ -84,7 +84,7 @@ export function createAlbumMediaFeed(
},
cell_media: {
album_id: albumId,
batch_id: "",
batch_id: '',
media_items: [{
image: {
lloc: lloc
@@ -141,9 +141,9 @@ export function createAlbumCommentRequest(
type: RichMsgType.KRICHMSGTYPEPLAINTEXT,
content: content,
who: 0,
uid: "",
name: "",
url: ""
uid: '',
name: '',
url: ''
}],
user: {
uin: uin
@@ -196,7 +196,7 @@ export function createAlbumFeedPublish(
return {
cell_common: {
time: Date.now(),
feed_id: ""
feed_id: ''
},
cell_user_info: {
user: {

View File

@@ -1,4 +1,4 @@
import { GroupDetailInfoV2Param, GroupExtInfo, GroupExtFilter } from "../types";
import { GroupDetailInfoV2Param, GroupExtInfo, GroupExtFilter } from '../types';
export function createGroupDetailInfoV2Param(group_code: string): GroupDetailInfoV2Param {
return {
@@ -51,7 +51,7 @@ export function createGroupDetailInfoV2Param(group_code: string): GroupDetailInf
}, groupSecLevel: 0,
groupSecLevelInfo: 0,
subscriptionUin: 0,
subscriptionUid: "",
subscriptionUid: '',
allowMemberInvite: 0,
groupQuestion: 0,
groupAnswer: 0,
@@ -81,34 +81,34 @@ export function createGroupDetailInfoV2Param(group_code: string): GroupDetailInf
modifyInfo: {
noCodeFingerOpenFlag: 0,
noFingerOpenFlag: 0,
groupName: "",
groupName: '',
classExt: 0,
classText: "",
fingerMemo: "",
richFingerMemo: "",
classText: '',
fingerMemo: '',
richFingerMemo: '',
tagRecord: [],
groupGeoInfo: {
ownerUid: "",
ownerUid: '',
SetTime: 0,
CityId: 0,
Longitude: "",
Latitude: "",
GeoContent: "",
poiId: ""
Longitude: '',
Latitude: '',
GeoContent: '',
poiId: ''
},
groupExtAdminNum: 0,
flag: 0,
groupMemo: "",
groupAioSkinUrl: "",
groupBoardSkinUrl: "",
groupCoverSkinUrl: "",
groupMemo: '',
groupAioSkinUrl: '',
groupBoardSkinUrl: '',
groupCoverSkinUrl: '',
groupGrade: 0,
activeMemberNum: 0,
certificationType: 0,
certificationText: "",
certificationText: '',
groupNewGuideLines: {
enabled: false,
content: ""
content: ''
}, groupFace: 0,
addOption: 0,
shutUpTime: 0,
@@ -121,15 +121,15 @@ export function createGroupDetailInfoV2Param(group_code: string): GroupDetailInf
},
groupSecLevel: 0,
groupSecLevelInfo: 0,
subscriptionUin: "",
subscriptionUid: "",
subscriptionUin: '',
subscriptionUid: '',
allowMemberInvite: 0,
groupQuestion: "",
groupAnswer: "",
groupQuestion: '',
groupAnswer: '',
groupFlagExt3: 0,
groupFlagExt3Mask: 0,
groupOpenAppid: 0,
rootId: "",
rootId: '',
msgLimitFrequency: 0,
hlGuildAppid: 0,
hlGuildSubType: 0,
@@ -137,20 +137,20 @@ export function createGroupDetailInfoV2Param(group_code: string): GroupDetailInf
groupFlagExt4: 0,
groupFlagExt4Mask: 0,
groupSchoolInfo: {
location: "",
location: '',
grade: 0,
school: ""
school: ''
},
groupCardPrefix:
{
introduction: "",
introduction: '',
rptPrefix: []
},
allianceId: "",
allianceId: '',
groupFlagPro1: 0,
groupFlagPro1Mask: 0
}
}
};
}
export function createGroupExtInfo(group_code: string): GroupExtInfo {
return {
@@ -205,7 +205,7 @@ export function createGroupExtInfo(group_code: string): GroupExtInfo {
inviteRobotMemberExamine: 0,
groupSquareSwitch: 0,
}
}
};
}
export function createGroupExtFilter(): GroupExtFilter {
return {
@@ -241,5 +241,5 @@ export function createGroupExtFilter(): GroupExtFilter {
inviteRobotMemberSwitch: 0,
inviteRobotMemberExamine: 0,
groupSquareSwitch: 0,
}
};
};

View File

@@ -1 +1 @@
export * from "./group";
export * from './group';

View File

@@ -70,9 +70,9 @@ export function qunAlbumControl({
img_name,
sAlbumName,
sAlbumID,
photo_num = "1",
video_num = "0",
batch_num = "1"
photo_num = '1',
video_num = '0',
batch_num = '1'
}: {
uin: string,
group_id: string,
@@ -100,18 +100,18 @@ export function qunAlbumControl({
data: pskey,
appid: 5
},
appid: "qun",
appid: 'qun',
checksum: pic_md5,
check_type: 0,
file_len: img_size,
env: {
refer: "qzone",
deviceInfo: "h5"
refer: 'qzone',
deviceInfo: 'h5'
},
model: 0,
biz_req: {
sPicTitle: img_name,
sPicDesc: "",
sPicDesc: '',
sAlbumName: sAlbumName,
sAlbumID: sAlbumID,
iAlbumTypeID: 0,
@@ -119,7 +119,7 @@ export function qunAlbumControl({
iUploadType: 0,
iUpPicType: 0,
iBatchID: timestamp,
sPicPath: "",
sPicPath: '',
iPicWidth: 0,
iPicHight: 0,
iWaterType: 0,
@@ -127,7 +127,7 @@ export function qunAlbumControl({
iNeedFeeds: 1,
iUploadTime: timestamp,
mapExt: {
appid: "qun",
appid: 'qun',
userid: group_id
},
stExtendInfo: {
@@ -138,11 +138,11 @@ export function qunAlbumControl({
}
}
},
session: "",
session: '',
asy_upload: 0,
cmd: "FileUpload"
cmd: 'FileUpload'
}]
}
};
}
export function createStreamUpload(
@@ -159,16 +159,16 @@ export function createStreamUpload(
) {
return {
uin: uin,
appid: "qun",
appid: 'qun',
session: session,
offset: offset,//分片起始位置
data: data,//base64编码数据
checksum: "",
checksum: '',
check_type: 0,
retry: 0,//重试次数
seq: seq,//分片序号
end: end,//分片结束位置 文件总大小
cmd: "FileUpload",
cmd: 'FileUpload',
slice_size: slice_size,//分片大小16KB 16384
biz_req: {
iUploadType: 3

View File

@@ -386,45 +386,5 @@
"9.9.21-39038": {
"appid": 537313906,
"qua": "V1_WIN_NQ_9.9.21_39038_GW_B"
},
"9.9.22-40362": {
"appid": 537314212,
"qua": "V1_WIN_NQ_9.9.22_40362_GW_B"
},
"3.2.20-40768": {
"appid": 537319840,
"qua": "V1_LNX_NQ_3.2.20_40768_GW_B"
},
"9.9.22-40768": {
"appid": 537319804,
"qua": "V1_WIN_NQ_9.9.22_40768_GW_B"
},
"6.9.82-40768": {
"appid": 537319829,
"qua": "V1_MAC_NQ_6.9.82_40768_GW_B"
},
"3.2.20-40824": {
"appid": 537319840,
"qua": "V1_LNX_NQ_3.2.20_40824_GW_B"
},
"9.9.22-40824": {
"appid": 537319804,
"qua": "V1_WIN_NQ_9.9.22_40824_GW_B"
},
"6.9.82-40824": {
"appid": 537319829,
"qua": "V1_MAC_NQ_6.9.82_40824_GW_B"
},
"6.9.82-40990": {
"appid": 537319880,
"qua": "V1_MAC_NQ_6.9.82_40990_GW_B"
},
"9.9.22-40990": {
"appid": 537319855,
"qua": "V1_WIN_NQ_9.9.22.40990_GW_B"
},
"3.2.20-40990": {
"appid": 537319891,
"qua": "V1_LNX_NQ_3.2.20_40990_GW_B"
}
}

View File

@@ -1,38 +0,0 @@
{
"9.9.22-40990-x64": {
"send": "1B5699C",
"recv": "1D8CA9D"
},
"9.9.22-40824-x64": {
"send": "1B5699C",
"recv": "1D8CA9D"
},
"9.9.22-40768-x64": {
"send": "1B5699C",
"recv": "1D8CA9D"
},
"3.2.20-40768-x64": {
"send": "2A1B840",
"recv": "2D28F20"
},
"3.2.20-40824-x64": {
"send": "2A1B840",
"recv": "2D28F20"
},
"3.2.20-40990-x64": {
"send": "2A1B840",
"recv": "2D28F20"
},
"3.2.20-40990-arm64": {
"send": "157C0E8",
"recv": "1546658"
},
"3.2.20-40824-arm64": {
"send": "157C0E8",
"recv": "1546658"
},
"3.2.20-40768-arm64": {
"send": "157C0E8",
"recv": "1546658"
}
}

View File

@@ -507,56 +507,8 @@
"send": "7B025C8",
"recv": "7B05F58"
},
"9.9.21-39038-x64": {
"9.9.21-39038-x64": {
"send": "313FB58",
"recv": "31432FC"
},
"9.9.22-40362-x64": {
"send": "31C0EB8",
"recv": "31C465C"
},
"3.2.20-40768-x64": {
"send": "B69CFE0",
"recv": "B6A0A60"
},
"9.9.22-40768-x64": {
"send": "31C1838",
"recv": "31C4FDC"
},
"3.2.20-40768-arm64": {
"send": "7D49B18",
"recv": "7D4D4A8"
},
"6.9.82-40768-arm64": {
"send": "202A198",
"recv": "202B718"
},
"9.9.22-40824-x64": {
"send": "31C1838",
"recv": "31C4FDC"
},
"3.2.20-40824-arm64": {
"send": "7D49B18",
"recv": "7D4D4A8"
},
"6.9.82-40824-arm64": {
"send": "202A198",
"recv": "202B718"
},
"3.2.20-40990-x64": {
"send": "B69CFE0",
"recv": "B6A0A60"
},
"3.2.20-40990-arm64": {
"send": "7D49B18",
"recv": "7D4D4A8"
},
"9.9.22-40990-x64": {
"send": "31C1838",
"recv": "31C4FDC"
},
"6.9.82-40990-arm64": {
"send": "202A198",
"recv": "202B718"
}
}

View File

@@ -30,7 +30,6 @@ import os from 'node:os';
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners';
import { proxiedListenerOf } from '@/common/proxy-handler';
import { NTQQPacketApi } from './apis/packet';
import { NativePacketHandler } from './packet/handler/client';
export * from './wrapper';
export * from './types';
export * from './services';
@@ -259,7 +258,6 @@ export interface InstanceContext {
readonly loginService: NodeIKernelLoginService;
readonly basicInfoWrapper: QQBasicInfoWrapper;
readonly pathWrapper: NapCatPathWrapper;
readonly packetHandler: NativePacketHandler;
}
export interface StableNTApiWrapper {

View File

@@ -0,0 +1,88 @@
import crypto, { createHash } from 'crypto';
import { OidbPacket, PacketHexStr } from '@/core/packet/transformer/base';
import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext';
export interface RecvPacket {
type: string, // 仅recv
data: RecvPacketData
}
export interface RecvPacketData {
seq: number
cmd: string
hex_data: string
}
function randText(len: number): string {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < len; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
export abstract class IPacketClient {
protected readonly napcore: NapCoreContext;
protected readonly logger: PacketLogger;
protected readonly cb = new Map<string, (json: RecvPacketData) => Promise<any> | any>(); // hash-type callback
logStack: LogStack;
available: boolean = false;
protected constructor(napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) {
this.napcore = napCore;
this.logger = logger;
this.logStack = logStack;
}
abstract check(): boolean;
abstract init(pid: number, recv: string, send: string): Promise<void>;
abstract sendCommandImpl(cmd: string, data: string, hash: string, timeout: number): void;
private async sendCommand(cmd: string, data: string, trace_data: string, rsp: boolean = false, timeout: number = 20000, sendcb: (json: RecvPacketData) => void = () => {
}): Promise<RecvPacketData> {
return new Promise<RecvPacketData>((resolve, reject) => {
if (!this.available) {
reject(new Error('packetBackend 当前不可用!'));
}
let hash = createHash('md5').update(trace_data).digest('hex');
const timeoutHandle = setTimeout(() => {
this.cb.delete(hash + 'send');
this.cb.delete(hash + 'recv');
reject(new Error(`sendCommand timed out after ${timeout} ms for ${cmd} with hash ${hash}`));
}, timeout);
this.cb.set(hash + 'send', async (json: RecvPacketData) => {
sendcb(json);
if (!rsp) {
clearTimeout(timeoutHandle);
resolve(json);
}
});
if (rsp) {
this.cb.set(hash + 'recv', async (json: RecvPacketData) => {
clearTimeout(timeoutHandle);
resolve(json);
});
}
this.sendCommandImpl(cmd, data, hash, timeout);
});
}
async sendPacket(cmd: string, data: PacketHexStr, rsp = false, timeout = 20000): Promise<RecvPacketData> {
const md5 = crypto.createHash('md5').update(data).digest('hex');
const trace_data = (randText(4) + md5 + data).slice(0, data.length / 2);// trace_data
return this.sendCommand(cmd, data, trace_data, rsp, timeout, async () => {
await this.napcore.sendSsoCmdReqByContend(cmd, trace_data);
});
}
async sendOidbPacket(pkt: OidbPacket, rsp = false, timeout = 20000): Promise<RecvPacketData> {
return this.sendPacket(pkt.cmd, pkt.data, rsp, timeout);
}
}

View File

@@ -1,40 +1,27 @@
import { createHash } from 'crypto';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { IPacketClient } from '@/core/packet/client/baseClient';
import { constants } from 'node:os';
import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext';
import { OidbPacket, PacketBuf } from '@/core/packet/transformer/base';
export interface RecvPacket {
type: string, // 仅recv
data: RecvPacketData
}
export interface RecvPacketData {
seq: number
cmd: string
data: Buffer
}
// 0 send 1 recv
export interface NativePacketExportType {
initHook?: (send: string, recv: string) => boolean;
InitHook?: (send: string, recv: string, callback: (type: number, uin: string, cmd: string, seq: number, hex_data: string) => void, o3_hook: boolean) => boolean;
SendPacket?: (cmd: string, data: string, trace_id: string) => void;
}
export class NativePacketClient {
protected readonly napcore: NapCoreContext;
protected readonly logger: PacketLogger;
protected readonly cb = new Map<string, (json: RecvPacketData) => Promise<any> | any>(); // hash-type callback
logStack: LogStack;
available: boolean = false;
export class NativePacketClient extends IPacketClient {
private readonly supportedPlatforms = ['win32.x64', 'linux.x64', 'linux.arm64', 'darwin.x64', 'darwin.arm64'];
private readonly MoeHooExport: { exports: NativePacketExportType } = { exports: {} };
private readonly sendEvent = new Map<number, string>(); // seq - hash
private readonly timeEvent = new Map<string, NodeJS.Timeout>(); // hash - timeout
constructor(napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) {
this.napcore = napCore;
this.logger = logger;
this.logStack = logStack;
super(napCore, logger, logStack);
}
check(): boolean {
@@ -43,7 +30,7 @@ export class NativePacketClient {
this.logStack.pushLogWarn(`NativePacketClient: 不支持的平台: ${platform}`);
return false;
}
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './native/napi2native/napi2native.' + platform + '.node');
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './moehoo/MoeHoo.' + platform + '.node');
if (!fs.existsSync(moehoo_path)) {
this.logStack.pushLogWarn(`NativePacketClient: 缺失运行时文件: ${moehoo_path}`);
return false;
@@ -53,55 +40,36 @@ export class NativePacketClient {
async init(_pid: number, recv: string, send: string): Promise<void> {
const platform = process.platform + '.' + process.arch;
const isNewQQ = this.napcore.basicInfo.requireMinNTQQBuild("40824");
if (isNewQQ) {
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './native/napi2native/napi2native.' + platform + '.node');
process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY);
this.MoeHooExport?.exports.initHook?.(send, recv);
this.available = true;
}
const isNewQQ = this.napcore.basicInfo.requireMinNTQQBuild('36580');
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './moehoo/MoeHoo.' + platform + (isNewQQ ? '.new' : '') + '.node');
process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY);
this.MoeHooExport.exports.InitHook?.(send, recv, (type: number, _uin: string, cmd: string, seq: number, hex_data: string) => {
const hash = createHash('md5').update(Buffer.from(hex_data, 'hex')).digest('hex');
if (type === 0 && this.cb.get(hash + 'recv')) {
//此时为send 提取seq
this.sendEvent.set(seq, hash);
setTimeout(() => {
this.sendEvent.delete(seq);
this.timeEvent.delete(hash);
}, +(this.timeEvent.get(hash) ?? 20000));
//正式send完成 无recv v
//均无异常 v
}
if (type === 1 && this.sendEvent.get(seq)) {
const hash = this.sendEvent.get(seq);
const callback = this.cb.get(hash + 'recv');
callback?.({ seq, cmd, hex_data });
}
}, this.napcore.config.o3HookMode == 1);
this.available = true;
}
async sendPacket(
cmd: string,
data: PacketBuf,
rsp = false,
timeout = 5000
): Promise<RecvPacketData> {
if (!rsp) {
this.napcore
.sendSsoCmdReqByContend(cmd, data)
.catch(err =>
this.logger.error(
`[PacketClient] sendPacket 无响应命令发送失败 cmd=${cmd} err=${err}`
)
);
return { seq: 0, cmd, data: Buffer.alloc(0) };
}
const sendPromise = this.napcore
.sendSsoCmdReqByContend(cmd, data)
.then(ret => ({
seq: 0,
cmd,
data: (ret as { rspbuffer: Buffer }).rspbuffer
}));
const timeoutPromise = new Promise<RecvPacketData>((_, reject) => {
setTimeout(
() =>
reject(
new Error(
`[PacketClient] sendPacket 超时 cmd=${cmd} timeout=${timeout}ms`
)
),
timeout
);
});
return Promise.race([sendPromise, timeoutPromise]);
}
async sendOidbPacket(pkt: OidbPacket, rsp = false, timeout = 5000): Promise<RecvPacketData> {
return await this.sendPacket(pkt.cmd, pkt.data, rsp, timeout);
sendCommandImpl(cmd: string, data: string, hash: string, timeout: number): void {
this.timeEvent.set(hash, setTimeout(() => {
this.timeEvent.delete(hash);//考虑情况为正式send都没进
}, timeout));
this.MoeHooExport.exports.SendPacket?.(cmd, data, hash);
this.cb.get(hash + 'send')?.({ seq: 0, cmd, hex_data: '' });
}
}

View File

@@ -1,8 +1,17 @@
import { IPacketClient } from '@/core/packet/client/baseClient';
import { NativePacketClient } from '@/core/packet/client/nativeClient';
import { OidbPacket } from '@/core/packet/transformer/base';
import { PacketLogger } from '@/core/packet/context/loggerContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
type clientPriorityType = {
[key: number]: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => IPacketClient;
}
const clientPriority: clientPriorityType = {
10: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new NativePacketClient(napCore, logger, logStack)
};
export class LogStack {
private stack: string[] = [];
private readonly logger: PacketLogger;
@@ -43,7 +52,7 @@ export class PacketClientContext {
private readonly napCore: NapCoreContext;
private readonly logger: PacketLogger;
private readonly logStack: LogStack;
private readonly _client: NativePacketClient;
private readonly _client: IPacketClient;
constructor(napCore: NapCoreContext, logger: PacketLogger) {
this.napCore = napCore;
@@ -66,15 +75,48 @@ export class PacketClientContext {
async sendOidbPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T, timeout?: number): Promise<T extends true ? Buffer : void> {
const raw = await this._client.sendOidbPacket(pkt, rsp, timeout);
return raw.data as T extends true ? Buffer : void;
return (rsp ? Buffer.from(raw.hex_data, 'hex') : undefined) as T extends true ? Buffer : void;
}
private newClient(): NativePacketClient {
this.logger.info('使用 NativePacketClient 作为后端');
const client = new NativePacketClient(this.napCore, this.logger, this.logStack);
if (!client.check()) {
throw new Error('[Core] [Packet] NativePacketClient 不可用NapCat.Packet将不会加载');
private newClient(): IPacketClient {
const prefer = this.napCore.config.packetBackend;
let client: IPacketClient | null;
switch (prefer) {
case 'native':
this.logger.info('使用指定的 NativePacketClient 作为后端');
client = new NativePacketClient(this.napCore, this.logger, this.logStack);
break;
case 'auto':
case undefined:
client = this.judgeClient();
break;
default:
this.logger.error(`未知的PacketBackend ${prefer},请检查配置文件!`);
client = null;
}
if (!client?.check()) {
throw new Error('[Core] [Packet] 无可用的后端NapCat.Packet将不会加载');
}
if (!client) {
throw new Error('[Core] [Packet] 后端异常NapCat.Packet将不会加载');
}
return client;
}
private judgeClient(): IPacketClient {
const sortedClients = Object.entries(clientPriority)
.map(([priority, clientFactory]) => {
const client = clientFactory(this.napCore, this.logger, this.logStack);
const score = +priority * +client.check();
return { client, score };
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score);
const selectedClient = sortedClients[0]?.client;
if (!selectedClient) {
throw new Error('[Core] [Packet] 无可用的后端NapCat.Packet将不会加载');
}
this.logger.info(`自动选择 ${selectedClient.constructor.name} 作为后端`);
return selectedClient;
}
}

View File

@@ -34,5 +34,5 @@ export class NapCoreContext {
return this.core.configLoader.configData;
}
sendSsoCmdReqByContend = (cmd: string, data: Buffer) => this.core.context.session.getMsgService().sendSsoCmdReqByContend(cmd, data);
sendSsoCmdReqByContend = (cmd: string, trace_id: string) => this.core.context.session.getMsgService().sendSsoCmdReqByContend(cmd, trace_id);
}

View File

@@ -122,28 +122,28 @@ export class PacketOperationContext {
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetPttUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
async GetPttUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>,timeout: number = 20000) {
const req = trans.DownloadPtt.build(selfUid, node);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadPtt.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetVideoUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
async GetVideoUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>, timeout: number = 20000) {
const req = trans.DownloadVideo.build(selfUid, node);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadVideo.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetGroupImageUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
async GetGroupImageUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout: number = 20000) {
const req = trans.DownloadGroupImage.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadImage.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout: number = 20000) {
const req = trans.DownloadGroupPtt.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadImage.parse(resp);
@@ -183,9 +183,9 @@ export class PacketOperationContext {
const ps = msg.map((m) => {
return m.msg.map(async (e) => {
if (e instanceof PacketMsgReplyElement && !e.targetElems) {
this.context.logger.debug(`Cannot find reply element's targetElems, prepare to fetch it...`);
this.context.logger.debug('Cannot find reply element\'s targetElems, prepare to fetch it...');
if (!e.targetPeer?.peerUid) {
this.context.logger.error(`targetPeer is undefined!`);
this.context.logger.error('targetPeer is undefined!');
}
let targetMsg: NapProtoEncodeStructType<typeof PushMsgBody>[] | undefined;
if (e.isGroupReply) {
@@ -198,7 +198,7 @@ export class PacketOperationContext {
}
});
}).flat();
await Promise.all(ps)
await Promise.all(ps);
await this.UploadResources(msg, groupUin);
}
@@ -206,14 +206,14 @@ export class PacketOperationContext {
const req = trans.FetchGroupMessage.build(groupUin, startSeq, endSeq);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.FetchGroupMessage.parse(resp);
return res.body.messages
return res.body.messages;
}
async FetchC2CMessage(targetUid: string, startSeq: number, endSeq: number): Promise<NapProtoDecodeStructType<typeof PushMsgBody>[]> {
const req = trans.FetchC2CMessage.build(targetUid, startSeq, endSeq);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.FetchC2CMessage.parse(resp);
return res.messages
return res.messages;
}
async UploadForwardMsg(msg: PacketMsg[], groupUin: number = 0) {
@@ -243,14 +243,14 @@ export class PacketOperationContext {
return res.rename.retCode;
}
async GetGroupFileUrl(groupUin: number, fileUUID: string, timeout?: number) {
async GetGroupFileUrl(groupUin: number, fileUUID: string,timeout: number = 20000) {
const req = trans.DownloadGroupFile.build(groupUin, fileUUID);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadGroupFile.parse(resp);
return `https://${res.download.downloadDns}/ftn_handler/${Buffer.from(res.download.downloadUrl).toString('hex')}/?fname=`;
}
async GetPrivateFileUrl(self_id: string, fileUUID: string, md5: string, timeout?: number) {
async GetPrivateFileUrl(self_id: string, fileUUID: string, md5: string, timeout: number = 20000) {
const req = trans.DownloadPrivateFile.build(self_id, fileUUID, md5);
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
const res = trans.DownloadPrivateFile.parse(resp);

View File

@@ -1,214 +0,0 @@
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { constants } from 'node:os';
import { LogWrapper } from '@/common/log';
import offset from '@/core/external/packet.json';
interface OffsetType {
[key: string]: {
recv: string;
send: string;
};
}
const typedOffset: OffsetType = offset;
// 0 send 1 recv
export interface NativePacketExportType {
initHook?: (send: string, recv: string, callback: (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string) => void, o3_hook: boolean) => boolean;
}
export type PacketType = 0 | 1; // 0: send, 1: recv
export type PacketCallback = (data: { type: PacketType, uin: string, cmd: string, seq: number, hex_data: string }) => void;
interface ListenerEntry {
callback: PacketCallback;
once: boolean;
}
export class NativePacketHandler {
private readonly supportedPlatforms = ['win32.x64', 'linux.x64', 'linux.arm64', 'darwin.x64', 'darwin.arm64'];
private readonly MoeHooExport: { exports: NativePacketExportType } = { exports: {} };
protected readonly logger: LogWrapper;
// 统一的监听器存储 - key: 'all' | 'type:0' | 'type:1' | 'cmd:xxx' | 'exact:type:cmd'
private readonly listeners: Map<string, Set<ListenerEntry>> = new Map();
constructor({ logger }: { logger: LogWrapper }) {
this.logger = logger;
}
/**
* 添加监听器的通用方法
*/
private addListener(key: string, callback: PacketCallback, once: boolean = false): () => void {
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
const entry: ListenerEntry = { callback, once };
this.listeners.get(key)!.add(entry);
return () => this.removeListener(key, callback);
}
/**
* 移除监听器的通用方法
*/
private removeListener(key: string, callback: PacketCallback): boolean {
const entries = this.listeners.get(key);
if (!entries) return false;
for (const entry of entries) {
if (entry.callback === callback) {
return entries.delete(entry);
}
}
return false;
}
// ===== 永久监听器 =====
/** 监听所有数据包 */
onAll(callback: PacketCallback): () => void {
return this.addListener('all', callback);
}
/** 监听特定类型的数据包 (0: send, 1: recv) */
onType(type: PacketType, callback: PacketCallback): () => void {
return this.addListener(`type:${type}`, callback);
}
/** 监听所有发送的数据包 */
onSend(callback: PacketCallback): () => void {
return this.onType(0, callback);
}
/** 监听所有接收的数据包 */
onRecv(callback: PacketCallback): () => void {
return this.onType(1, callback);
}
/** 监听特定cmd的数据包(不限type) */
onCmd(cmd: string, callback: PacketCallback): () => void {
return this.addListener(`cmd:${cmd}`, callback);
}
/** 监听特定type和cmd的数据包(精确匹配) */
onExact(type: PacketType, cmd: string, callback: PacketCallback): () => void {
return this.addListener(`exact:${type}:${cmd}`, callback);
}
// ===== 一次性监听器 =====
/** 一次性监听所有数据包 */
onceAll(callback: PacketCallback): () => void {
return this.addListener('all', callback, true);
}
/** 一次性监听特定类型的数据包 */
onceType(type: PacketType, callback: PacketCallback): () => void {
return this.addListener(`type:${type}`, callback, true);
}
/** 一次性监听所有发送的数据包 */
onceSend(callback: PacketCallback): () => void {
return this.onceType(0, callback);
}
/** 一次性监听所有接收的数据包 */
onceRecv(callback: PacketCallback): () => void {
return this.onceType(1, callback);
}
/** 一次性监听特定cmd的数据包 */
onceCmd(cmd: string, callback: PacketCallback): () => void {
return this.addListener(`cmd:${cmd}`, callback, true);
}
/** 一次性监听特定type和cmd的数据包 */
onceExact(type: PacketType, cmd: string, callback: PacketCallback): () => void {
return this.addListener(`exact:${type}:${cmd}`, callback, true);
}
// ===== 移除监听器 =====
/** 移除特定的全局监听器 */
off(key: string, callback: PacketCallback): boolean {
return this.removeListener(key, callback);
}
/** 移除特定key下的所有监听器 */
offAll(key: string): void {
this.listeners.delete(key);
}
/** 移除所有监听器 */
removeAllListeners(): void {
this.listeners.clear();
}
/**
* 触发监听器 - 按优先级触发: 精确匹配 > cmd匹配 > type匹配 > 全局
*/
private emitPacket(type: PacketType, uin: string, cmd: string, seq: number, hex_data: string): void {
const keys = [
`exact:${type}:${cmd}`, // 精确匹配
`cmd:${cmd}`, // cmd匹配
`type:${type}`, // type匹配
'all' // 全局
];
for (const key of keys) {
const entries = this.listeners.get(key);
if (!entries) continue;
const toRemove: ListenerEntry[] = [];
for (const entry of entries) {
try {
entry.callback({ type, uin, cmd, seq, hex_data });
if (entry.once) {
toRemove.push(entry);
}
} catch (error) {
this.logger.logError('监听器回调执行出错:', error);
}
}
// 移除一次性监听器
for (const entry of toRemove) {
entries.delete(entry);
}
}
}
async init(version: string): Promise<boolean> {
const version_arch = version + '-' + process.arch;
try {
const send = typedOffset[version_arch]?.send;
const recv = typedOffset[version_arch]?.recv;
if (!send || !recv) {
this.logger.logWarn(`NativePacketClient: 未找到对应版本的偏移数据: ${version_arch}`);
return false;
}
const platform = process.platform + '.' + process.arch;
if (!this.supportedPlatforms.includes(platform)) {
this.logger.logWarn(`NativePacketClient: 不支持的平台: ${platform}`);
return false;
}
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './native/packet/MoeHoo.' + platform + '.node');
process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY);
if (!fs.existsSync(moehoo_path)) {
this.logger.logWarn(`NativePacketClient: 缺失运行时文件: ${moehoo_path}`);
return false;
}
this.MoeHooExport.exports.initHook?.(send, recv, (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string) => {
this.emitPacket(type, uin, cmd, seq, hex_data);
}, true);
return true;
}
catch (error) {
this.logger.logError('NativePacketClient 初始化出错:', error);
return false;
}
}
}

View File

@@ -1,6 +1,6 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
import { MiniAppReqParams } from '@/core/packet/entities/miniApp';
class GetMiniAppAdaptShareInfo extends PacketTransformer<typeof proto.MiniAppAdaptShareInfoResp> {
@@ -41,7 +41,7 @@ class GetMiniAppAdaptShareInfo extends PacketTransformer<typeof proto.MiniAppAda
});
return {
cmd: 'LightAppSvc.mini_app_share.AdaptShareInfo',
data: PacketBufBuilder(data)
data: PacketHexStrBuilder(data)
};
}

View File

@@ -1,15 +1,15 @@
import { NapProtoDecodeStructType } from '@napneko/nap-proto-core';
import { PacketMsgBuilder } from '@/core/packet/message/builder';
export type PacketBuf = Buffer & { readonly hexNya: unique symbol };
export type PacketHexStr = string & { readonly hexNya: unique symbol };
export const PacketBufBuilder = (str: Uint8Array): PacketBuf => {
return Buffer.from(str) as PacketBuf;
export const PacketHexStrBuilder = (str: Uint8Array): PacketHexStr => {
return Buffer.from(str).toString('hex') as PacketHexStr;
};
export interface OidbPacket {
cmd: string;
data: PacketBuf
data: PacketHexStr
}
export abstract class PacketTransformer<T> {

View File

@@ -1,6 +1,6 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
class FetchSessionKey extends PacketTransformer<typeof proto.HttpConn0x6ff_501Response> {
constructor() {
@@ -25,7 +25,7 @@ class FetchSessionKey extends PacketTransformer<typeof proto.HttpConn0x6ff_501Re
});
return {
cmd: 'HttpConn.0x6ff_501',
data: PacketBufBuilder(req)
data: PacketHexStrBuilder(req)
};
}

View File

@@ -1,6 +1,6 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
class DownloadForwardMsg extends PacketTransformer<typeof proto.RecvLongMsgResp> {
constructor() {
@@ -25,7 +25,7 @@ class DownloadForwardMsg extends PacketTransformer<typeof proto.RecvLongMsgResp>
});
return {
cmd: 'trpc.group.long_msg_interface.MsgService.SsoRecvLongMsg',
data: PacketBufBuilder(req)
data: PacketHexStrBuilder(req)
};
}

View File

@@ -1,6 +1,6 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
class FetchC2CMessage extends PacketTransformer<typeof proto.SsoGetC2cMsgResponse> {
constructor() {
@@ -15,7 +15,7 @@ class FetchC2CMessage extends PacketTransformer<typeof proto.SsoGetC2cMsgRespons
});
return {
cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetC2cMsg',
data: PacketBufBuilder(req)
data: PacketHexStrBuilder(req)
};
}

View File

@@ -1,6 +1,6 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
class FetchGroupMessage extends PacketTransformer<typeof proto.SsoGetGroupMsgResponse> {
constructor() {
@@ -18,7 +18,7 @@ class FetchGroupMessage extends PacketTransformer<typeof proto.SsoGetGroupMsgRes
});
return {
cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetGroupMsg',
data: PacketBufBuilder(req)
data: PacketHexStrBuilder(req)
};
}

View File

@@ -1,7 +1,7 @@
import zlib from 'node:zlib';
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
import { PacketMsg } from '@/core/packet/message/message';
class UploadForwardMsg extends PacketTransformer<typeof proto.SendLongMsgResp> {
@@ -39,7 +39,7 @@ class UploadForwardMsg extends PacketTransformer<typeof proto.SendLongMsgResp> {
);
return {
cmd: 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg',
data: PacketBufBuilder(req)
data: PacketHexStrBuilder(req)
};
}

View File

@@ -1,6 +1,6 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
class OidbBase extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase> {
constructor() {
@@ -16,7 +16,7 @@ class OidbBase extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase> {
});
return {
cmd: `OidbSvcTrpcTcp.0x${cmd.toString(16).toUpperCase()}_${subCmd}`,
data: PacketBufBuilder(data),
data: PacketHexStrBuilder(data),
};
}

View File

@@ -1,4 +1,4 @@
import { ProtoField, ScalarType } from "@napneko/nap-proto-core";
import { ProtoField, ScalarType } from '@napneko/nap-proto-core';
export const OidbSvcTrpcTcp0XF90_1 = {
groupUin: ProtoField(1, ScalarType.UINT32),

View File

@@ -1,4 +1,4 @@
import { AlbumCommentReplyContent, AlbumFeedLikePublish, AlbumListRequest, AlbumMediaFeed } from "../data/album";
import { AlbumCommentReplyContent, AlbumFeedLikePublish, AlbumListRequest, AlbumMediaFeed } from '../data/album';
export interface NodeIKernelAlbumService {

View File

@@ -585,7 +585,7 @@ export interface NodeIKernelMsgService {
prepareTempChat(args: unknown): unknown;
sendSsoCmdReqByContend(cmd: string, param: unknown): Promise<unknown>;
sendSsoCmdReqByContend(cmd: string, param: string): Promise<unknown>;
getTempChatInfo(ChatType: number, Uid: string): Promise<TmpChatInfoApi>;

View File

@@ -1,12 +1,12 @@
import { EventType } from "@/onebot/event/OneBotEvent";
import type { PluginModule } from "@/onebot/network/plugin-manger";
import { EventType } from '@/onebot/event/OneBotEvent';
import type { PluginModule } from '@/onebot/network/plugin-manger';
const plugin_init: PluginModule["plugin_init"] = async (_core, _obContext, _actions, _instance) => {
console.log(`[Plugin: example] 插件已初始化`);
}
const plugin_onmessage: PluginModule["plugin_onmessage"] = async (adapter, _core, _obCtx, event, actions, instance) => {
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
console.log('[Plugin: example] 插件已初始化');
};
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, actions, instance) => {
if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) {
await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config);
}
}
};
export { plugin_init, plugin_onmessage };

View File

@@ -9,8 +9,8 @@ import { NodeIKernelLoginService } from '@/core/services';
import { NodeIQQNTWrapperSession, WrapperNodeApi } from '@/core/wrapper';
import { InitWebUi, WebUiConfig, webUiRuntimePort } from '@/webui';
import { NapCatOneBot11Adapter } from '@/onebot';
import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg';
import { FFmpegService } from '@/common/ffmpeg';
import { NativePacketHandler } from '@/core/packet/handler/client';
//Framework ES入口文件
export async function getWebUiUrl() {
@@ -38,15 +38,15 @@ export async function NCoreInitFramework(
const logger = new LogWrapper(pathWrapper.logsPath);
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
nativePacketHandler.onAll((packet) => {
console.log('[Packet]', packet.uin, packet.cmd, packet.hex_data);
});
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
// 在 init 之后注册监听器
// 初始化 FFmpeg 服务
await FFmpegService.init(pathWrapper.binaryPath, logger);
if (!process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) {
downloadFFmpegIfNotExists(logger).then(({ path, reset }) => {
if (reset && path) {
FFmpegService.setFfmpegPath(path, logger);
}
}).catch(e => {
logger.logError('[Ffmpeg] Error:', e);
});
}
//直到登录成功后,执行下一步
// const selfInfo = {
// uid: 'u_FUSS0_x06S_9Tf4na_WpUg',
@@ -72,7 +72,7 @@ export async function NCoreInitFramework(
// 过早进入会导致addKernelMsgListener等Listener添加失败
// await sleep(2500);
// 初始化 NapCatFramework
const loaderObject = new NapCatFramework(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler);
const loaderObject = new NapCatFramework(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper, pathWrapper);
await loaderObject.core.initCore();
//启动WebUi
@@ -93,10 +93,8 @@ export class NapCatFramework {
selfInfo: SelfInfo,
basicInfoWrapper: QQBasicInfoWrapper,
pathWrapper: NapCatPathWrapper,
packetHandler: NativePacketHandler,
) {
this.context = {
packetHandler,
workingEnv: NapCatCoreWorkingEnv.Framework,
wrapper,
session,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -25,6 +25,6 @@ export class ClickInlineKeyboardButton extends OneBotAction<Payload, unknown> {
callback_data: payload.callback_data,
dmFlag: 0,
chatType: 2
})
});
}
}

View File

@@ -5,7 +5,7 @@ import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.String(),
album_id: Type.String(),
attach_info: Type.String({ default: "" }),
attach_info: Type.String({ default: '' }),
});
type Payload = Static<typeof SchemaData>;

View File

@@ -1,4 +1,4 @@
import { PacketBuf } from '@/core/packet/transformer/base';
import { PacketHexStr } from '@/core/packet/transformer/base';
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { ProtoBuf, ProtoBufBase, PBUint32, PBString } from 'napcat.protobuf';
@@ -36,11 +36,11 @@ export class GetUnidirectionalFriendList extends OneBotAction<void, Friend[]> {
uint64_uin: self_id,
uint64_top: 0,
uint32_req_num: 99,
bytes_cookies: ""
bytes_cookies: ''
};
const packed_data = await this.pack_data(JSON.stringify(req_json));
const data = Buffer.from(packed_data);
const rsq = { cmd: 'MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0', data: data as PacketBuf };
const data = Buffer.from(packed_data).toString('hex');
const rsq = { cmd: 'MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0', data: data as PacketHexStr };
const rsp_data = await this.core.apis.PacketApi.pkt.operation.sendPacket(rsq, true);
const block_json = ProtoBuf(class extends ProtoBufBase { data = PBString(4); }).decode(rsp_data);
const block_list: Block[] = JSON.parse(block_json.data).rpt_block_list;

View File

@@ -1,4 +1,4 @@
import { PacketBuf } from '@/core/packet/transformer/base';
import { PacketHexStr } from '@/core/packet/transformer/base';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
@@ -16,7 +16,7 @@ export class SendPacket extends GetPacketStatusDepends<Payload, string | undefin
override actionName = ActionName.SendPacket;
async _handle(payload: Payload) {
const rsp = typeof payload.rsp === 'boolean' ? payload.rsp : payload.rsp === 'true';
const data = await this.core.apis.PacketApi.pkt.operation.sendPacket({ cmd: payload.cmd, data: Buffer.from(payload.data, 'hex') as PacketBuf }, rsp);
const data = await this.core.apis.PacketApi.pkt.operation.sendPacket({ cmd: payload.cmd, data: payload.data as PacketHexStr }, rsp);
return typeof data === 'object' ? data.toString('hex') : undefined;
}
}

View File

@@ -1,6 +1,6 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { ChatType, Peer, ElementType } from '@/core/types';
import { ChatType, Peer } from '@/core/types';
import fs from 'fs';
import { uriToLocalFile } from '@/common/file';
import { SendMessageContext } from '@/onebot/api';
@@ -16,15 +16,11 @@ const SchemaData = Type.Object({
type Payload = Static<typeof SchemaData>;
interface UploadGroupFileResponse {
file_id: string | null;
}
export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, UploadGroupFileResponse> {
export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, null> {
override actionName = ActionName.GoCQHTTP_UploadGroupFile;
override payloadSchema = SchemaData;
async _handle(payload: Payload): Promise<UploadGroupFileResponse> {
async _handle(payload: Payload): Promise<null> {
let file = payload.file;
if (fs.existsSync(file)) {
file = `file://${file}`;
@@ -43,11 +39,7 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, Uploa
};
const sendFileEle = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id);
msgContext.deleteAfterSentFiles.push(downloadResult.path);
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles);
const fileElement = returnMsg.elements.find(ele => ele.elementType === ElementType.FILE);
return {
file_id: fileElement?.fileElement?.fileUuid || null
};
await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles);
return null;
}
}

View File

@@ -1,6 +1,6 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { ChatType, Peer, SendFileElement, ElementType } from '@/core/types';
import { ChatType, Peer, SendFileElement } from '@/core/types';
import fs from 'fs';
import { uriToLocalFile } from '@/common/file';
import { SendMessageContext } from '@/onebot/api';
@@ -15,11 +15,7 @@ const SchemaData = Type.Object({
type Payload = Static<typeof SchemaData>;
interface UploadPrivateFileResponse {
file_id: string | null;
}
export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, UploadPrivateFileResponse> {
export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, null> {
override actionName = ActionName.GOCQHTTP_UploadPrivateFile;
override payloadSchema = SchemaData;
@@ -35,7 +31,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, Upl
throw new Error('缺少参数 user_id');
}
async _handle(payload: Payload): Promise<UploadPrivateFileResponse> {
async _handle(payload: Payload): Promise<null> {
let file = payload.file;
if (fs.existsSync(file)) {
file = `file://${file}`;
@@ -53,11 +49,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, Upl
};
const sendFileEle: SendFileElement = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name);
msgContext.deleteAfterSentFiles.push(downloadResult.path);
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles);
const fileElement = returnMsg.elements.find(ele => ele.elementType === ElementType.FILE);
return {
file_id: fileElement?.fileElement?.fileUuid || null
};
await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles);
return null;
}
}

View File

@@ -4,10 +4,7 @@ import { MessageUnique } from '@/common/message-unique';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
message_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
msg_seq: Type.Optional(Type.String()),
msg_random: Type.Optional(Type.String()),
group_id: Type.Optional(Type.String()),
message_id: Type.Union([Type.Number(), Type.String()]),
});
type Payload = Static<typeof SchemaData>;
@@ -16,20 +13,6 @@ export default class DelEssenceMsg extends OneBotAction<Payload, unknown> {
override payloadSchema = SchemaData;
async _handle(payload: Payload): Promise<unknown> {
// 如果直接提供了 msg_seq, msg_random, group_id,优先使用
if (payload.msg_seq && payload.msg_random && payload.group_id) {
return await this.core.apis.GroupApi.removeGroupEssenceBySeq(
payload.group_id,
payload.msg_random,
payload.msg_seq,
);
}
// 如果没有 message_id,则必须提供 msg_seq, msg_random, group_id
if (!payload.message_id) {
throw new Error('必须提供 message_id 或者同时提供 msg_seq, msg_random, group_id');
}
const msg = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id);
if (!msg) {
const data = this.core.apis.GroupApi.essenceLRU.getValue(+payload.message_id);

View File

@@ -18,9 +18,6 @@ class GetGroupInfo extends OneBotAction<Payload, OB11Group> {
const group = (await this.core.apis.GroupApi.getGroups()).find(e => e.groupCode == payload.group_id.toString());
if (!group) {
const data = await this.core.apis.GroupApi.fetchGroupDetail(payload.group_id.toString());
if (data.ownerUid && data.ownerUin === '0') {
data.ownerUin = await this.core.apis.UserApi.getUinByUidV2(data.ownerUid);
}
return {
...data,
group_all_shut: data.shutUpAllTimestamp > 0 ? -1 : 0,

View File

@@ -132,8 +132,6 @@ import { SetGroupAlbumMediaLike } from './extends/SetGroupAlbumMediaLike';
import { DelGroupAlbumMedia } from './extends/DelGroupAlbumMedia';
import { CleanStreamTempFile } from './stream/CleanStreamTempFile';
import { DownloadFileStream } from './stream/DownloadFileStream';
import { DownloadFileRecordStream } from './stream/DownloadFileRecordStream';
import { DownloadFileImageStream } from './stream/DownloadFileImageStream';
import { TestDownloadStream } from './stream/TestStreamDownload';
import { UploadFileStream } from './stream/UploadFileStream';
@@ -142,8 +140,6 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
const actionHandlers = [
new CleanStreamTempFile(obContext, core),
new DownloadFileStream(obContext, core),
new DownloadFileRecordStream(obContext, core),
new DownloadFileImageStream(obContext, core),
new TestDownloadStream(obContext, core),
new UploadFileStream(obContext, core),
new DelGroupAlbumMedia(obContext, core),

View File

@@ -33,7 +33,7 @@ class GetMsg extends OneBotAction<Payload, OB11Message> {
// if (orimsg) {
// msg = orimsg;
// } else {
msg = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgIdWithPeer?.MsgId || payload.message_id.toString()])).msgList[0];
msg = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgIdWithPeer?.MsgId || payload.message_id.toString()])).msgList[0];
//}
if (!msg) throw Error('消息不存在');
const retMsg = await this.obContext.apis.MsgApi.parseMessage(msg, config.messagePostFormat);

View File

@@ -55,7 +55,7 @@ export async function createContext(core: NapCatCore, payload: OB11PostContext |
chatType: ChatType.KCHATTYPEGROUP,
peerUid: payload.group_id.toString(),
guildId: ''
}
};
}
throw new Error('无法获取用户信息');
}

View File

@@ -8,7 +8,7 @@ export class GetRkeyEx extends GetPacketStatusDepends<void, unknown> {
let rkeys = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
return rkeys.map(rkey => {
return {
type: rkey.type === 10 ? "private" : "group",
type: rkey.type === 10 ? 'private' : 'group',
rkey: rkey.rkey,
created_at: rkey.time,
ttl: rkey.ttl,

View File

@@ -30,7 +30,7 @@ export class GetRkeyServer extends GetPacketStatusDepends<void, { private_rkey?:
private_rkey: privateRkeyItem ? privateRkeyItem.rkey : undefined,
group_rkey: groupRkeyItem ? groupRkeyItem.rkey : undefined,
expired_time: this.expiryTime,
name: "NapCat 4"
name: 'NapCat 4'
};
return this.rkeyCache;

View File

@@ -14,8 +14,8 @@ export class SendPokeBase extends GetPacketStatusDepends<Payload, void> {
async _handle(payload: Payload) {
// 这里的 !! 可以传入空字符串 忽略这些数据有利用接口统一接口
const target_id = !!payload.target_id ? payload.target_id : payload.user_id;
const peer_id = !!payload.group_id ? payload.group_id : payload.user_id;
const target_id = payload.target_id ? payload.target_id : payload.user_id;
const peer_id = payload.group_id ? payload.group_id : payload.user_id;
const is_group = !!payload.group_id;
if (!target_id || !peer_id) {

View File

@@ -14,11 +14,9 @@ export const ActionName = {
CleanStreamTempFile: 'clean_stream_temp_file',
// 所有 Upload/Download Stream Api 应当 _stream 结尾
TestDownloadStream: 'test_download_stream',
TestDownloadStream: 'test_download_stream',
UploadFileStream: 'upload_file_stream',
DownloadFileStream: 'download_file_stream',
DownloadFileRecordStream: 'download_file_record_stream',
DownloadFileImageStream: 'download_file_image_stream',
DelGroupAlbumMedia: 'del_group_album_media',
SetGroupAlbumMediaLike: 'set_group_album_media_like',

View File

@@ -1,99 +0,0 @@
import { OneBotAction, OneBotRequestToolkit } from '@/onebot/action/OneBotAction';
import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs';
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
export interface ResolvedFileInfo {
downloadPath: string;
fileName: string;
fileSize: number;
}
export interface DownloadResult {
// 文件信息
file_name?: string;
file_size?: number;
chunk_size?: number;
// 分片数据
index?: number;
data?: string;
size?: number;
progress?: number;
base64_size?: number;
// 完成信息
total_chunks?: number;
total_bytes?: number;
message?: string;
data_type?: 'file_info' | 'file_chunk' | 'file_complete';
// 可选扩展字段
width?: number;
height?: number;
out_format?: string;
}
export abstract class BaseDownloadStream<PayloadType, ResultType> extends OneBotAction<PayloadType, StreamPacket<ResultType>> {
protected async resolveDownload(file?: string): Promise<ResolvedFileInfo> {
const target = file || '';
let downloadPath = '';
let fileName = '';
let fileSize = 0;
const contextMsgFile = FileNapCatOneBotUUID.decode(target);
if (contextMsgFile && contextMsgFile.msgId && contextMsgFile.elementId) {
const { peer, msgId, elementId } = contextMsgFile;
downloadPath = await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', '');
const rawMessage = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId]))?.msgList
.find(msg => msg.msgId === msgId);
const mixElement = rawMessage?.elements.find(e => e.elementId === elementId);
const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement;
if (!mixElementInner) throw new Error('element not found');
fileSize = parseInt(mixElementInner.fileSize?.toString() ?? '0');
fileName = mixElementInner.fileName ?? '';
return { downloadPath, fileName, fileSize };
}
const contextModelIdFile = FileNapCatOneBotUUID.decodeModelId(target);
if (contextModelIdFile && contextModelIdFile.modelId) {
const { peer, modelId } = contextModelIdFile;
downloadPath = await this.core.apis.FileApi.downloadFileForModelId(peer, modelId, '');
return { downloadPath, fileName, fileSize };
}
const searchResult = (await this.core.apis.FileApi.searchForFile([target]));
if (searchResult) {
downloadPath = await this.core.apis.FileApi.downloadFileById(searchResult.id, parseInt(searchResult.fileSize));
fileSize = parseInt(searchResult.fileSize);
fileName = searchResult.fileName;
return { downloadPath, fileName, fileSize };
}
throw new Error('file not found');
}
protected async streamFileChunks(req: OneBotRequestToolkit, streamPath: string, chunkSize: number, chunkDataType: string): Promise<{ totalChunks: number; totalBytes: number }>
{
const stats = await fs.promises.stat(streamPath);
const totalSize = stats.size;
const readStream = fs.createReadStream(streamPath, { highWaterMark: chunkSize });
let chunkIndex = 0;
let bytesRead = 0;
for await (const chunk of readStream) {
const base64Chunk = (chunk as Buffer).toString('base64');
bytesRead += (chunk as Buffer).length;
await req.send({
type: StreamStatus.Stream,
data_type: chunkDataType,
index: chunkIndex,
data: base64Chunk,
size: (chunk as Buffer).length,
progress: Math.round((bytesRead / totalSize) * 100),
base64_size: base64Chunk.length
} as unknown as StreamPacket<any>);
chunkIndex++;
}
return { totalChunks: chunkIndex, totalBytes: bytesRead };
}
}

View File

@@ -1,60 +0,0 @@
import { ActionName } from '@/onebot/action/router';
import { OneBotRequestToolkit } from '@/onebot/action/OneBotAction';
import { Static, Type } from '@sinclair/typebox';
import { NetworkAdapterConfig } from '@/onebot/config/config';
import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs';
import { imageSizeFallBack } from '@/image-size';
import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream';
const SchemaData = Type.Object({
file: Type.Optional(Type.String()),
file_id: Type.Optional(Type.String()),
chunk_size: Type.Optional(Type.Number({ default: 64 * 1024 })) // 默认64KB分块
});
type Payload = Static<typeof SchemaData>;
export class DownloadFileImageStream extends BaseDownloadStream<Payload, DownloadResult> {
override actionName = ActionName.DownloadFileImageStream;
override payloadSchema = SchemaData;
override useStream = true;
async _handle(payload: Payload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<StreamPacket<DownloadResult>> {
try {
payload.file ||= payload.file_id || '';
const chunkSize = payload.chunk_size || 64 * 1024;
const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file);
const stats = await fs.promises.stat(downloadPath);
const totalSize = fileSize || stats.size;
const { width, height } = await imageSizeFallBack(downloadPath);
// 发送文件信息(与 DownloadFileStream 对齐,但包含宽高)
await req.send({
type: StreamStatus.Stream,
data_type: 'file_info',
file_name: fileName,
file_size: totalSize,
chunk_size: chunkSize,
width,
height
});
const { totalChunks, totalBytes } = await this.streamFileChunks(req, downloadPath, chunkSize, 'file_chunk');
// 返回完成状态(与 DownloadFileStream 对齐)
return {
type: StreamStatus.Response,
data_type: 'file_complete',
total_chunks: totalChunks,
total_bytes: totalBytes,
message: 'Download completed'
};
} catch (error) {
throw new Error(`Download failed: ${(error as Error).message}`);
}
}
}

View File

@@ -1,96 +0,0 @@
import { ActionName } from '@/onebot/action/router';
import { OneBotRequestToolkit } from '@/onebot/action/OneBotAction';
import { Static, Type } from '@sinclair/typebox';
import { NetworkAdapterConfig } from '@/onebot/config/config';
import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs';
import { decode } from 'silk-wasm';
import { FFmpegService } from '@/common/ffmpeg';
import { BaseDownloadStream } from './BaseDownloadStream';
const out_format = ['mp3', 'amr', 'wma', 'm4a', 'spx', 'ogg', 'wav', 'flac'];
const SchemaData = Type.Object({
file: Type.Optional(Type.String()),
file_id: Type.Optional(Type.String()),
chunk_size: Type.Optional(Type.Number({ default: 64 * 1024 })), // 默认64KB分块
out_format: Type.Optional(Type.String())
});
type Payload = Static<typeof SchemaData>;
import { DownloadResult } from './BaseDownloadStream';
export class DownloadFileRecordStream extends BaseDownloadStream<Payload, DownloadResult> {
override actionName = ActionName.DownloadFileRecordStream;
override payloadSchema = SchemaData;
override useStream = true;
async _handle(payload: Payload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<StreamPacket<DownloadResult>> {
try {
payload.file ||= payload.file_id || '';
const chunkSize = payload.chunk_size || 64 * 1024;
const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file);
// 处理输出格式转换
let streamPath = downloadPath;
if (payload.out_format && typeof payload.out_format === 'string') {
if (!out_format.includes(payload.out_format)) {
throw new Error('转换失败 out_format 字段可能格式不正确');
}
const pcmFile = `${downloadPath}.pcm`;
const outputFile = `${downloadPath}.${payload.out_format}`;
try {
// 如果已存在目标文件则跳过转换
await fs.promises.access(outputFile);
streamPath = outputFile;
} catch {
// 尝试解码 silk 到 pcm 再用 ffmpeg 转换
await this.decodeFile(downloadPath, pcmFile);
await FFmpegService.convertFile(pcmFile, outputFile, payload.out_format);
streamPath = outputFile;
}
}
const stats = await fs.promises.stat(streamPath);
const totalSize = fileSize || stats.size;
await req.send({
type: StreamStatus.Stream,
data_type: 'file_info',
file_name: fileName,
file_size: totalSize,
chunk_size: chunkSize,
out_format: payload.out_format
});
const { totalChunks, totalBytes } = await this.streamFileChunks(req, streamPath, chunkSize, 'file_chunk');
return {
type: StreamStatus.Response,
data_type: 'file_complete',
total_chunks: totalChunks,
total_bytes: totalBytes,
message: 'Download completed'
};
} catch (error) {
throw new Error(`Download failed: ${(error as Error).message}`);
}
}
private async decodeFile(inputFile: string, outputFile: string): Promise<void> {
try {
const inputData = await fs.promises.readFile(inputFile);
const decodedData = await decode(inputData, 24000);
await fs.promises.writeFile(outputFile, Buffer.from(decodedData.data));
} catch (error) {
console.error('Error decoding file:', error);
throw error;
}
}
}

View File

@@ -1,10 +1,10 @@
import { ActionName } from '@/onebot/action/router';
import { OneBotRequestToolkit } from '@/onebot/action/OneBotAction';
import { OneBotAction, OneBotRequestToolkit } from '@/onebot/action/OneBotAction';
import { Static, Type } from '@sinclair/typebox';
import { NetworkAdapterConfig } from '@/onebot/config/config';
import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs';
import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream';
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
const SchemaData = Type.Object({
file: Type.Optional(Type.String()),
file_id: Type.Optional(Type.String()),
@@ -13,7 +13,28 @@ const SchemaData = Type.Object({
type Payload = Static<typeof SchemaData>;
export class DownloadFileStream extends BaseDownloadStream<Payload, DownloadResult> {
// 下载结果类型
interface DownloadResult {
// 文件信息
file_name?: string;
file_size?: number;
chunk_size?: number;
// 分片数据
index?: number;
data?: string;
size?: number;
progress?: number;
base64_size?: number;
// 完成信息
total_chunks?: number;
total_bytes?: number;
message?: string;
data_type?: 'file_info' | 'file_chunk' | 'file_complete';
}
export class DownloadFileStream extends OneBotAction<Payload, StreamPacket<DownloadResult>> {
override actionName = ActionName.DownloadFileStream;
override payloadSchema = SchemaData;
override useStream = true;
@@ -22,12 +43,50 @@ export class DownloadFileStream extends BaseDownloadStream<Payload, DownloadResu
try {
payload.file ||= payload.file_id || '';
const chunkSize = payload.chunk_size || 64 * 1024;
let downloadPath = '';
let fileName = '';
let fileSize = 0;
const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file);
//接收消息标记模式
const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file);
if (contextMsgFile && contextMsgFile.msgId && contextMsgFile.elementId) {
const { peer, msgId, elementId } = contextMsgFile;
downloadPath = await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', '');
const rawMessage = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId]))?.msgList
.find(msg => msg.msgId === msgId);
const mixElement = rawMessage?.elements.find(e => e.elementId === elementId);
const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement;
if (!mixElementInner) throw new Error('element not found');
fileSize = parseInt(mixElementInner.fileSize?.toString() ?? '0');
fileName = mixElementInner.fileName ?? '';
}
//群文件模式
else if (FileNapCatOneBotUUID.decodeModelId(payload.file)) {
const contextModelIdFile = FileNapCatOneBotUUID.decodeModelId(payload.file);
if (contextModelIdFile && contextModelIdFile.modelId) {
const { peer, modelId } = contextModelIdFile;
downloadPath = await this.core.apis.FileApi.downloadFileForModelId(peer, modelId, '');
}
}
//搜索名字模式
else {
const searchResult = (await this.core.apis.FileApi.searchForFile([payload.file]));
if (searchResult) {
downloadPath = await this.core.apis.FileApi.downloadFileById(searchResult.id, parseInt(searchResult.fileSize));
fileSize = parseInt(searchResult.fileSize);
fileName = searchResult.fileName;
}
}
if (!downloadPath) {
throw new Error('file not found');
}
// 获取文件大小
const stats = await fs.promises.stat(downloadPath);
const totalSize = fileSize || stats.size;
// 发送文件信息
await req.send({
type: StreamStatus.Stream,
data_type: 'file_info',
@@ -36,13 +95,34 @@ export class DownloadFileStream extends BaseDownloadStream<Payload, DownloadResu
chunk_size: chunkSize
});
const { totalChunks, totalBytes } = await this.streamFileChunks(req, downloadPath, chunkSize, 'file_chunk');
// 创建读取流并分块发送
const readStream = fs.createReadStream(downloadPath, { highWaterMark: chunkSize });
let chunkIndex = 0;
let bytesRead = 0;
for await (const chunk of readStream) {
const base64Chunk = chunk.toString('base64');
bytesRead += chunk.length;
await req.send({
type: StreamStatus.Stream,
data_type: 'file_chunk',
index: chunkIndex,
data: base64Chunk,
size: chunk.length,
progress: Math.round((bytesRead / totalSize) * 100),
base64_size: base64Chunk.length
});
chunkIndex++;
}
// 返回完成状态
return {
type: StreamStatus.Response,
data_type: 'file_complete',
total_chunks: totalChunks,
total_bytes: totalBytes,
total_chunks: chunkIndex,
total_bytes: bytesRead,
message: 'Download completed'
};

View File

@@ -1,5 +1,5 @@
import { OneBotAction, OneBotRequestToolkit } from "../OneBotAction";
import { NetworkAdapterConfig } from "@/onebot/config/config";
import { OneBotAction, OneBotRequestToolkit } from '../OneBotAction';
import { NetworkAdapterConfig } from '@/onebot/config/config';
export type StreamPacketBasic = {
type: StreamStatus;
data_type?: string;

View File

@@ -154,7 +154,7 @@ export class UploadFileStream extends OneBotAction<Payload, StreamPacket<StreamR
try {
fs.rmSync(stream.tempDir, { recursive: true, force: true });
} catch (cleanupError) {
console.error(`Failed to cleanup temp dir during creation error:`, cleanupError);
console.error('Failed to cleanup temp dir during creation error:', cleanupError);
}
}
throw error;

View File

@@ -180,7 +180,7 @@ export class OneBotMsgApi {
file_size: element.fileSize,
url: url,
},
}
};
}
}
return {
@@ -489,7 +489,7 @@ export class OneBotMsgApi {
url: pttUrl,
file_size: element.fileSize,
},
}
};
}
return {
type: OB11MessageDataType.voice,
@@ -578,8 +578,13 @@ export class OneBotMsgApi {
};
}
if (!context.peer || !atQQ || context.peer.chatType == ChatType.KCHATTYPEC2C) return undefined; // 过滤掉空atQQ
if (!context.peer || context.peer.chatType == ChatType.KCHATTYPEC2C) return undefined;
if (atQQ === 'all') return at(atQQ, atQQ, NTMsgAtType.ATTYPEALL, '全体成员');
// 检查 peerUid 是否有效
if (!context.peer.peerUid) {
this.core.context.logger.logWarn('AT消息处理群组 peerUid 无效', { atQQ });
return undefined;
}
const atMember = await this.core.apis.GroupApi.getGroupMember(context.peer.peerUid, atQQ);
if (atMember) {
return at(atQQ, atMember.uid, NTMsgAtType.ATTYPEONE, atMember.nick || atMember.cardName);
@@ -1150,16 +1155,16 @@ export class OneBotMsgApi {
const calculateTotalSize = async (elements: SendMessageElement[]): Promise<number> => {
const sizePromises = elements.map(async element => {
switch (element.elementType) {
case ElementType.PTT:
return (await fsPromise.stat(element.pttElement.filePath)).size;
case ElementType.FILE:
return (await fsPromise.stat(element.fileElement.filePath)).size;
case ElementType.VIDEO:
return (await fsPromise.stat(element.videoElement.filePath)).size;
case ElementType.PIC:
return (await fsPromise.stat(element.picElement.sourcePath)).size;
default:
return 0;
case ElementType.PTT:
return (await fsPromise.stat(element.pttElement.filePath)).size;
case ElementType.FILE:
return (await fsPromise.stat(element.fileElement.filePath)).size;
case ElementType.VIDEO:
return (await fsPromise.stat(element.videoElement.filePath)).size;
case ElementType.PIC:
return (await fsPromise.stat(element.picElement.sourcePath)).size;
default:
return 0;
}
});
const sizes = await Promise.all(sizePromises);
@@ -1249,16 +1254,16 @@ export class OneBotMsgApi {
groupChangDecreseType2String(type: number): GroupDecreaseSubType {
switch (type) {
case 130:
return 'leave';
case 131:
return 'kick';
case 3:
return 'kick_me';
case 129:
return 'disband';
default:
return 'kick';
case 130:
return 'leave';
case 131:
return 'kick';
case 3:
return 'kick_me';
case 129:
return 'disband';
default:
return 'kick';
}
}

View File

@@ -102,6 +102,7 @@ export class NapCatOneBot11Adapter {
async InitOneBot () {
const selfInfo = this.core.selfInfo;
const ob11Config = this.configLoader.configData;
this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid, false)
.then(async (user) => {
selfInfo.nick = user.nick;

View File

@@ -129,7 +129,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
await this.onEvent({ ...OB11Response.ok(data, real_echo, true) } as unknown as OB11EmitEventContent);
} : async (data: object) => {
let newPromise = new Promise<void>((resolve, _reject) => {
res.write(JSON.stringify({ ...OB11Response.ok(data, real_echo, true) }) + "\r\n\r\n", () => {
res.write(JSON.stringify({ ...OB11Response.ok(data, real_echo, true) }) + '\r\n\r\n', () => {
resolve();
});
});
@@ -137,7 +137,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
}
}, real_echo);
if (useStream) {
res.write(JSON.stringify({ ...result }) + "\r\n\r\n");
res.write(JSON.stringify({ ...result }) + '\r\n\r\n');
return res.end();
};
return res.json(result);

View File

@@ -74,7 +74,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
this.logger.log(`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error loading plugins:`, error);
this.logger.logError('[Plugin Adapter] Error loading plugins:', error);
}
}

Some files were not shown because too many files have changed in this diff Show More