mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-10 06:50:26 +00:00
Compare commits
8 Commits
v4.9.2
...
fix-config
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9e569771c | ||
|
|
84fa9da762 | ||
|
|
2f9bcd88ba | ||
|
|
8eacaa294f | ||
|
|
5a2939f4ec | ||
|
|
d3013e32e1 | ||
|
|
5499b5fbc9 | ||
|
|
ed0b8408df |
17
README.md
17
README.md
@@ -13,15 +13,6 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
|
||||
---
|
||||
|
||||
## New Feature
|
||||
在 v4.8.115+ 版本开始
|
||||
|
||||
1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file)
|
||||
2. NapCatQQ 推荐 message_id/user_id/group_id 均使用字符串类型
|
||||
|
||||
- [1] 解决 Docker/跨设备/大文件 的多媒体上下传问题
|
||||
- [2] 采用字符串可以解决扩展到int64的问题,同时也可以解决部分语言(如JavaScript)对大整数支持不佳的问题,增加极少成本。
|
||||
|
||||
## Welcome
|
||||
+ NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
|
||||
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
||||
@@ -42,7 +33,6 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
|
||||
**首次使用**请务必查看如下文档看使用教程
|
||||
|
||||
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||
## Link
|
||||
|
||||
| Docs | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
@@ -51,17 +41,12 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
| Docs | [](https://napneko.pages.dev/) | [](https://napcat.cyou/) | [](https://www.napcat.wiki) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| QQ Group | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/8zJMLjqy2Y) | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/I6LU87a0Yq) |
|
||||
| QQ Group | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/8zJMLjqy2Y) | [](https://qm.qq.com/q/HaRcfrHpUk) | [](https://qm.qq.com/q/I6LU87a0Yq) |
|
||||
|:-:|:-:|:-:|:-:|:-:|
|
||||
|
||||
| Telegram | [](https://t.me/napcatqq) |
|
||||
|:-:|:-:|
|
||||
|
||||
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||
|:-:|:-:|
|
||||
|
||||
> 请不要在其余社区提及本项目(包括其余协议端/相关应用端项目)引发争论,如有建议到达官方交流群讨论或PR。
|
||||
|
||||
## Thanks
|
||||
|
||||
+ [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
||||
|
||||
BIN
external/LiteLoaderWrapper.zip
vendored
BIN
external/LiteLoaderWrapper.zip
vendored
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"name": "NapCatQQ",
|
||||
"slug": "NapCat.Framework",
|
||||
"description": "高性能的 OneBot 11 协议实现",
|
||||
"version": "4.9.0",
|
||||
"version": "4.8.109",
|
||||
"icon": "./logo.png",
|
||||
"authors": [
|
||||
{
|
||||
|
||||
@@ -82,7 +82,6 @@ export default function FileTable({
|
||||
setPreviewImages([])
|
||||
setPreviewIndex(0)
|
||||
setShowImage(false)
|
||||
setPage(1)
|
||||
}, [currentPath])
|
||||
|
||||
const onPreviewImage = (name: string, images: PreviewImage[]) => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -182,4 +182,4 @@ const ServerConfigCard = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerConfigCard
|
||||
export default ServerConfigCard
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "napcat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "4.9.0",
|
||||
"version": "4.8.109",
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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.error('[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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; // 默认时长
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const napCatVersion = '4.9.0';
|
||||
export const napCatVersion = '4.8.109';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
40
src/core/external/appid.json
vendored
40
src/core/external/appid.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
38
src/core/external/napi2native.json
vendored
38
src/core/external/napi2native.json
vendored
@@ -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": "2CC8120",
|
||||
"recv": "2D28F20"
|
||||
},
|
||||
"3.2.20-40824-x64": {
|
||||
"send": "2CC8120",
|
||||
"recv": "2D28F20"
|
||||
},
|
||||
"3.2.20-40990-x64": {
|
||||
"send": "2CC8120",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
88
src/core/packet/client/baseClient.ts
Normal file
88
src/core/packet/client/baseClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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: '' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/native/packet/MoeHoo.darwin.x64.node
Normal file
BIN
src/native/packet/MoeHoo.darwin.x64.node
Normal file
Binary file not shown.
BIN
src/native/packet/MoeHoo.linux.arm64.new.node
Normal file
BIN
src/native/packet/MoeHoo.linux.arm64.new.node
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/native/packet/MoeHoo.linux.x64.new.node
Normal file
BIN
src/native/packet/MoeHoo.linux.x64.new.node
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/native/packet/MoeHoo.win32.x64.new.node
Normal file
BIN
src/native/packet/MoeHoo.win32.x64.new.node
Normal file
Binary file not shown.
Binary file not shown.
@@ -4,10 +4,9 @@ import { NapCatCore } from '@/core';
|
||||
import { NapCatOneBot11Adapter, OB11Return } from '@/onebot';
|
||||
import { NetworkAdapterConfig } from '../config/config';
|
||||
import { TSchema } from '@sinclair/typebox';
|
||||
import { StreamPacket, StreamPacketBasic, StreamStatus } from './stream/StreamBasic';
|
||||
|
||||
export class OB11Response {
|
||||
private static createResponse<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||
private static createResponse<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null): OB11Return<T> {
|
||||
return {
|
||||
status,
|
||||
retcode,
|
||||
@@ -15,32 +14,28 @@ export class OB11Response {
|
||||
message,
|
||||
wording: message,
|
||||
echo,
|
||||
stream: useStream ? 'stream-action' : 'normal-action'
|
||||
};
|
||||
}
|
||||
|
||||
static res<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||
return this.createResponse(data, status, retcode, message, echo, useStream);
|
||||
static res<T>(data: T, status: string, retcode: number, message: string = ''): OB11Return<T> {
|
||||
return this.createResponse(data, status, retcode, message);
|
||||
}
|
||||
|
||||
static ok<T>(data: T, echo: unknown = null, useStream: boolean = false): OB11Return<T> {
|
||||
return this.createResponse(data, 'ok', 0, '', echo, useStream);
|
||||
static ok<T>(data: T, echo: unknown = null): OB11Return<T> {
|
||||
return this.createResponse(data, 'ok', 0, '', echo);
|
||||
}
|
||||
|
||||
static error(err: string, retcode: number, echo: unknown = null, useStream: boolean = false): OB11Return<null | StreamPacketBasic> {
|
||||
return this.createResponse(useStream ? { type: StreamStatus.Error, data_type: 'error' } : null, 'failed', retcode, err, echo, useStream);
|
||||
static error(err: string, retcode: number, echo: unknown = null): OB11Return<null> {
|
||||
return this.createResponse(null, 'failed', retcode, err, echo);
|
||||
}
|
||||
}
|
||||
export abstract class OneBotRequestToolkit {
|
||||
abstract send<T>(packet: StreamPacket<T>): Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class OneBotAction<PayloadType, ReturnDataType> {
|
||||
actionName: typeof ActionName[keyof typeof ActionName] = ActionName.Unknown;
|
||||
core: NapCatCore;
|
||||
private validate?: ValidateFunction<unknown> = undefined;
|
||||
payloadSchema?: TSchema = undefined;
|
||||
obContext: NapCatOneBot11Adapter;
|
||||
useStream: boolean = false;
|
||||
|
||||
constructor(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
||||
this.obContext = obContext;
|
||||
@@ -62,33 +57,33 @@ export abstract class OneBotAction<PayloadType, ReturnDataType> {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
public async handle(payload: PayloadType, adaptername: string, config: NetworkAdapterConfig, req: OneBotRequestToolkit = { send: async () => { } }, echo?: string): Promise<OB11Return<ReturnDataType | StreamPacketBasic | null>> {
|
||||
public async handle(payload: PayloadType, adaptername: string, config: NetworkAdapterConfig): Promise<OB11Return<ReturnDataType | null>> {
|
||||
const result = await this.check(payload);
|
||||
if (!result.valid) {
|
||||
return OB11Response.error(result.message, 400);
|
||||
}
|
||||
try {
|
||||
const resData = await this._handle(payload, adaptername, config, req);
|
||||
return OB11Response.ok(resData, echo, this.useStream);
|
||||
const resData = await this._handle(payload, adaptername, config);
|
||||
return OB11Response.ok(resData);
|
||||
} catch (e: unknown) {
|
||||
this.core.context.logger.logError('发生错误', e);
|
||||
return OB11Response.error((e as Error).message.toString() || (e as Error)?.stack?.toString() || '未知错误,可能操作超时', 200, echo, this.useStream);
|
||||
return OB11Response.error((e as Error).message.toString() || (e as Error)?.stack?.toString() || '未知错误,可能操作超时', 200);
|
||||
}
|
||||
}
|
||||
|
||||
public async websocketHandle(payload: PayloadType, echo: unknown, adaptername: string, config: NetworkAdapterConfig, req: OneBotRequestToolkit = { send: async () => { } }): Promise<OB11Return<ReturnDataType | StreamPacketBasic | null>> {
|
||||
public async websocketHandle(payload: PayloadType, echo: unknown, adaptername: string, config: NetworkAdapterConfig): Promise<OB11Return<ReturnDataType | null>> {
|
||||
const result = await this.check(payload);
|
||||
if (!result.valid) {
|
||||
return OB11Response.error(result.message, 1400, echo, this.useStream);
|
||||
return OB11Response.error(result.message, 1400, echo);
|
||||
}
|
||||
try {
|
||||
const resData = await this._handle(payload, adaptername, config, req);
|
||||
return OB11Response.ok(resData, echo, this.useStream);
|
||||
const resData = await this._handle(payload, adaptername, config);
|
||||
return OB11Response.ok(resData, echo);
|
||||
} catch (e: unknown) {
|
||||
this.core.context.logger.logError('发生错误', e);
|
||||
return OB11Response.error(((e as Error).message.toString() || (e as Error).stack?.toString()) ?? 'Error', 1200, echo, this.useStream);
|
||||
return OB11Response.error(((e as Error).message.toString() || (e as Error).stack?.toString()) ?? 'Error', 1200, echo);
|
||||
}
|
||||
}
|
||||
|
||||
abstract _handle(payload: PayloadType, adaptername: string, config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<ReturnDataType>;
|
||||
abstract _handle(payload: PayloadType, adaptername: string, config: NetworkAdapterConfig): Promise<ReturnDataType>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -39,8 +39,8 @@ export class GetUnidirectionalFriendList extends OneBotAction<void, Friend[]> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,12 +41,12 @@ export class GetFileBase extends OneBotAction<GetFilePayload, GetFileResponse> {
|
||||
let url = '';
|
||||
if (mixElement?.picElement && rawMessage) {
|
||||
const tempData =
|
||||
await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: true }) as OB11MessageImage | undefined;
|
||||
await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, { parseMultMsg: false }) as OB11MessageImage | undefined;
|
||||
url = tempData?.data.url ?? '';
|
||||
}
|
||||
if (mixElement?.videoElement && rawMessage) {
|
||||
const tempData =
|
||||
await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: true }) as OB11MessageVideo | undefined;
|
||||
await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, { parseMultMsg: false }) as OB11MessageVideo | undefined;
|
||||
url = tempData?.data.url ?? '';
|
||||
}
|
||||
const res: GetFileResponse = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -130,22 +130,10 @@ import { DoGroupAlbumComment } from './extends/DoGroupAlbumComment';
|
||||
import { GetGroupAlbumMediaList } from './extends/GetGroupAlbumMediaList';
|
||||
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';
|
||||
|
||||
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
||||
|
||||
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),
|
||||
new SetGroupAlbumMediaLike(obContext, core),
|
||||
new DoGroupAlbumComment(obContext, core),
|
||||
|
||||
@@ -10,16 +10,6 @@ export interface InvalidCheckResult {
|
||||
}
|
||||
|
||||
export const ActionName = {
|
||||
// 所有 Normal Stream Api 表示并未流传输 表示与流传输有关
|
||||
CleanStreamTempFile: 'clean_stream_temp_file',
|
||||
|
||||
// 所有 Upload/Download Stream Api 应当 _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',
|
||||
DoGroupAlbumComment: 'do_group_album_comment',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ActionName } from '@/onebot/action/router';
|
||||
import { OneBotAction } from '@/onebot/action/OneBotAction';
|
||||
import { join } from 'node:path';
|
||||
import { readdir, unlink } from 'node:fs/promises';
|
||||
|
||||
export class CleanStreamTempFile extends OneBotAction<void, void> {
|
||||
override actionName = ActionName.CleanStreamTempFile;
|
||||
|
||||
async _handle(_payload: void): Promise<void> {
|
||||
try {
|
||||
// 获取临时文件夹路径
|
||||
const tempPath = this.core.NapCatTempPath;
|
||||
|
||||
// 读取文件夹中的所有文件
|
||||
const files = await readdir(tempPath);
|
||||
|
||||
// 删除每个文件
|
||||
const deletePromises = files.map(async (file) => {
|
||||
const filePath = join(tempPath, file);
|
||||
try {
|
||||
await unlink(filePath);
|
||||
this.core.context.logger.log(`已删除文件: ${filePath}`);
|
||||
} catch (err: unknown) {
|
||||
this.core.context.logger.log(`删除文件 ${filePath} 失败: ${(err as Error).message}`);
|
||||
|
||||
}
|
||||
});
|
||||
await Promise.all(deletePromises);
|
||||
} catch (err: unknown) {
|
||||
this.core.context.logger.log(`清理流临时文件失败: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +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 { 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 DownloadFileStream extends BaseDownloadStream<Payload, DownloadResult> {
|
||||
override actionName = ActionName.DownloadFileStream;
|
||||
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;
|
||||
|
||||
await req.send({
|
||||
type: StreamStatus.Stream,
|
||||
data_type: 'file_info',
|
||||
file_name: fileName,
|
||||
file_size: totalSize,
|
||||
chunk_size: chunkSize
|
||||
});
|
||||
|
||||
const { totalChunks, totalBytes } = await this.streamFileChunks(req, downloadPath, 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Stream-Api
|
||||
|
||||
## 流式接口
|
||||
@@ -1,16 +0,0 @@
|
||||
import { OneBotAction, OneBotRequestToolkit } from "../OneBotAction";
|
||||
import { NetworkAdapterConfig } from "@/onebot/config/config";
|
||||
export type StreamPacketBasic = {
|
||||
type: StreamStatus;
|
||||
data_type?: string;
|
||||
};
|
||||
export type StreamPacket<T> = T & StreamPacketBasic;
|
||||
export enum StreamStatus {
|
||||
Stream = 'stream', // 分片流数据包
|
||||
Response = 'response', // 流最终响应
|
||||
Reset = 'reset', // 重置流
|
||||
Error = 'error' // 流错误
|
||||
}
|
||||
export abstract class BasicStream<T, R> extends OneBotAction<T, StreamPacket<R>> {
|
||||
abstract override _handle(_payload: T, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<StreamPacket<R>>;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ActionName } from '@/onebot/action/router';
|
||||
import { OneBotAction, OneBotRequestToolkit } from '@/onebot/action/OneBotAction';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { NetworkAdapterConfig } from '@/onebot/config/config';
|
||||
import { StreamPacket, StreamStatus } from './StreamBasic';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
error: Type.Optional(Type.Boolean({ default: false }))
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class TestDownloadStream extends OneBotAction<Payload, StreamPacket<{ data: string }>> {
|
||||
override actionName = ActionName.TestDownloadStream;
|
||||
override payloadSchema = SchemaData;
|
||||
override useStream = true;
|
||||
|
||||
async _handle(_payload: Payload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await req.send({ type: StreamStatus.Stream, data: `Index-> ${i + 1}`, data_type: 'data_chunk' });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
if( _payload.error ){
|
||||
throw new Error('This is a test error');
|
||||
}
|
||||
return {
|
||||
type: StreamStatus.Response,
|
||||
data_type: 'data_complete',
|
||||
data: 'Stream transmission complete'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
import { ActionName } from '@/onebot/action/router';
|
||||
import { OneBotAction } 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 { join as joinPath } from 'node:path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createHash } from 'crypto';
|
||||
import { unlink } from 'node:fs';
|
||||
|
||||
// 简化配置
|
||||
const CONFIG = {
|
||||
TIMEOUT: 10 * 60 * 1000, // 10分钟超时
|
||||
MEMORY_THRESHOLD: 10 * 1024 * 1024, // 10MB,超过使用磁盘
|
||||
MEMORY_LIMIT: 100 * 1024 * 1024 // 100MB内存总限制
|
||||
} as const;
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
stream_id: Type.String(),
|
||||
chunk_data: Type.Optional(Type.String()),
|
||||
chunk_index: Type.Optional(Type.Number()),
|
||||
total_chunks: Type.Optional(Type.Number()),
|
||||
file_size: Type.Optional(Type.Number()),
|
||||
expected_sha256: Type.Optional(Type.String()),
|
||||
is_complete: Type.Optional(Type.Boolean()),
|
||||
filename: Type.Optional(Type.String()),
|
||||
reset: Type.Optional(Type.Boolean()),
|
||||
verify_only: Type.Optional(Type.Boolean()),
|
||||
file_retention: Type.Number({ default: 5 * 60 * 1000 }) // 默认5分钟 回收 不设置或0为不回收
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
// 简化流状态接口
|
||||
interface StreamState {
|
||||
id: string;
|
||||
filename: string;
|
||||
totalChunks: number;
|
||||
receivedChunks: number;
|
||||
missingChunks: Set<number>;
|
||||
|
||||
// 可选属性
|
||||
fileSize?: number;
|
||||
expectedSha256?: string;
|
||||
|
||||
// 存储策略
|
||||
useMemory: boolean;
|
||||
memoryChunks?: Map<number, Buffer>;
|
||||
tempDir?: string;
|
||||
finalPath?: string;
|
||||
fileRetention?: number;
|
||||
|
||||
// 管理
|
||||
createdAt: number;
|
||||
timeoutId: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
interface StreamResult {
|
||||
stream_id: string;
|
||||
status: 'file_created' | 'chunk_received' | 'file_complete';
|
||||
received_chunks: number;
|
||||
total_chunks: number;
|
||||
file_path?: string;
|
||||
file_size?: number;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
export class UploadFileStream extends OneBotAction<Payload, StreamPacket<StreamResult>> {
|
||||
override actionName = ActionName.UploadFileStream;
|
||||
override payloadSchema = SchemaData;
|
||||
override useStream = true;
|
||||
|
||||
private static streams = new Map<string, StreamState>();
|
||||
private static memoryUsage = 0;
|
||||
|
||||
async _handle(payload: Payload, _adaptername: string, _config: NetworkAdapterConfig): Promise<StreamPacket<StreamResult>> {
|
||||
const { stream_id, reset, verify_only } = payload;
|
||||
|
||||
if (reset) {
|
||||
this.cleanupStream(stream_id);
|
||||
throw new Error('Stream reset completed');
|
||||
}
|
||||
|
||||
if (verify_only) {
|
||||
const stream = UploadFileStream.streams.get(stream_id);
|
||||
if (!stream) throw new Error('Stream not found');
|
||||
return this.getStreamStatus(stream);
|
||||
}
|
||||
|
||||
const stream = this.getOrCreateStream(payload);
|
||||
|
||||
if (payload.chunk_data && payload.chunk_index !== undefined) {
|
||||
return await this.processChunk(stream, payload.chunk_data, payload.chunk_index);
|
||||
}
|
||||
|
||||
if (payload.is_complete || stream.receivedChunks === stream.totalChunks) {
|
||||
return await this.completeStream(stream);
|
||||
}
|
||||
|
||||
return this.getStreamStatus(stream);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private getOrCreateStream(payload: Payload): StreamState {
|
||||
let stream = UploadFileStream.streams.get(payload.stream_id);
|
||||
|
||||
if (!stream) {
|
||||
if (!payload.total_chunks) {
|
||||
throw new Error('total_chunks required for new stream');
|
||||
}
|
||||
stream = this.createStream(payload);
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
private createStream(payload: Payload): StreamState {
|
||||
const { stream_id, total_chunks, file_size, filename, expected_sha256 } = payload;
|
||||
|
||||
const useMemory = this.shouldUseMemory(file_size);
|
||||
if (useMemory && file_size && (UploadFileStream.memoryUsage + file_size) > CONFIG.MEMORY_LIMIT) {
|
||||
throw new Error('Memory limit exceeded');
|
||||
}
|
||||
|
||||
const stream: StreamState = {
|
||||
id: stream_id,
|
||||
filename: filename || `upload_${randomUUID()}`,
|
||||
totalChunks: total_chunks!,
|
||||
receivedChunks: 0,
|
||||
missingChunks: new Set(Array.from({ length: total_chunks! }, (_, i) => i)),
|
||||
fileSize: file_size,
|
||||
expectedSha256: expected_sha256,
|
||||
useMemory,
|
||||
createdAt: Date.now(),
|
||||
timeoutId: this.setupTimeout(stream_id),
|
||||
fileRetention: payload.file_retention
|
||||
};
|
||||
try {
|
||||
if (useMemory) {
|
||||
stream.memoryChunks = new Map();
|
||||
if (file_size) UploadFileStream.memoryUsage += file_size;
|
||||
} else {
|
||||
this.setupDiskStorage(stream);
|
||||
}
|
||||
|
||||
UploadFileStream.streams.set(stream_id, stream);
|
||||
return stream;
|
||||
} catch (error) {
|
||||
// 如果设置存储失败,清理已创建的资源
|
||||
clearTimeout(stream.timeoutId);
|
||||
if (stream.tempDir && fs.existsSync(stream.tempDir)) {
|
||||
try {
|
||||
fs.rmSync(stream.tempDir, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
console.error(`Failed to cleanup temp dir during creation error:`, cleanupError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldUseMemory(fileSize?: number): boolean {
|
||||
return fileSize !== undefined && fileSize <= CONFIG.MEMORY_THRESHOLD;
|
||||
}
|
||||
|
||||
private setupDiskStorage(stream: StreamState): void {
|
||||
const tempDir = joinPath(this.core.NapCatTempPath, `upload_${stream.id}`);
|
||||
const finalPath = joinPath(this.core.NapCatTempPath, stream.filename);
|
||||
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
stream.tempDir = tempDir;
|
||||
stream.finalPath = finalPath;
|
||||
}
|
||||
|
||||
private setupTimeout(streamId: string): NodeJS.Timeout {
|
||||
return setTimeout(() => {
|
||||
console.log(`Stream ${streamId} timeout`);
|
||||
this.cleanupStream(streamId);
|
||||
}, CONFIG.TIMEOUT);
|
||||
}
|
||||
|
||||
private async processChunk(stream: StreamState, chunkData: string, chunkIndex: number): Promise<StreamPacket<StreamResult>> {
|
||||
// 验证索引
|
||||
if (chunkIndex < 0 || chunkIndex >= stream.totalChunks) {
|
||||
throw new Error(`Invalid chunk index: ${chunkIndex}`);
|
||||
}
|
||||
|
||||
// 检查重复
|
||||
if (!stream.missingChunks.has(chunkIndex)) {
|
||||
return this.getStreamStatus(stream);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(chunkData, 'base64');
|
||||
|
||||
// 存储分片
|
||||
if (stream.useMemory) {
|
||||
stream.memoryChunks!.set(chunkIndex, buffer);
|
||||
} else {
|
||||
const chunkPath = joinPath(stream.tempDir!, `${chunkIndex}.chunk`);
|
||||
await fs.promises.writeFile(chunkPath, buffer);
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
stream.missingChunks.delete(chunkIndex);
|
||||
stream.receivedChunks++;
|
||||
this.refreshTimeout(stream);
|
||||
|
||||
return {
|
||||
type: StreamStatus.Stream,
|
||||
stream_id: stream.id,
|
||||
status: 'chunk_received',
|
||||
received_chunks: stream.receivedChunks,
|
||||
total_chunks: stream.totalChunks
|
||||
};
|
||||
}
|
||||
|
||||
private refreshTimeout(stream: StreamState): void {
|
||||
clearTimeout(stream.timeoutId);
|
||||
stream.timeoutId = this.setupTimeout(stream.id);
|
||||
}
|
||||
|
||||
private getStreamStatus(stream: StreamState): StreamPacket<StreamResult> {
|
||||
return {
|
||||
type: StreamStatus.Stream,
|
||||
stream_id: stream.id,
|
||||
status: 'file_created',
|
||||
received_chunks: stream.receivedChunks,
|
||||
total_chunks: stream.totalChunks
|
||||
};
|
||||
}
|
||||
|
||||
private async completeStream(stream: StreamState): Promise<StreamPacket<StreamResult>> {
|
||||
// 合并分片
|
||||
const finalBuffer = stream.useMemory ?
|
||||
await this.mergeMemoryChunks(stream) :
|
||||
await this.mergeDiskChunks(stream);
|
||||
|
||||
// 验证SHA256
|
||||
const sha256 = this.validateSha256(stream, finalBuffer);
|
||||
|
||||
// 保存文件
|
||||
const finalPath = stream.finalPath || joinPath(this.core.NapCatTempPath, stream.filename);
|
||||
await fs.promises.writeFile(finalPath, finalBuffer);
|
||||
|
||||
// 清理资源但保留文件
|
||||
this.cleanupStream(stream.id, false);
|
||||
if (stream.fileRetention && stream.fileRetention > 0) {
|
||||
setTimeout(() => {
|
||||
unlink(finalPath, err => {
|
||||
if (err) this.core.context.logger.logError(`Failed to delete retained file ${finalPath}:`, err);
|
||||
});
|
||||
}, stream.fileRetention);
|
||||
}
|
||||
return {
|
||||
type: StreamStatus.Response,
|
||||
stream_id: stream.id,
|
||||
status: 'file_complete',
|
||||
received_chunks: stream.receivedChunks,
|
||||
total_chunks: stream.totalChunks,
|
||||
file_path: finalPath,
|
||||
file_size: finalBuffer.length,
|
||||
sha256
|
||||
};
|
||||
}
|
||||
|
||||
private async mergeMemoryChunks(stream: StreamState): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for (let i = 0; i < stream.totalChunks; i++) {
|
||||
const chunk = stream.memoryChunks!.get(i);
|
||||
if (!chunk) throw new Error(`Missing memory chunk ${i}`);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
private async mergeDiskChunks(stream: StreamState): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for (let i = 0; i < stream.totalChunks; i++) {
|
||||
const chunkPath = joinPath(stream.tempDir!, `${i}.chunk`);
|
||||
if (!fs.existsSync(chunkPath)) throw new Error(`Missing chunk file ${i}`);
|
||||
chunks.push(await fs.promises.readFile(chunkPath));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
private validateSha256(stream: StreamState, buffer: Buffer): string | undefined {
|
||||
if (!stream.expectedSha256) return undefined;
|
||||
|
||||
const actualSha256 = createHash('sha256').update(buffer).digest('hex');
|
||||
if (actualSha256 !== stream.expectedSha256) {
|
||||
throw new Error(`SHA256 mismatch. Expected: ${stream.expectedSha256}, Got: ${actualSha256}`);
|
||||
}
|
||||
return actualSha256;
|
||||
}
|
||||
|
||||
private cleanupStream(streamId: string, deleteFinalFile = true): void {
|
||||
const stream = UploadFileStream.streams.get(streamId);
|
||||
if (!stream) return;
|
||||
|
||||
try {
|
||||
// 清理超时
|
||||
clearTimeout(stream.timeoutId);
|
||||
|
||||
// 清理内存
|
||||
if (stream.useMemory) {
|
||||
if (stream.fileSize) {
|
||||
UploadFileStream.memoryUsage = Math.max(0, UploadFileStream.memoryUsage - stream.fileSize);
|
||||
}
|
||||
stream.memoryChunks?.clear();
|
||||
}
|
||||
|
||||
// 清理临时文件夹及其所有内容
|
||||
if (stream.tempDir) {
|
||||
try {
|
||||
if (fs.existsSync(stream.tempDir)) {
|
||||
fs.rmSync(stream.tempDir, { recursive: true, force: true });
|
||||
console.log(`Cleaned up temp directory: ${stream.tempDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to cleanup temp directory ${stream.tempDir}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除最终文件(如果需要)
|
||||
if (deleteFinalFile && stream.finalPath) {
|
||||
try {
|
||||
if (fs.existsSync(stream.finalPath)) {
|
||||
fs.unlinkSync(stream.finalPath);
|
||||
console.log(`Deleted final file: ${stream.finalPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete final file ${stream.finalPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Cleanup error for stream ${streamId}:`, error);
|
||||
} finally {
|
||||
UploadFileStream.streams.delete(streamId);
|
||||
console.log(`Stream ${streamId} cleaned up`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
NapCat OneBot WebSocket 文件流上传测试脚本
|
||||
用于测试 UploadFileStream 接口的一次性分片上传功能
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
import websockets
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
class OneBotUploadTester:
|
||||
def __init__(self, ws_url: str = "ws://localhost:3001", access_token: Optional[str] = None):
|
||||
self.ws_url = ws_url
|
||||
self.access_token = access_token
|
||||
self.websocket = None
|
||||
|
||||
async def connect(self):
|
||||
"""连接到 OneBot WebSocket"""
|
||||
headers = {}
|
||||
if self.access_token:
|
||||
headers["Authorization"] = f"Bearer {self.access_token}"
|
||||
|
||||
print(f"连接到 {self.ws_url}")
|
||||
self.websocket = await websockets.connect(self.ws_url, additional_headers=headers)
|
||||
print("WebSocket 连接成功")
|
||||
|
||||
async def disconnect(self):
|
||||
"""断开 WebSocket 连接"""
|
||||
if self.websocket:
|
||||
await self.websocket.close()
|
||||
print("WebSocket 连接已断开")
|
||||
|
||||
def calculate_file_chunks(self, file_path: str, chunk_size: int = 64) -> tuple[List[bytes], str, int]:
|
||||
"""
|
||||
计算文件分片和 SHA256
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
chunk_size: 分片大小(默认64KB)
|
||||
|
||||
Returns:
|
||||
(chunks, sha256_hash, total_size)
|
||||
"""
|
||||
chunks = []
|
||||
hasher = hashlib.sha256()
|
||||
total_size = 0
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
hasher.update(chunk)
|
||||
total_size += len(chunk)
|
||||
|
||||
sha256_hash = hasher.hexdigest()
|
||||
print(f"文件分析完成:")
|
||||
print(f" - 文件大小: {total_size} 字节")
|
||||
print(f" - 分片数量: {len(chunks)}")
|
||||
print(f" - SHA256: {sha256_hash}")
|
||||
|
||||
return chunks, sha256_hash, total_size
|
||||
|
||||
async def send_action(self, action: str, params: dict, echo: str = None) -> dict:
|
||||
"""发送 OneBot 动作请求"""
|
||||
if not echo:
|
||||
echo = str(uuid.uuid4())
|
||||
|
||||
message = {
|
||||
"action": action,
|
||||
"params": params,
|
||||
"echo": echo
|
||||
}
|
||||
|
||||
print(f"发送请求: {action}")
|
||||
await self.websocket.send(json.dumps(message))
|
||||
|
||||
# 等待响应
|
||||
while True:
|
||||
response = await self.websocket.recv()
|
||||
data = json.loads(response)
|
||||
|
||||
# 检查是否是我们的响应
|
||||
if data.get("echo") == echo:
|
||||
return data
|
||||
else:
|
||||
# 可能是其他消息,继续等待
|
||||
print(f"收到其他消息: {data}")
|
||||
continue
|
||||
|
||||
async def upload_file_stream_batch(self, file_path: str, chunk_size: int = 64 ) -> str:
|
||||
"""
|
||||
一次性批量上传文件流
|
||||
|
||||
Args:
|
||||
file_path: 要上传的文件路径
|
||||
chunk_size: 分片大小
|
||||
|
||||
Returns:
|
||||
上传完成后的文件路径
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||||
|
||||
# 分析文件
|
||||
chunks, sha256_hash, total_size = self.calculate_file_chunks(str(file_path), chunk_size)
|
||||
stream_id = str(uuid.uuid4())
|
||||
|
||||
print(f"\n开始上传文件: {file_path.name}")
|
||||
print(f"流ID: {stream_id}")
|
||||
|
||||
# 一次性发送所有分片
|
||||
total_chunks = len(chunks)
|
||||
|
||||
for chunk_index, chunk_data in enumerate(chunks):
|
||||
# 将分片数据编码为 base64
|
||||
chunk_base64 = base64.b64encode(chunk_data).decode('utf-8')
|
||||
|
||||
# 构建参数
|
||||
params = {
|
||||
"stream_id": stream_id,
|
||||
"chunk_data": chunk_base64,
|
||||
"chunk_index": chunk_index,
|
||||
"total_chunks": total_chunks,
|
||||
"file_size": total_size,
|
||||
"expected_sha256": sha256_hash,
|
||||
"filename": file_path.name,
|
||||
"file_retention": 30 * 1000
|
||||
}
|
||||
|
||||
# 发送分片
|
||||
response = await self.send_action("upload_file_stream", params)
|
||||
|
||||
if response.get("status") != "ok":
|
||||
raise Exception(f"上传分片 {chunk_index} 失败: {response}")
|
||||
|
||||
# 解析流响应
|
||||
stream_data = response.get("data", {})
|
||||
print(f"分片 {chunk_index + 1}/{total_chunks} 上传成功 "
|
||||
f"(接收: {stream_data.get('received_chunks', 0)}/{stream_data.get('total_chunks', 0)})")
|
||||
|
||||
# 发送完成信号
|
||||
print(f"\n所有分片发送完成,请求文件合并...")
|
||||
complete_params = {
|
||||
"stream_id": stream_id,
|
||||
"is_complete": True
|
||||
}
|
||||
|
||||
response = await self.send_action("upload_file_stream", complete_params)
|
||||
|
||||
if response.get("status") != "ok":
|
||||
raise Exception(f"文件合并失败: {response}")
|
||||
|
||||
result = response.get("data", {})
|
||||
|
||||
if result.get("status") == "file_complete":
|
||||
print(f"✅ 文件上传成功!")
|
||||
print(f" - 文件路径: {result.get('file_path')}")
|
||||
print(f" - 文件大小: {result.get('file_size')} 字节")
|
||||
print(f" - SHA256: {result.get('sha256')}")
|
||||
return result.get('file_path')
|
||||
else:
|
||||
raise Exception(f"文件状态异常: {result}")
|
||||
|
||||
async def test_upload(self, file_path: str, chunk_size: int = 64 ):
|
||||
"""测试文件上传"""
|
||||
try:
|
||||
await self.connect()
|
||||
|
||||
# 执行上传
|
||||
uploaded_path = await self.upload_file_stream_batch(file_path, chunk_size)
|
||||
|
||||
print(f"\n🎉 测试完成! 上传后的文件路径: {uploaded_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 测试失败: {e}")
|
||||
raise
|
||||
finally:
|
||||
await self.disconnect()
|
||||
|
||||
def create_test_file(file_path: str, size_mb: float = 1):
|
||||
"""创建测试文件"""
|
||||
size_bytes = int(size_mb * 1024 * 1024)
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
# 写入一些有意义的测试数据
|
||||
test_data = b"NapCat Upload Test Data - " * 100
|
||||
written = 0
|
||||
while written < size_bytes:
|
||||
write_size = min(len(test_data), size_bytes - written)
|
||||
f.write(test_data[:write_size])
|
||||
written += write_size
|
||||
|
||||
print(f"创建测试文件: {file_path} ({size_mb}MB)")
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="NapCat OneBot 文件流上传测试")
|
||||
parser.add_argument("--url", default="ws://localhost:3001", help="WebSocket URL")
|
||||
parser.add_argument("--token", help="访问令牌")
|
||||
parser.add_argument("--file", help="要上传的文件路径")
|
||||
parser.add_argument("--chunk-size", type=int, default=64*1024, help="分片大小(字节)")
|
||||
parser.add_argument("--create-test", type=float, help="创建测试文件(MB)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 创建测试文件
|
||||
if args.create_test:
|
||||
test_file = "test_upload_file.bin"
|
||||
create_test_file(test_file, args.create_test)
|
||||
if not args.file:
|
||||
args.file = test_file
|
||||
|
||||
if not args.file:
|
||||
print("请指定要上传的文件路径,或使用 --create-test 创建测试文件")
|
||||
return
|
||||
|
||||
# 创建测试器并运行
|
||||
tester = OneBotUploadTester(args.url, args.token)
|
||||
await tester.test_upload(args.file, args.chunk_size)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 安装依赖提示
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
print("请先安装依赖: pip install websockets")
|
||||
exit(1)
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -578,7 +578,7 @@ 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, '全体成员');
|
||||
const atMember = await this.core.apis.GroupApi.getGroupMember(context.peer.peerUid, atQQ);
|
||||
if (atMember) {
|
||||
@@ -1124,13 +1124,10 @@ export class OneBotMsgApi {
|
||||
if (ignoreTypes.includes(sendMsg.type)) {
|
||||
continue;
|
||||
}
|
||||
const converter = this.ob11ToRawConverters[sendMsg.type] as ((
|
||||
const converter = this.ob11ToRawConverters[sendMsg.type] as (
|
||||
sendMsg: Extract<OB11MessageData, { type: OB11MessageData['type'] }>,
|
||||
context: SendMessageContext,
|
||||
) => Promise<SendMessageElement | undefined>) | undefined;
|
||||
if (converter == undefined) {
|
||||
throw new Error('未知的消息类型:' + sendMsg.type);
|
||||
}
|
||||
) => Promise<SendMessageElement | undefined>;
|
||||
const callResult = converter(
|
||||
sendMsg,
|
||||
{ peer, deleteAfterSentFiles },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,7 +23,7 @@ export abstract class IOB11NetworkAdapter<CT extends NetworkAdapterConfig> {
|
||||
this.logger = core.context.logger;
|
||||
}
|
||||
|
||||
abstract onEvent<T extends OB11EmitEventContent>(event: T): Promise<void>;
|
||||
abstract onEvent<T extends OB11EmitEventContent>(event: T): void;
|
||||
|
||||
abstract open(): void | Promise<void>;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export class OB11HttpClientAdapter extends IOB11NetworkAdapter<HttpClientConfig>
|
||||
super(name, config, core, obContext, actions);
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
this.emitEventAsync(event).catch(e => this.logger.logError('[OneBot] [Http Client] 新消息事件HTTP上报返回快速操作失败', e));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
|
||||
if (req.path === '/_events') {
|
||||
this.createSseSupport(req, res);
|
||||
} else {
|
||||
super.httpApiRequest(req, res, true);
|
||||
super.httpApiRequest(req, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,22 +23,11 @@ export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
|
||||
req.on('close', () => {
|
||||
this.sseClients = this.sseClients.filter((client) => client !== res);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
override async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
let promises: Promise<void>[] = [];
|
||||
override onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
this.sseClients.forEach((res) => {
|
||||
promises.push(new Promise<void>((resolve, reject) => {
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}));
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
override async onEvent<T extends OB11EmitEventContent>(_event: T) {
|
||||
override onEvent<T extends OB11EmitEventContent>(_event: T) {
|
||||
// http server is passive, no need to emit event
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
}
|
||||
}
|
||||
|
||||
async httpApiRequest(req: Request, res: Response, request_sse: boolean = false) {
|
||||
async httpApiRequest(req: Request, res: Response) {
|
||||
let payload = req.body;
|
||||
if (req.method == 'get') {
|
||||
payload = req.query;
|
||||
@@ -117,35 +117,17 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
return res.json(hello);
|
||||
}
|
||||
const actionName = req.path.split('/')[1];
|
||||
const payload_echo = payload['echo'];
|
||||
const real_echo = payload_echo ?? Math.random().toString(36).substring(2, 15);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const action = this.actions.get(actionName as any);
|
||||
if (action) {
|
||||
const useStream = action.useStream;
|
||||
try {
|
||||
const result = await action.handle(payload, this.name, this.config, {
|
||||
send: request_sse ? async (data: object) => {
|
||||
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", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return newPromise;
|
||||
}
|
||||
}, real_echo);
|
||||
if (useStream) {
|
||||
res.write(JSON.stringify({ ...result }) + "\r\n\r\n");
|
||||
return res.end();
|
||||
};
|
||||
const result = await action.handle(payload, this.name, this.config);
|
||||
return res.json(result);
|
||||
} catch (error: unknown) {
|
||||
return res.json(OB11Response.error((error as Error)?.stack?.toString() || (error as Error)?.message || 'Error Handle', 200, real_echo));
|
||||
return res.json(OB11Response.error((error as Error)?.stack?.toString() || (error as Error)?.message || 'Error Handle', 200));
|
||||
}
|
||||
} else {
|
||||
return res.json(OB11Response.error('不支持的Api ' + actionName, 200, real_echo));
|
||||
return res.json(OB11Response.error('不支持的Api ' + actionName, 200));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ export class OB11NetworkManager {
|
||||
}
|
||||
|
||||
async emitEvent(event: OB11EmitEventContent) {
|
||||
return Promise.all(Array.from(this.adapters.values()).map(async adapter => {
|
||||
return Promise.all(Array.from(this.adapters.values()).map(adapter => {
|
||||
if (adapter.isEnable) {
|
||||
return await adapter.onEvent(event);
|
||||
return adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -32,19 +32,19 @@ export class OB11NetworkManager {
|
||||
}
|
||||
|
||||
async emitEventByName(names: string[], event: OB11EmitEventContent) {
|
||||
return Promise.all(names.map(async name => {
|
||||
return Promise.all(names.map(name => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isEnable) {
|
||||
return await adapter.onEvent(event);
|
||||
return adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async emitEventByNames(map: Map<string, OB11EmitEventContent>) {
|
||||
return Promise.all(Array.from(map.entries()).map(async ([name, event]) => {
|
||||
return Promise.all(Array.from(map.entries()).map(([name, event]) => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isEnable) {
|
||||
return await adapter.onEvent(event);
|
||||
return adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -251,20 +251,14 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 遍历所有已加载的插件,调用它们的事件处理方法
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
Array.from(this.loadedPlugins.values()).map((plugin) =>
|
||||
this.callPluginEventHandler(plugin, event)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.logError('[Plugin Adapter] Error handling event:', error);
|
||||
for (const [, plugin] of this.loadedPlugins) {
|
||||
this.callPluginEventHandler(plugin, event);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -251,7 +251,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
super(name, config, core, obContext, actions);
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||
this.connection.send(JSON.stringify(event));
|
||||
}
|
||||
@@ -62,15 +62,10 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
}
|
||||
}
|
||||
|
||||
private async checkStateAndReply<T>(data: T) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||
this.connection.send(JSON.stringify(data));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
private checkStateAndReply<T>(data: T) {
|
||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||
this.connection.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
private async tryConnect() {
|
||||
@@ -97,7 +92,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
});
|
||||
this.connection.on('open', () => {
|
||||
try {
|
||||
this.connectEvent(this.core).catch(e => this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e));
|
||||
this.connectEvent(this.core);
|
||||
} catch (e) {
|
||||
this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e);
|
||||
}
|
||||
@@ -128,9 +123,9 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
}
|
||||
}
|
||||
|
||||
async connectEvent(core: NapCatCore) {
|
||||
connectEvent(core: NapCatCore) {
|
||||
try {
|
||||
await this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT));
|
||||
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT));
|
||||
} catch (e) {
|
||||
this.logger.logError('[OneBot] [WebSocket Client] 发送生命周期失败', e);
|
||||
}
|
||||
@@ -145,7 +140,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
echo = receiveData.echo;
|
||||
this.logger.logDebug('[OneBot] [WebSocket Client] 收到正向Websocket消息', receiveData);
|
||||
} catch {
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
|
||||
this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
|
||||
return;
|
||||
}
|
||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};// 兼容类型验证
|
||||
@@ -153,15 +148,11 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
const action = this.actions.get(receiveData.action as any);
|
||||
if (!action) {
|
||||
this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的Api ' + receiveData.action);
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('不支持的Api ' + receiveData.action, 1404, echo));
|
||||
this.checkStateAndReply<unknown>(OB11Response.error('不支持的Api ' + receiveData.action, 1404, echo));
|
||||
return;
|
||||
}
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) });
|
||||
}
|
||||
});
|
||||
await this.checkStateAndReply<unknown>({ ...retdata });
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config);
|
||||
this.checkStateAndReply<unknown>({ ...retdata });
|
||||
}
|
||||
async reload(newConfig: WebsocketClientConfig) {
|
||||
const wasEnabled = this.isEnable;
|
||||
|
||||
@@ -83,25 +83,17 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
}
|
||||
connectEvent(core: NapCatCore, wsClient: WebSocket) {
|
||||
try {
|
||||
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient).catch(e => this.logger.logError('[OneBot] [WebSocket Server] 发送生命周期失败', e));
|
||||
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient);
|
||||
} catch (e) {
|
||||
this.logger.logError('[OneBot] [WebSocket Server] 发送生命周期失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
let promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,15 +160,10 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkStateAndReply<T>(data: T, wsClient: WebSocket) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(data));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
private checkStateAndReply<T>(data: T, wsClient: WebSocket) {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(wsClient: WebSocket, message: RawData) {
|
||||
@@ -188,7 +175,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
echo = receiveData.echo;
|
||||
//this.logger.logDebug('收到正向Websocket消息', receiveData);
|
||||
} catch {
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
|
||||
this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
|
||||
return;
|
||||
}
|
||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};//兼容类型验证 不然类型校验爆炸
|
||||
@@ -196,15 +183,11 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
const action = this.actions.get(receiveData.action as any);
|
||||
if (!action) {
|
||||
this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的API ' + receiveData.action);
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient);
|
||||
this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient);
|
||||
return;
|
||||
}
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient);
|
||||
}
|
||||
});
|
||||
await this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config);
|
||||
this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
|
||||
}
|
||||
|
||||
async reload(newConfig: WebsocketServerConfig) {
|
||||
|
||||
@@ -46,7 +46,6 @@ export interface OB11Return<DataType> {
|
||||
message: string;
|
||||
echo?: unknown; // ws调用api才有此字段
|
||||
wording?: string; // go-cqhttp字段,错误信息
|
||||
stream?: 'stream-action' | 'normal-action' ; // 流式返回标记
|
||||
}
|
||||
|
||||
// 消息数据类型枚举
|
||||
|
||||
@@ -67,11 +67,11 @@ export class WindowsPtyAgent {
|
||||
}
|
||||
if (this._useConpty) {
|
||||
if (!conptyNative) {
|
||||
conptyNative = require_dlopen('./native/pty/' + process.platform + '.' + process.arch + '/conpty.node');
|
||||
conptyNative = require_dlopen('./pty/' + process.platform + '.' + process.arch + '/conpty.node');
|
||||
}
|
||||
} else {
|
||||
if (!winptyNative) {
|
||||
winptyNative = require_dlopen('./native/pty/' + process.platform + '.' + process.arch + '/pty.node');
|
||||
winptyNative = require_dlopen('./pty/' + process.platform + '.' + process.arch + '/pty.node');
|
||||
}
|
||||
}
|
||||
this._ptyNative = this._useConpty ? conptyNative : winptyNative;
|
||||
@@ -203,13 +203,7 @@ export class WindowsPtyAgent {
|
||||
}
|
||||
|
||||
private _getWindowsBuildNumber(): number {
|
||||
const release = os.release();
|
||||
// Limit input length to prevent potential DoS attacks
|
||||
if (release.length > 50) {
|
||||
return 0;
|
||||
}
|
||||
// Use non-global regex with more specific pattern to prevent backtracking
|
||||
const osVersion = /^(\d{1,5})\.(\d{1,5})\.(\d{1,10})/.exec(release);
|
||||
const osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release());
|
||||
let buildNumber: number = 0;
|
||||
if (osVersion && osVersion.length === 4) {
|
||||
buildNumber = parseInt(osVersion[3]!);
|
||||
|
||||
@@ -31,9 +31,9 @@ import { WebUiDataRuntime } from '@/webui/src/helper/Data';
|
||||
import { napCatVersion } from '@/common/version';
|
||||
import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener';
|
||||
import { sleep } from '@/common/helper';
|
||||
import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg';
|
||||
import { FFmpegService } from '@/common/ffmpeg';
|
||||
import { connectToNamedPipe } from '@/shell/pipe';
|
||||
import { NativePacketHandler } from '@/core/packet/handler/client';
|
||||
// NapCat Shell App ES 入口文件
|
||||
async function handleUncaughtExceptions(logger: LogWrapper) {
|
||||
process.on('uncaughtException', (err) => {
|
||||
@@ -313,19 +313,18 @@ export async function NCoreInitShell() {
|
||||
const pathWrapper = new NapCatPathWrapper();
|
||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||
handleUncaughtExceptions(logger);
|
||||
|
||||
// 初始化 FFmpeg 服务
|
||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||
|
||||
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||||
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 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());
|
||||
|
||||
const o3Service = wrapper.NodeIO3MiscService.get();
|
||||
o3Service.addO3MiscListener(new NodeIO3MiscListener());
|
||||
@@ -392,7 +391,6 @@ export async function NCoreInitShell() {
|
||||
selfInfo,
|
||||
basicInfoWrapper,
|
||||
pathWrapper,
|
||||
nativePacketHandler
|
||||
).InitNapCat();
|
||||
}
|
||||
|
||||
@@ -409,10 +407,8 @@ export class NapCatShell {
|
||||
selfInfo: SelfInfo,
|
||||
basicInfoWrapper: QQBasicInfoWrapper,
|
||||
pathWrapper: NapCatPathWrapper,
|
||||
packetHandler: NativePacketHandler,
|
||||
) {
|
||||
this.context = {
|
||||
packetHandler,
|
||||
workingEnv: NapCatCoreWorkingEnv.Shell,
|
||||
wrapper,
|
||||
session,
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { randomUUID, randomBytes } from 'node:crypto'
|
||||
import { createServer as createHttpsServer } from 'https';
|
||||
import { LogWrapper } from '@/common/log';
|
||||
import { NapCatPathWrapper } from '@/common/path';
|
||||
import { WebUiConfigWrapper } from '@webapi/helper/config';
|
||||
import { ALLRouter } from '@webapi/router';
|
||||
import { cors } from '@webapi/middleware/cors';
|
||||
import { createUrl, getRandomToken } from '@webapi/utils/url';
|
||||
import { createUrl } from '@webapi/utils/url';
|
||||
import { sendError } from '@webapi/utils/response';
|
||||
import { join } from 'node:path';
|
||||
import { terminalManager } from '@webapi/terminal/terminal_manager';
|
||||
@@ -90,21 +90,24 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
|
||||
let config = await WebUiConfig.GetWebUIConfig();
|
||||
|
||||
// 检查并更新默认密码 - 最高优先级
|
||||
if (config.token === 'napcat' || !config.token) {
|
||||
const randomToken = getRandomToken(8);
|
||||
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
||||
logger.log(`[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码`);
|
||||
if (config.defaultToken || config.token === 'napcat' || !config.token) {
|
||||
const randomToken = randomBytes(6).toString('hex');
|
||||
await WebUiConfig.UpdateWebUIConfig({ token: randomToken, defaultToken: false });
|
||||
logger.log(`[NapCat] [WebUi] 🔐 检测到默认密码,已自动更新为安全密码`);
|
||||
|
||||
// 存储token到全局变量,等待QQ登录成功后发送
|
||||
setPendingTokenToSend(randomToken);
|
||||
logger.log(`[NapCat] [WebUi] 新密码将在QQ登录成功后发送给用户`);
|
||||
logger.log(`[NapCat] [WebUi] 📤 新密码将在QQ登录成功后发送给用户`);
|
||||
|
||||
// 重新获取更新后的配置
|
||||
config = await WebUiConfig.GetWebUIConfig();
|
||||
} else {
|
||||
logger.log(`[NapCat] [WebUi] ✅ 当前使用安全密码`);
|
||||
}
|
||||
|
||||
// 存储启动时的初始token用于鉴权
|
||||
setInitialWebUiToken(config.token);
|
||||
logger.log(`[NapCat] [WebUi] 🔑 已缓存启动时的token用于鉴权,运行时手动修改配置文件密码将不会生效`);
|
||||
|
||||
// 检查是否禁用WebUI
|
||||
if (config.disableWebUI) {
|
||||
@@ -213,7 +216,7 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
|
||||
// ------------启动服务------------
|
||||
server.listen(port, host, async () => {
|
||||
let searchParams = { token: token };
|
||||
logger.log(`[NapCat] [WebUi] WebUi Token: ${token}`);
|
||||
logger.log(`[NapCat] [WebUi] 🔑 token=${token}`);
|
||||
logger.log(
|
||||
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
|
||||
);
|
||||
|
||||
@@ -141,7 +141,7 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
||||
return sendError(res, '旧 token 不匹配');
|
||||
}
|
||||
// 直接更新配置文件中的token,不需要通过WebUiConfig.UpdateToken方法
|
||||
await WebUiConfig.UpdateWebUIConfig({ token: newToken });
|
||||
await WebUiConfig.UpdateWebUIConfig({ token: newToken, defaultToken: false });
|
||||
// 更新内存中的缓存token,使新密码立即生效
|
||||
setInitialWebUiToken(newToken);
|
||||
|
||||
|
||||
@@ -156,6 +156,10 @@ const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): P
|
||||
|
||||
// 获取目录内容
|
||||
export const ListFilesHandler: RequestHandler = async (req, res) => {
|
||||
const webuiToken = await WebUiConfig.GetWebUIConfig();
|
||||
if (webuiToken.defaultToken) {
|
||||
return sendError(res, '默认密码禁止使用');
|
||||
}
|
||||
try {
|
||||
const requestPath = getQueryStringParam(req.query['path']) || (isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/');
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ export const CreateTerminalHandler: RequestHandler = async (req, res) => {
|
||||
if (isMacOS) {
|
||||
return sendError(res, 'MacOS不支持终端');
|
||||
}
|
||||
if ((await WebUiConfig.GetWebUIConfig()).defaultToken) {
|
||||
return sendError(res, '该密码禁止创建终端');
|
||||
}
|
||||
try {
|
||||
const { cols, rows } = req.body;
|
||||
const { id } = terminalManager.createTerminal(cols, rows);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user