Compare commits

..

5 Commits

Author SHA1 Message Date
手瓜一十雪
62b04007bb fix 2025-04-04 13:29:00 +08:00
手瓜一十雪
39d938f603 fix 2025-04-04 13:28:50 +08:00
手瓜一十雪
6f6cffbc67 feat: codec wav 2025-04-04 13:28:28 +08:00
手瓜一十雪
f739c88106 feat 2025-04-04 12:40:25 +08:00
手瓜一十雪
320a23de20 feat: audio 2025-04-04 12:16:56 +08:00
93 changed files with 1706 additions and 3103 deletions

View File

@@ -1,9 +1,8 @@
<img src="https://napneko.github.io/assets/newnewlogo.png" width = "305" height = "411" alt="NapCat" align=right />
<div align="center">
# NapCat
![NapCatQQ](https://socialify.git.ci/NapNeko/NapCatQQ/image?font=Jost&logo=https%3A%2F%2Fnapneko.github.io%2Fassets%2Fnewlogo.png&name=1&owner=1&pattern=Diagonal+Stripes&stargazers=1&theme=Auto)
_Modern protocol-side framework implemented based on NTQQ._
@@ -38,20 +37,17 @@ _Modern protocol-side framework implemented based on NTQQ._
| Docs | [![Github.IO](https://img.shields.io/badge/docs%20on-Github.IO-orange)](https://napneko.github.io/) | [![Cloudflare.Worker](https://img.shields.io/badge/docs%20on-Cloudflare.Worker-black)](https://doc.napneko.icu/) | [![Cloudflare.HKServer](https://img.shields.io/badge/docs%20on-Cloudflare.HKServer-informational)](https://napcat.napneko.icu/) |
|:-:|:-:|:-:|:-:|
| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://napcat.cyou/) | [![NapCat.Wiki](https://img.shields.io/badge/docs%20on-NapCat.Wiki-red)](https://www.napcat.wiki) |
| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://docs.napcat.cyou/) | [![NapCat.Wiki](https://img.shields.io/badge/docs%20on-NapCat.Wiki-red)](https://www.napcat.wiki) |
|:-:|:-:|:-:|:-:|
| QQ Group | [![QQ Group#4](https://img.shields.io/badge/QQ%20Group%234-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#3](https://img.shields.io/badge/QQ%20Group%233-Join-blue)](https://qm.qq.com/q/8zJMLjqy2Y) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/HaRcfrHpUk) | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) |
|:-:|:-:|:-:|:-:|:-:|
| Telegram | [![Telegram](https://img.shields.io/badge/Telegram-MelodicMoonlight-blue)](https://t.me/MelodicMoonlight) |
|:-:|:-:|
| Contact | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/HaRcfrHpUk) | [![Telegram](https://img.shields.io/badge/Telegram-MelodicMoonlight-blue)](https://t.me/MelodicMoonlight) |
|:-:|:-:|:-:|:-:|
## Thanks
+ [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
+ [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
+ [LLOneBot](https://github.com/LLOneBot/LLOneBot) 相关的开发曾参与本项目部分开发
+ 不过最最重要的 还是需要感谢屏幕前的你哦~

Binary file not shown.

BIN
external/logo.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -1,9 +1,9 @@
{
"name": "qq-chat",
"version": "9.9.19-34740",
"verHash": "f31348f2",
"linuxVersion": "3.2.17-34740",
"linuxVerHash": "5aa2d8d6",
"version": "9.9.18-32869",
"verHash": "e735296c",
"linuxVersion": "3.2.16-32869",
"linuxVerHash": "4c192ba9",
"private": true,
"description": "QQ",
"productName": "QQ",
@@ -16,10 +16,27 @@
"bin": {
"qd": "externals/devtools/cli/index.js"
},
"appid": {
"win32": "537258389",
"darwin": "537258412",
"linux": "537258424"
},
"main": "./loadNapCat.js",
"buildVersion": "34740",
"peerDependenciesMeta": {
"*": {
"optional": true
}
},
"pnpm": {
"patchedDependencies": {
"@vue/runtime-dom@3.5.12": "patches/@vue__runtime-dom@3.5.12.patch",
"@swc/helpers@0.5.3": "patches/@swc__helpers@0.5.3.patch",
"vuex@4.1.0": "patches/vuex@4.1.0.patch"
}
},
"buildVersion": "32869",
"isPureShell": true,
"isByteCodeShell": true,
"platform": "win32",
"eleArch": "x64"
}
}

View File

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

View File

@@ -55,7 +55,6 @@
"ahooks": "^3.8.4",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"echarts": "^5.5.1",
"event-source-polyfill": "^1.0.31",
"framer-motion": "^12.0.6",
@@ -89,7 +88,6 @@
"@eslint/js": "^9.19.0",
"@react-types/shared": "^3.26.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/crypto-js": "^4.2.2",
"@types/event-source-polyfill": "^1.0.5",
"@types/fabric": "^5.3.9",
"@types/node": "^22.12.0",

View File

@@ -3,7 +3,7 @@ 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
@@ -17,10 +17,9 @@ export default class WebUIManager {
}
public static async loginWithToken(token: string) {
const sha256 = CryptoJS.SHA256(token + '.napcat').toString();
const { data } = await serverRequest.post<ServerResponse<AuthResponse>>(
'/auth/login',
{ hash: sha256 }
{ token }
)
return data.data.Credential
}

View File

@@ -47,22 +47,6 @@ export default function WebLoginPage() {
}
}
// 处理全局键盘事件
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !isLoading) {
onSubmit()
}
}
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
// 清理函数
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [tokenValue, isLoading]) // 依赖项包含用于登录的状态
useEffect(() => {
if (token) {
onSubmit()

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.7.62",
"version": "4.7.13",
"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",
@@ -21,6 +21,7 @@
"@eslint/compat": "^1.2.2",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0",
"@ffmpeg.wasm/main": "^0.13.1",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
"@log4js-node/log4js-api": "^1.0.2",
"@napneko/nap-proto-core": "^0.0.4",
@@ -31,6 +32,7 @@
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.1",
"@types/node-wav": "^0.0.4",
"@types/on-finished": "^2.3.4",
"@types/qrcode-terminal": "^0.12.2",
"@types/react-color": "^3.0.13",
@@ -43,29 +45,33 @@
"commander": "^13.0.0",
"compressing": "^1.10.1",
"cors": "^2.8.5",
"esbuild": "0.25.4",
"esbuild": "0.25.0",
"eslint": "^9.14.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.29.1",
"express": "npm:@karinjs/express@1.0.3",
"express-rate-limit": "^7.5.0",
"fast-xml-parser": "^4.3.6",
"file-type": "^20.0.0",
"globals": "^16.0.0",
"image-size": "^1.1.1",
"json5": "^2.2.3",
"multer": "^1.4.5-lts.1",
"napcat.protobuf": "^1.1.4",
"on-finished": "^2.4.1",
"silk-wasm": "^3.6.1",
"typescript": "^5.3.3",
"typescript-eslint": "^8.13.0",
"vite": "^6.0.1",
"vite-plugin-cp": "^6.0.0",
"vite-plugin-wasm": "^3.4.1",
"vite-plugin-cp": "^4.0.8",
"vite-tsconfig-paths": "^5.1.0",
"winston": "^3.17.0",
"ws": "npm:@karinjs/ws@1.2.0"
"winston": "^3.17.0"
},
"dependencies": {
"type-is": "^2.0.1"
"@breezystack/lamejs": "^1.2.7",
"@ffmpeg.wasm/core-mt": "^0.13.2",
"audio-decode": "^2.2.2",
"express": "^5.0.0",
"node-wav": "^0.0.2",
"silk-wasm": "^3.6.1",
"wavefile": "^11.0.0",
"ws": "^8.18.0"
}
}

View File

@@ -0,0 +1,145 @@
// WAV 文件头结构
interface WavHeader {
riffChunkId: string; // "RIFF"
riffChunkSize: number; // 文件大小 - 8
riffFormat: string; // "WAVE"
fmtChunkId: string; // "fmt "
fmtChunkSize: number; // 16
audioFormat: number; // 1 = PCM
numChannels: number; // 声道数
sampleRate: number; // 采样率
byteRate: number; // 字节率 (SampleRate * NumChannels * BitsPerSample / 8)
blockAlign: number; // 块对齐 (NumChannels * BitsPerSample / 8)
bitsPerSample: number; // 采样位数
dataChunkId: string; // "data"
dataChunkSize: number; // 音频数据大小
}
export class WavEncoder {
private header: WavHeader;
private data: Buffer;
private dataOffset: number;
public bitsPerSample: number;
constructor(sampleRate: number, numChannels: number, bitsPerSample: number) {
if (![8, 16, 24, 32].includes(bitsPerSample)) {
throw new Error("Unsupported bitsPerSample value. Must be 8, 16, 24, or 32.");
}
this.bitsPerSample = bitsPerSample;
this.header = {
riffChunkId: "RIFF",
riffChunkSize: 0, // 待计算
riffFormat: "WAVE",
fmtChunkId: "fmt ",
fmtChunkSize: 16,
audioFormat: 1, // PCM
numChannels: numChannels,
sampleRate: sampleRate,
byteRate: sampleRate * numChannels * bitsPerSample / 8,
blockAlign: numChannels * bitsPerSample / 8,
bitsPerSample: bitsPerSample,
dataChunkId: "data",
dataChunkSize: 0 // 待计算
};
this.data = Buffer.alloc(0);
this.dataOffset = 0;
}
public write(buffer: Buffer): void {
this.data = Buffer.concat([this.data, buffer]);
this.dataOffset += buffer.length;
}
public encode(): Buffer {
this.header.dataChunkSize = this.dataOffset;
this.header.riffChunkSize = 36 + this.dataOffset;
const headerBuffer = Buffer.alloc(44);
headerBuffer.write(this.header.riffChunkId, 0, 4, 'ascii');
headerBuffer.writeUInt32LE(this.header.riffChunkSize, 4);
headerBuffer.write(this.header.riffFormat, 8, 4, 'ascii');
headerBuffer.write(this.header.fmtChunkId, 12, 4, 'ascii');
headerBuffer.writeUInt32LE(this.header.fmtChunkSize, 16);
headerBuffer.writeUInt16LE(this.header.audioFormat, 20);
headerBuffer.writeUInt16LE(this.header.numChannels, 22);
headerBuffer.writeUInt32LE(this.header.sampleRate, 24);
headerBuffer.writeUInt32LE(this.header.byteRate, 28);
headerBuffer.writeUInt16LE(this.header.blockAlign, 32);
headerBuffer.writeUInt16LE(this.header.bitsPerSample, 34);
headerBuffer.write(this.header.dataChunkId, 36, 4, 'ascii');
headerBuffer.writeUInt32LE(this.header.dataChunkSize, 40);
return Buffer.concat([headerBuffer, this.data]);
}
}
export class WavDecoder {
private header: WavHeader;
private data: Buffer;
private dataOffset: number;
public bitsPerSample: number;
constructor(private buffer: Buffer) {
this.header = {
riffChunkId: "",
riffChunkSize: 0,
riffFormat: "",
fmtChunkId: "",
fmtChunkSize: 0,
audioFormat: 0,
numChannels: 0,
sampleRate: 0,
byteRate: 0,
blockAlign: 0,
bitsPerSample: 0,
dataChunkId: "",
dataChunkSize: 0
};
this.data = Buffer.alloc(0);
this.dataOffset = 0;
this.decodeHeader();
this.decodeData();
this.bitsPerSample = this.header.bitsPerSample;
}
private decodeHeader(): void {
this.header.riffChunkId = this.buffer.toString('ascii', 0, 4);
this.header.riffChunkSize = this.buffer.readUInt32LE(4);
this.header.riffFormat = this.buffer.toString('ascii', 8, 4);
this.header.fmtChunkId = this.buffer.toString('ascii', 12, 4);
this.header.fmtChunkSize = this.buffer.readUInt32LE(16);
this.header.audioFormat = this.buffer.readUInt16LE(20);
this.header.numChannels = this.buffer.readUInt16LE(22);
this.header.sampleRate = this.buffer.readUInt32LE(24);
this.header.byteRate = this.buffer.readUInt32LE(28);
this.header.blockAlign = this.buffer.readUInt16LE(32);
this.header.bitsPerSample = this.buffer.readUInt16LE(34);
this.header.dataChunkId = this.buffer.toString('ascii', 36, 4);
this.header.dataChunkSize = this.buffer.readUInt32LE(40);
this.dataOffset = 44;
// 可以在此处添加对 header 值的校验
if (this.header.riffChunkId !== "RIFF" || this.header.riffFormat !== "WAVE") {
throw new Error("Invalid WAV file format.");
}
if (![8, 16, 24, 32].includes(this.header.bitsPerSample)) {
throw new Error(`Unsupported bitsPerSample: ${this.header.bitsPerSample}`);
}
}
private decodeData(): void {
this.data = this.buffer.slice(this.dataOffset, this.dataOffset + this.header.dataChunkSize);
}
public getHeader(): WavHeader {
return this.header;
}
public getData(): Buffer {
return this.data;
}
}

814
src/common/audio-pure.ts Normal file
View File

@@ -0,0 +1,814 @@
/**
* 现代音频格式转换库 - 使用纯JavaScript实现的音频格式转换工具
* 支持格式: MP3, WAV, FLAC, OGG, OPUS, AMR, M4A和PCM等格式间的转换
* PCM格式支持8位、16位和32位采样深度
*
* 特点:
* - 纯JavaScript/TypeScript实现无需外部依赖如FFmpeg
* - 完全支持Web和Node.js环境
* - 强类型定义和现代化错误处理
* - 高性能实现,支持流式处理
*/
import { readFile, writeFile } from 'fs/promises';
import path from 'path';
import audioDecode from 'audio-decode'; // 解码 WAV MP3 OGG FLAC
import { Mp3Encoder } from '@breezystack/lamejs'; // 编码 MP3
import { WavEncoder, WavDecoder } from './audio-enhance/codec/wav'; // 导入 WavEncoder 和 WavDecoder
// import { Encoder as FlacEncoder } from 'libflacjs/lib/encoder'; // 编码 FLAC
// import * as Flac from 'libflacjs'; // 编码 FLAC
// import { Muxer } from 'mp4-muxer'; // 替换demux用于编码 AAC/M4A
/* ============================================================================
类型与接口定义
============================================================================ */
/**
* 音频处理错误类 - 提供丰富的错误上下文
*/
export class AudioError extends Error {
constructor(
message: string,
public readonly step: 'decode' | 'encode' | 'convert' | 'validate',
public readonly format?: string,
public readonly cause?: Error
) {
super(message);
this.name = 'AudioError';
// 捕获原始错误堆栈
if (cause && cause.stack) {
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
}
}
}
/** 解码后的PCM数据及相关音频信息 */
export interface PCMData {
/** PCM样本数据统一使用Float32Array表示 */
samples: Float32Array;
/** 采样率Hz) */
sampleRate: number;
/** 声道数 */
channels: number;
/** 音频元数据 (可选) */
metadata?: AudioMetadata;
}
/** 音频元数据 */
export interface AudioMetadata {
title?: string;
artist?: string;
album?: string;
year?: number;
genre?: string;
duration?: number; // 秒
[key: string]: any; // 允许其他元数据
}
/** 音频转换选项 */
export interface ConvertOptions {
/** 目标采样率 */
sampleRate?: number;
/** 目标声道数 */
channels?: number;
/** PCM位深度 (8, 16 或 32) */
bitDepth?: 8 | 16 | 32;
/** 编码比特率 (kbps) */
bitrate?: number;
/** 编码质量 (0-1) */
quality?: number;
/** 保留元数据 */
preserveMetadata?: boolean;
/** 使用Web Worker (仅浏览器环境) */
useWorker?: boolean;
}
/**
* 音频编解码器接口
*/
interface Codec {
/** 编解码器名称 */
readonly name: string;
/** 支持的文件扩展名 */
readonly extensions: string[];
/** 检查是否支持指定格式 */
supports(format: string): boolean;
/** 解码音频数据为PCM */
decode(buffer: Buffer, options?: ConvertOptions): Promise<PCMData>;
/** 编码PCM数据为目标格式 */
encode(pcmData: PCMData, options?: ConvertOptions): Promise<Buffer>;
}
/**
* PCM数据处理工具
*/
class AudioProcessor {
/**
* 将Float32Array PCM数据转换为指定位深度的Buffer
*/
static floatToPCM(samples: Float32Array, bitDepth: number): Buffer {
const bytesPerSample = bitDepth / 8;
const buffer = Buffer.alloc(samples.length * bytesPerSample);
if (bitDepth === 8) {
for (let i = 0; i < samples.length; i++) {
// 将[-1,1]映射到[0,255]
const sample = Math.max(-1, Math.min(1, samples[i]!));
buffer[i] = (sample * 0.5 + 0.5) * 255;
}
} else if (bitDepth === 16) {
for (let i = 0; i < samples.length; i++) {
// 将[-1,1]映射到[-32768,32767]
const sample = Math.max(-1, Math.min(1, samples[i]!));
const val = sample < 0 ? sample * 32768 : sample * 32767;
buffer.writeInt16LE(Math.floor(val), i * 2);
}
} else if (bitDepth === 32) {
for (let i = 0; i < samples.length; i++) {
// 将[-1,1]映射到[-2147483648,2147483647]
const sample = Math.max(-1, Math.min(1, samples[i]!));
const val = sample < 0 ? sample * 2147483648 : sample * 2147483647;
buffer.writeInt32LE(Math.floor(val), i * 4);
}
} else {
throw new AudioError(`不支持的PCM位深度: ${bitDepth}`, 'encode', 'pcm');
}
return buffer;
}
/**
* 将指定位深度的PCM Buffer转换为Float32Array
*/
static pcmToFloat(buffer: Buffer, bitDepth: number): Float32Array {
const samples = new Float32Array(buffer.length / (bitDepth / 8));
if (bitDepth === 8) {
for (let i = 0; i < samples.length; i++) {
// 将[0,255]映射回[-1,1]
samples[i] = (buffer[i]! / 255) * 2 - 1;
}
} else if (bitDepth === 16) {
for (let i = 0; i < samples.length; i++) {
const val = buffer.readInt16LE(i * 2);
// 将[-32768,32767]映射回[-1,1]
samples[i] = val < 0 ? val / 32768 : val / 32767;
}
} else if (bitDepth === 32) {
for (let i = 0; i < samples.length; i++) {
const val = buffer.readInt32LE(i * 4);
// 将[-2147483648,2147483647]映射回[-1,1]
samples[i] = val < 0 ? val / 2147483648 : val / 2147483647;
}
} else {
throw new AudioError(`不支持的PCM位深度: ${bitDepth}`, 'decode', 'pcm');
}
return samples;
}
/**
* 重采样PCM数据
*/
static resample(samples: Float32Array, fromRate: number, toRate: number, channels: number): Float32Array {
if (fromRate === toRate) return samples;
const ratio = toRate / fromRate;
const inputLength = samples.length;
const outputLength = Math.ceil(inputLength * ratio);
const result = new Float32Array(outputLength);
// 线性插值重采样
for (let i = 0; i < outputLength; i++) {
const pos = i / ratio;
const leftPos = Math.floor(pos);
const rightPos = Math.min(leftPos + 1, inputLength - 1);
const fraction = pos - leftPos;
// 对每个通道分别进行插值
for (let channel = 0; channel < channels; channel++) {
const leftIdx = leftPos * channels + channel;
const rightIdx = rightPos * channels + channel;
const leftSample = samples[leftIdx] || 0;
const rightSample = samples[rightIdx] || 0;
// 线性插值
result[i * channels + channel] = leftSample + fraction * (rightSample - leftSample);
}
}
return result;
}
/**
* 混合声道 (多声道到单声道或立体声)
*/
static mixChannels(samples: Float32Array, fromChannels: number, toChannels: number): Float32Array {
if (fromChannels === toChannels) return samples;
const frameCount = samples.length / fromChannels;
const result = new Float32Array(frameCount * toChannels);
if (fromChannels === 1 && toChannels === 2) {
// 单声道到立体声 - 复制到两个声道
for (let i = 0; i < frameCount; i++) {
const sample = samples[i]!;
result[i * 2] = sample; // 左声道
result[i * 2 + 1] = sample; // 右声道
}
} else if (fromChannels === 2 && toChannels === 1) {
// 立体声到单声道 - 取平均值
for (let i = 0; i < frameCount; i++) {
const left = samples[i * 2];
const right = samples[i * 2 + 1];
result[i] = (left! + right!) / 2;
}
} else if (fromChannels > toChannels) {
// 多声道到少声道 - 根据需要混合
for (let i = 0; i < frameCount; i++) {
for (let c = 0; c < toChannels; c++) {
// 根据toChannel位置映射到fromChannel
let sum = 0;
let count = 0;
for (let fc = c; fc < fromChannels; fc += toChannels) {
sum += samples[i * fromChannels + fc]!;
count++;
}
result[i * toChannels + c] = sum / count;
}
}
} else {
// 少声道到多声道 - 根据需要复制
for (let i = 0; i < frameCount; i++) {
for (let c = 0; c < toChannels; c++) {
// 循环复制
const fromChannel = c % fromChannels;
result[i * toChannels + c] = samples[i * fromChannels + fromChannel]!;
}
}
}
return result;
}
/**
* 处理PCM数据包括重采样和声道转换
*/
static processPCM(pcmData: PCMData, options?: ConvertOptions): PCMData {
const targetSampleRate = options?.sampleRate ?? pcmData.sampleRate;
const targetChannels = options?.channels ?? pcmData.channels;
let processedSamples = pcmData.samples;
// 如果需要重采样
console.log(`重采样: ${pcmData.sampleRate}Hz → ${targetSampleRate}Hz`);
if (pcmData.sampleRate !== targetSampleRate) {
processedSamples = this.resample(
processedSamples,
pcmData.sampleRate,
targetSampleRate,
pcmData.channels
);
}
// 如果需要改变声道数
if (pcmData.channels !== targetChannels) {
processedSamples = this.mixChannels(
processedSamples,
pcmData.channels,
targetChannels
);
}
return {
samples: processedSamples,
sampleRate: targetSampleRate,
channels: targetChannels,
metadata: options?.preserveMetadata ? pcmData.metadata : undefined
};
}
/**
* 从Buffer中提取音频元数据
*/
static extractMetadata(data: any): AudioMetadata | undefined {
if (!data) return undefined;
return {
title: data.title || data.TITLE,
artist: data.artist || data.ARTIST || data.performer,
album: data.album || data.ALBUM,
year: data.year ? parseInt(data.year) : (data.date ? parseInt(data.date) : undefined),
genre: data.genre || data.GENRE,
duration: data.duration
};
}
/**
* 将交织的PCM数据分离为各声道数据
*/
static deinterleaveChannels(samples: Float32Array | Int16Array, channels: number): Array<Float32Array | Int16Array> {
const frameCount = samples.length / channels;
const result = new Array(channels);
// 创建每个声道的数组
for (let c = 0; c < channels; c++) {
result[c] = new (samples.constructor as any)(frameCount);
}
// 分离声道数据
for (let i = 0; i < frameCount; i++) {
for (let c = 0; c < channels; c++) {
result[c][i] = samples[i * channels + c];
}
}
return result;
}
/**
* 将Float32Array转换为Int16Array
*/
static floatToInt16(samples: Float32Array): Int16Array {
const int16Samples = new Int16Array(samples.length);
for (let i = 0; i < samples.length; i++) {
const sample = Math.max(-1, Math.min(1, samples[i]!));
int16Samples[i] = Math.round(sample < 0 ? sample * 32768 : sample * 32767);
}
return int16Samples;
}
}
/* ============================================================================
编解码器实现
============================================================================ */
/**
* 通用音频解码器 - 使用audio-decode库处理多种格式
*/
class GenericDecoder {
/**
* 使用audio-decode解码多种格式
*/
static async decode(buffer: Buffer, _options?: ConvertOptions): Promise<PCMData> {
try {
// 使用audio-decode解码音频
const audioData = await audioDecode(buffer);
return {
samples: this.interleaveSamples(audioData),
sampleRate: audioData.sampleRate,
channels: audioData.numberOfChannels,
metadata: AudioProcessor.extractMetadata({})
};
} catch (error: any) {
throw new AudioError(
`音频解码错误: ${error.message}`,
'decode',
'audio',
error
);
}
}
/**
* 将多声道音频数据交织成单个Float32Array
*/
private static interleaveSamples(audioData: AudioBuffer): Float32Array {
const channels = audioData.numberOfChannels;
const length = audioData.length;
const result = new Float32Array(length * channels);
for (let c = 0; c < channels; c++) {
const channelData = audioData.getChannelData(c);
for (let i = 0; i < length; i++) {
result[i * channels + c] = channelData[i]!;
}
}
return result;
}
}
/**
* 基础编解码器类 - 提供通用实现
*/
abstract class BaseCodec implements Codec {
abstract readonly name: string;
abstract readonly extensions: string[];
supports(format: string): boolean {
return this.extensions.includes(format.toLowerCase());
}
async decode(buffer: Buffer, options?: ConvertOptions): Promise<PCMData> {
return GenericDecoder.decode(buffer, options);
}
abstract encode(pcmData: PCMData, options?: ConvertOptions): Promise<Buffer>;
}
/**
* MP3编解码器
*/
class MP3Codec extends BaseCodec {
readonly name = 'MP3 Codec';
readonly extensions = ['mp3'];
async encode(pcmData: PCMData, options?: ConvertOptions): Promise<Buffer> {
try {
const processed = AudioProcessor.processPCM(pcmData, options);
const bitrate = options?.bitrate ?? 128;
// 创建MP3编码器
const encoder = new Mp3Encoder(
processed.channels,
processed.sampleRate,
bitrate
);
// 将Float32Array转换为Int16Array (lamejs需要)
const samples = AudioProcessor.floatToPCM(processed.samples, 16);
const int16Samples = new Int16Array(samples.buffer, samples.byteOffset, samples.length / 2);
const mp3Data: Uint8Array[] = [];
const sampleBlockSize = 1152; // MP3编码的标准帧大小
// 分块处理,避免内存占用过大
for (let i = 0; i < int16Samples.length; i += sampleBlockSize) {
const chunk = int16Samples.subarray(i, i + sampleBlockSize);
const mp3buf = encoder.encodeBuffer(chunk);
if (mp3buf.length > 0) {
mp3Data.push(new Uint8Array(mp3buf));
}
}
// 完成编码,获取最后一块数据
const finalChunk = encoder.flush();
if (finalChunk.length > 0) {
mp3Data.push(new Uint8Array(finalChunk));
}
// 合并所有MP3数据块
const totalLength = mp3Data.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of mp3Data) {
result.set(arr, offset);
offset += arr.length;
}
return Buffer.from(result);
} catch (error: any) {
throw new AudioError(`MP3编码错误: ${error.message}`, 'encode', 'mp3', error);
}
}
}
/**
* WAV编解码器
*/
class WAVCodec extends BaseCodec {
readonly name = 'WAV Codec';
readonly extensions = ['wav'];
override async decode(buffer: Buffer, options?: ConvertOptions): Promise<PCMData> {
try {
const decoder = new WavDecoder(buffer);
const header = decoder.getHeader();
const data = decoder.getData();
const sampleRate = header.sampleRate;
const channels = header.numChannels;
const bitsPerSample = header.bitsPerSample;
// 将Buffer转换为Float32Array
let samples: Float32Array;
if (bitsPerSample === 8 || bitsPerSample === 16 || bitsPerSample === 32) {
samples = AudioProcessor.pcmToFloat(data, bitsPerSample);
} else {
throw new AudioError(`不支持的WAV位深: ${bitsPerSample}`, 'decode', 'wav');
}
return {
samples,
sampleRate,
channels,
metadata: undefined
};
} catch (error: any) {
// WAV解析失败尝试使用通用解码器
return GenericDecoder.decode(buffer, options);
}
}
async encode(pcmData: PCMData, options?: ConvertOptions): Promise<Buffer> {
try {
const processed = AudioProcessor.processPCM(pcmData, options);
const bitDepth = options?.bitDepth ?? 16;
const encoder = new WavEncoder(processed.sampleRate, processed.channels, bitDepth);
// 将Float32Array转换为指定位深度的Buffer
const pcmBuffer = AudioProcessor.floatToPCM(processed.samples, bitDepth);
encoder.write(pcmBuffer);
return encoder.encode();
} catch (error: any) {
throw new AudioError(`WAV编码错误: ${error.message}`, 'encode', 'wav', error);
}
}
}
/**
* OGG Vorbis编解码器
*/
class OGGCodec extends BaseCodec {
readonly name = 'OGG Vorbis Codec';
readonly extensions = ['ogg'];
async encode(pcmData: PCMData, options?: ConvertOptions): Promise<Buffer> {
try {
// 注意这里应该使用专门的OGG Vorbis编码库但为了保持库的纯JavaScript特性
// 我们可以使用一个基于Web Audio API的解决方案或找一个纯JS的OGG编码器
// 由于pure-JS的OGG编码器较为复杂这里提供一个简化的实现
// 在实际应用中应使用专门的库如ogg-vorbis-encoder-js
const processed = AudioProcessor.processPCM(pcmData, options);
// 如果后续需要OGG编码功能请添加合适的库
// 这里返回一个模拟实现
return this.createMockOggFile(processed, options);
} catch (error: any) {
throw new AudioError(`OGG编码错误: ${error.message}`, 'encode', 'ogg', error);
}
}
// 创建模拟的OGG文件仅作示例实际应用中请替换为真实OGG编码
private createMockOggFile(pcmData: PCMData, options?: ConvertOptions): Buffer {
const quality = options?.quality ?? 0.5;
// 创建基本的OGG头部
const header = Buffer.alloc(100);
header.write('OggS', 0);
header.writeUInt8(0, 4); // 版本
header.writeUInt8(pcmData.channels, 5);
header.writeUInt32LE(pcmData.sampleRate, 6);
header.writeUInt8(Math.floor(quality * 10), 10);
// 对音频数据进行简单的处理(仅作示例)
const samplesBuffer = AudioProcessor.floatToPCM(pcmData.samples, 16);
// 组合头部和数据
return Buffer.concat([header, samplesBuffer]);
}
}
/**
* PCM编解码器
*/
class PCMCodec implements Codec {
readonly name = 'PCM Codec';
readonly extensions = ['pcm'];
supports(format: string): boolean {
return this.extensions.includes(format.toLowerCase());
}
async decode(buffer: Buffer, options?: ConvertOptions): Promise<PCMData> {
try {
const bitDepth = options?.bitDepth ?? 16;
// 验证位深度是否受支持
if (![8, 16, 32].includes(bitDepth)) {
throw new AudioError(`不支持的PCM位深: ${bitDepth}`, 'decode', 'pcm');
}
// 获取采样率和声道数 (PCM文件本身不包含这些信息使用默认值或用户提供的值)
const sampleRate = options?.sampleRate ?? 44100;
const channels = options?.channels ?? 2;
// 将PCM数据转换为Float32Array
const samples = AudioProcessor.pcmToFloat(buffer, bitDepth);
return {
samples,
sampleRate,
channels
};
} catch (error: any) {
if (error instanceof AudioError) throw error;
throw new AudioError(`PCM解码错误: ${error.message}`, 'decode', 'pcm', error);
}
}
async encode(pcmData: PCMData, options?: ConvertOptions): Promise<Buffer> {
try {
const processed = AudioProcessor.processPCM(pcmData, options);
const bitDepth = options?.bitDepth ?? 16;
// 验证位深度是否受支持
if (![8, 16, 32].includes(bitDepth)) {
throw new AudioError(`不支持的PCM位深: ${bitDepth}`, 'encode', 'pcm');
}
// 将Float32Array转换为指定位深度的PCM数据
return AudioProcessor.floatToPCM(processed.samples, bitDepth);
} catch (error: any) {
if (error instanceof AudioError) throw error;
throw new AudioError(`PCM编码错误: ${error.message}`, 'encode', 'pcm', error);
}
}
}
/**
* 编解码器注册表
*/
class CodecRegistry {
private static codecs: Map<string, Codec> = new Map();
private static initialized = false;
/**
* 初始化编解码器注册表
*/
static init(): void {
if (this.initialized) return;
// 注册所有支持的编解码器
this.register(new MP3Codec());
this.register(new WAVCodec());
this.register(new OGGCodec());
this.register(new PCMCodec());
this.initialized = true;
}
/**
* 注册编解码器
*/
static register(codec: Codec): void {
codec.extensions.forEach(ext => this.codecs.set(ext.toLowerCase(), codec));
}
/**
* 获取指定格式的编解码器
*/
static getCodec(format: string): Codec {
this.init();
const codec = this.codecs.get(format.toLowerCase());
if (!codec) {
throw new AudioError(`不支持的音频格式: ${format}`, 'validate', format);
}
return codec;
}
/**
* 获取所有支持的格式
*/
static getSupportedFormats(): string[] {
this.init();
return [...new Set(this.codecs.keys())];
}
}
/**
* 转换音频文件格式
*
* @param inputPath 输入文件路径
* @param outputPath 输出文件路径
* @param targetFormat 目标格式
* @param options 转换选项
*/
export async function convertAudio(
inputPath: string,
outputPath: string,
targetFormat: string,
options?: ConvertOptions
): Promise<void> {
// 初始化编解码器注册表
CodecRegistry.init();
// 提取文件扩展名
const inputExt = path.extname(inputPath).slice(1).toLowerCase();
// 验证格式支持
if (!CodecRegistry.getSupportedFormats().includes(inputExt)) {
throw new AudioError(`不支持的输入格式: ${inputExt}`, 'validate', inputExt);
}
if (!CodecRegistry.getSupportedFormats().includes(targetFormat)) {
throw new AudioError(`不支持的目标格式: ${targetFormat}`, 'validate', targetFormat);
}
try {
// 读取输入文件
const inputBuffer = await readFile(inputPath);
// 解码为PCM
const inputCodec = CodecRegistry.getCodec(inputExt);
const decoded = await inputCodec.decode(inputBuffer, options);
// 编码为目标格式
const outputCodec = CodecRegistry.getCodec(targetFormat);
const outputBuffer = await outputCodec.encode(decoded, options);
// 写入输出文件
await writeFile(outputPath, outputBuffer);
console.log(
`转换完成: ${inputExt}${targetFormat}, ` +
`保存到 ${outputPath} ` +
`(${(outputBuffer.length / 1024).toFixed(2)} KB)`
);
} catch (error: any) {
// 统一错误处理
if (error instanceof AudioError) {
throw error;
}
throw new AudioError(
`音频转换失败: ${error.message}`,
'convert',
`${inputExt}->${targetFormat}`,
error
);
}
}
/**
* 从二进制数据转换音频格式
*
* @param inputBuffer 输入音频数据
* @param inputFormat 输入格式
* @param outputFormat 输出格式
* @param options 转换选项
* @returns 转换后的音频数据
*/
export async function convertAudioBuffer(
inputBuffer: Buffer,
inputFormat: string,
outputFormat: string,
options?: ConvertOptions
): Promise<Buffer> {
// 初始化编解码器注册表
CodecRegistry.init();
// 验证格式支持
if (!CodecRegistry.getSupportedFormats().includes(inputFormat)) {
throw new AudioError(`不支持的输入格式: ${inputFormat}`, 'validate', inputFormat);
}
if (!CodecRegistry.getSupportedFormats().includes(outputFormat)) {
throw new AudioError(`不支持的目标格式: ${outputFormat}`, 'validate', outputFormat);
}
try {
// 解码为PCM
const inputCodec = CodecRegistry.getCodec(inputFormat);
const decoded = await inputCodec.decode(inputBuffer, options);
// 编码为目标格式
const outputCodec = CodecRegistry.getCodec(outputFormat);
return await outputCodec.encode(decoded, options);
} catch (error: any) {
// 统一错误处理
if (error instanceof AudioError) {
throw error;
}
throw new AudioError(
`音频转换失败: ${error.message}`,
'convert',
`${inputFormat}->${outputFormat}`,
error
);
}
}
/**
* 创建音频转换函数
*
* @param inputFormat 输入格式
* @param outputFormat 输出格式
* @param options 转换选项
*/
export async function createAudioConverter(
inputFormat: string,
outputFormat: string,
options?: ConvertOptions
): Promise<(inputBuffer: Buffer) => Promise<Buffer>> {
// 初始化编解码器
CodecRegistry.init();
// 验证格式支持
const inputCodec = CodecRegistry.getCodec(inputFormat);
const outputCodec = CodecRegistry.getCodec(outputFormat);
// 返回转换函数
return async (inputBuffer: Buffer): Promise<Buffer> => {
const decoded = await inputCodec.decode(inputBuffer, options);
return outputCodec.encode(decoded, options);
};
}

View File

@@ -1,360 +0,0 @@
// 更正导入语句
import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import * as os from 'os';
import * as compressing from 'compressing'; // 修正导入方式
import { pipeline } from 'stream/promises';
import { fileURLToPath } from 'url';
import { LogWrapper } from './log';
const downloadOri = "https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip"
const urls = [
"https://github.moeyy.xyz/" + downloadOri,
"https://ghp.ci/" + downloadOri,
"https://gh.api.99988866.xyz/" + downloadOri,
"https://gh.api.99988866.xyz/" + downloadOri,
downloadOri
];
/**
* 测试URL是否可用
* @param url 待测试的URL
* @returns 如果URL可访问返回true否则返回false
*/
async function testUrl(url: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const req = https.get(url, { timeout: 5000 }, (res) => {
// 检查状态码是否表示成功
const statusCode = res.statusCode || 0;
if (statusCode >= 200 && statusCode < 300) {
// 终止请求并返回true
req.destroy();
resolve(true);
} else {
req.destroy();
resolve(false);
}
});
req.on('error', () => {
resolve(false);
});
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
/**
* 查找第一个可用的URL
* @returns 返回第一个可用的URL如果都不可用则返回null
*/
async function findAvailableUrl(): Promise<string | null> {
for (const url of urls) {
try {
const available = await testUrl(url);
if (available) {
return url;
}
} catch (error) {
// 忽略错误
}
}
return null;
}
/**
* 下载文件
* @param url 下载URL
* @param destPath 目标保存路径
* @returns 成功返回true失败返回false
*/
async function downloadFile(url: string, destPath: string, progressCallback?: (percent: number) => void): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const file = fs.createWriteStream(destPath);
const req = https.get(url, (res) => {
const statusCode = res.statusCode || 0;
if (statusCode >= 200 && statusCode < 300) {
// 获取文件总大小
const totalSize = parseInt(res.headers['content-length'] || '0', 10);
let downloadedSize = 0;
let lastReportedPercent = -1; // 上次报告的百分比
let lastReportTime = 0; // 上次报告的时间戳
// 如果有内容长度和进度回调,则添加数据监听
if (totalSize > 0 && progressCallback) {
// 初始报告 0%
progressCallback(0);
lastReportTime = Date.now();
res.on('data', (chunk) => {
downloadedSize += chunk.length;
const currentPercent = Math.floor((downloadedSize / totalSize) * 100);
const now = Date.now();
// 只在以下条件触发回调:
// 1. 百分比变化至少为1%
// 2. 距离上次报告至少500毫秒
// 3. 确保报告100%完成
if ((currentPercent !== lastReportedPercent &&
(currentPercent - lastReportedPercent >= 1 || currentPercent === 100)) &&
(now - lastReportTime >= 1000 || currentPercent === 100)) {
progressCallback(currentPercent);
lastReportedPercent = currentPercent;
lastReportTime = now;
}
});
}
pipeline(res, file)
.then(() => {
// 确保最后报告100%
if (progressCallback && lastReportedPercent !== 100) {
progressCallback(100);
}
resolve(true);
})
.catch(() => resolve(false));
} else {
file.close();
fs.unlink(destPath, () => { });
resolve(false);
}
});
req.on('error', () => {
file.close();
fs.unlink(destPath, () => { });
resolve(false);
});
});
}
/**
* 解压缩zip文件中的特定内容
* 只解压bin目录中的文件到目标目录
* @param zipPath 压缩文件路径
* @param extractDir 解压目标路径
*/
async function extractBinDirectory(zipPath: string, extractDir: string): Promise<void> {
try {
// 确保目标目录存在
if (!fs.existsSync(extractDir)) {
fs.mkdirSync(extractDir, { recursive: true });
}
// 解压文件
const zipStream = new compressing.zip.UncompressStream({ source: zipPath });
return new Promise<void>((resolve, reject) => {
// 监听条目事件
zipStream.on('entry', (header, stream, next) => {
// 获取文件路径
const filePath = header.name;
// 匹配内层bin目录中的文件
// 例如ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1/bin/ffmpeg.exe
if (filePath.includes('/bin/') && filePath.endsWith('.exe')) {
// 提取文件名
const fileName = path.basename(filePath);
const targetPath = path.join(extractDir, fileName);
// 创建写入流
const writeStream = fs.createWriteStream(targetPath);
// 将流管道连接到文件
stream.pipe(writeStream);
// 监听写入完成事件
writeStream.on('finish', () => {
next();
});
writeStream.on('error', () => {
next();
});
} else {
// 跳过不需要的文件
stream.resume();
next();
}
});
zipStream.on('error', (err) => {
reject(err);
});
zipStream.on('finish', () => {
resolve();
});
});
} catch (err) {
throw err;
}
}
/**
* 下载并设置FFmpeg
* @param destDir 目标安装目录默认为用户临时目录下的ffmpeg文件夹
* @param tempDir 临时文件目录,默认为系统临时目录
* @returns 返回ffmpeg可执行文件的路径如果失败则返回null
*/
export async function downloadFFmpeg(
destDir?: string,
tempDir?: string,
progressCallback?: (percent: number, stage: string) => void
): Promise<string | null> {
// 仅限Windows
if (os.platform() !== 'win32') {
return null;
}
const destinationDir = destDir || path.join(os.tmpdir(), 'ffmpeg');
const tempDirectory = tempDir || os.tmpdir();
const zipFilePath = path.join(tempDirectory, 'ffmpeg.zip'); // 临时下载到指定临时目录
const ffmpegExePath = path.join(destinationDir, 'ffmpeg.exe');
// 确保目录存在
if (!fs.existsSync(destinationDir)) {
fs.mkdirSync(destinationDir, { recursive: true });
}
// 确保临时目录存在
if (!fs.existsSync(tempDirectory)) {
fs.mkdirSync(tempDirectory, { recursive: true });
}
// 如果ffmpeg已经存在直接返回路径
if (fs.existsSync(ffmpegExePath)) {
if (progressCallback) progressCallback(100, '已找到FFmpeg');
return ffmpegExePath;
}
// 查找可用URL
if (progressCallback) progressCallback(0, '查找可用下载源');
const availableUrl = await findAvailableUrl();
if (!availableUrl) {
return null;
}
// 下载文件
if (progressCallback) progressCallback(5, '开始下载FFmpeg');
const downloaded = await downloadFile(
availableUrl,
zipFilePath,
(percent) => {
// 下载占总进度的70%
if (progressCallback) progressCallback(5 + Math.floor(percent * 0.7), '下载FFmpeg');
}
);
if (!downloaded) {
return null;
}
try {
// 直接解压bin目录文件到目标目录
if (progressCallback) progressCallback(75, '解压FFmpeg');
await extractBinDirectory(zipFilePath, destinationDir);
// 清理下载文件
if (progressCallback) progressCallback(95, '清理临时文件');
try {
fs.unlinkSync(zipFilePath);
} catch (err) {
// 忽略清理临时文件失败的错误
}
// 检查ffmpeg.exe是否成功解压
if (fs.existsSync(ffmpegExePath)) {
if (progressCallback) progressCallback(100, 'FFmpeg安装完成');
return ffmpegExePath;
} else {
return null;
}
} catch (err) {
return null;
}
}
/**
* 检查系统PATH环境变量中是否存在指定可执行文件
* @param executable 可执行文件名
* @returns 如果找到返回完整路径否则返回null
*/
function findExecutableInPath(executable: string): string | null {
// 仅适用于Windows系统
if (os.platform() !== 'win32') return null;
// 获取PATH环境变量
const pathEnv = process.env['PATH'] || '';
const pathDirs = pathEnv.split(';');
// 检查每个目录
for (const dir of pathDirs) {
if (!dir) continue;
try {
const filePath = path.join(dir, executable);
if (fs.existsSync(filePath)) {
return filePath;
}
} catch (error) {
continue;
}
}
return null;
}
export async function downloadFFmpegIfNotExists(log: LogWrapper) {
// 仅限Windows
if (os.platform() !== 'win32') {
return {
path: null,
reset: false
};
}
const ffmpegInPath = findExecutableInPath('ffmpeg.exe');
const ffprobeInPath = findExecutableInPath('ffprobe.exe');
if (ffmpegInPath && ffprobeInPath) {
const ffmpegDir = path.dirname(ffmpegInPath);
return {
path: ffmpegDir,
reset: true
};
}
// 如果环境变量中没有,检查项目目录中是否存在
const currentPath = path.dirname(fileURLToPath(import.meta.url));
const ffmpeg_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffmpeg.exe'));
const ffprobe_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffprobe.exe'));
if (!ffmpeg_exist || !ffprobe_exist) {
let url = await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => {
log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`);
});
if (!url) {
log.log('[FFmpeg] [Error] 下载FFmpeg失败');
return {
path: null,
reset: false
};
}
return {
path: path.join(currentPath, 'ffmpeg'),
reset: true
}
}
return {
path: path.join(currentPath, 'ffmpeg'),
reset: true
}
}

308
src/common/ffmpeg-worker.ts Normal file
View File

@@ -0,0 +1,308 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { FFmpeg } from '@ffmpeg.wasm/main';
import { randomUUID } from 'crypto';
import { readFileSync, statSync, writeFileSync } from 'fs';
import type { VideoInfo } from './video';
import { fileTypeFromFile } from 'file-type';
import imageSize from 'image-size';
import { parentPort } from 'worker_threads';
export function recvTask<T>(cb: (taskData: T) => Promise<unknown>) {
parentPort?.on('message', async (taskData: T) => {
try {
let ret = await cb(taskData);
parentPort?.postMessage(ret);
} catch (error: unknown) {
parentPort?.postMessage({ error: (error as Error).message });
}
});
}
export function sendLog(_log: string) {
//parentPort?.postMessage({ log });
}
class FFmpegService {
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
const videoFileName = `${randomUUID()}.mp4`;
const outputFileName = `${randomUUID()}.jpg`;
try {
ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath));
const code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
if (code !== 0) {
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
}
const thumbnail = ffmpegInstance.fs.readFile(outputFileName);
writeFileSync(thumbnailPath, thumbnail);
} catch (error) {
console.error('Error extracting thumbnail:', error);
throw error;
} finally {
try {
ffmpegInstance.fs.unlink(outputFileName);
} catch (unlinkError) {
console.error('Error unlinking output file:', unlinkError);
}
try {
ffmpegInstance.fs.unlink(videoFileName);
} catch (unlinkError) {
console.error('Error unlinking video file:', unlinkError);
}
}
}
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
const inputFileName = `${randomUUID()}.pcm`;
const outputFileName = `${randomUUID()}.${format}`;
try {
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(inputFile));
const params = format === 'amr'
? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, '-ar', '8000', '-b:a', '12.2k', outputFileName]
: ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, outputFileName];
const code = await ffmpegInstance.run(...params);
if (code !== 0) {
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
}
const outputData = ffmpegInstance.fs.readFile(outputFileName);
writeFileSync(outputFile, outputData);
} catch (error) {
console.error('Error converting file:', error);
throw error;
} finally {
try {
ffmpegInstance.fs.unlink(outputFileName);
} catch (unlinkError) {
console.error('Error unlinking output file:', unlinkError);
}
try {
ffmpegInstance.fs.unlink(inputFileName);
} catch (unlinkError) {
console.error('Error unlinking input file:', unlinkError);
}
}
}
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
const inputFileName = `${randomUUID()}.input`;
const outputFileName = `${randomUUID()}.pcm`;
try {
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath));
const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName];
const code = await ffmpegInstance.run(...params);
if (code !== 0) {
throw new Error('FFmpeg process exited with code ' + code);
}
const outputData = ffmpegInstance.fs.readFile(outputFileName);
writeFileSync(pcmPath, outputData);
return Buffer.from(outputData);
} catch (error: any) {
throw new Error('FFmpeg处理转换出错: ' + error.message);
} finally {
try {
ffmpegInstance.fs.unlink(outputFileName);
} catch (unlinkError) {
console.error('Error unlinking output file:', unlinkError);
}
try {
ffmpegInstance.fs.unlink(inputFileName);
} catch (unlinkError) {
console.error('Error unlinking output file:', unlinkError);
}
}
}
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
const startTime = Date.now();
sendLog(`开始获取视频信息: ${videoPath}`);
// 创建一个超时包装函数
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number, taskName: string): Promise<T> => {
return Promise.race([
promise,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error(`任务超时: ${taskName} (${timeoutMs}ms)`)), timeoutMs);
})
]);
};
// 并行执行多个任务
const [fileInfo, durationInfo] = await Promise.all([
// 任务1: 获取文件信息和提取缩略图
(async () => {
sendLog(`开始任务1: 获取文件信息和提取缩略图`);
// 获取文件信息 (并行)
const fileInfoStartTime = Date.now();
const [fileType, fileSize] = await Promise.all([
withTimeout(fileTypeFromFile(videoPath), 10000, '获取文件类型')
.then(result => {
sendLog(`获取文件类型完成,耗时: ${Date.now() - fileInfoStartTime}ms`);
return result;
}),
(async () => {
const result = statSync(videoPath).size;
sendLog(`获取文件大小完成,耗时: ${Date.now() - fileInfoStartTime}ms`);
return result;
})()
]);
// 直接实现缩略图提取 (不调用extractThumbnail方法)
const thumbStartTime = Date.now();
sendLog(`开始提取缩略图`);
const ffmpegInstance = await withTimeout(
FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }),
15000,
'创建FFmpeg实例(缩略图)'
);
const videoFileName = `${randomUUID()}.mp4`;
const outputFileName = `${randomUUID()}.jpg`;
try {
// 写入视频文件到FFmpeg
const writeFileStartTime = Date.now();
ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath));
sendLog(`写入视频文件到FFmpeg完成耗时: ${Date.now() - writeFileStartTime}ms`);
// 提取缩略图
const extractStartTime = Date.now();
const code = await withTimeout(
ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName),
30000,
'提取缩略图'
);
sendLog(`FFmpeg提取缩略图命令执行完成耗时: ${Date.now() - extractStartTime}ms`);
if (code !== 0) {
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
}
// 读取并保存缩略图
const saveStartTime = Date.now();
const thumbnail = ffmpegInstance.fs.readFile(outputFileName);
writeFileSync(thumbnailPath, thumbnail);
sendLog(`读取并保存缩略图完成,耗时: ${Date.now() - saveStartTime}ms`);
// 获取缩略图尺寸
const imageSizeStartTime = Date.now();
const image = imageSize(thumbnailPath);
sendLog(`获取缩略图尺寸完成,耗时: ${Date.now() - imageSizeStartTime}ms`);
sendLog(`提取缩略图完成,总耗时: ${Date.now() - thumbStartTime}ms`);
return {
format: fileType?.ext ?? 'mp4',
size: fileSize,
width: image.width ?? 100,
height: image.height ?? 100
};
} finally {
// 清理资源
try {
ffmpegInstance.fs.unlink(outputFileName);
} catch (error) {
sendLog(`清理输出文件失败: ${(error as Error).message}`);
}
try {
ffmpegInstance.fs.unlink(videoFileName);
} catch (error) {
sendLog(`清理视频文件失败: ${(error as Error).message}`);
}
}
})(),
// 任务2: 获取视频时长
(async () => {
const task2StartTime = Date.now();
sendLog(`开始任务2: 获取视频时长`);
// 创建FFmpeg实例
const ffmpegCreateStartTime = Date.now();
const ffmpegInstance = await withTimeout(
FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }),
15000,
'创建FFmpeg实例(时长)'
);
sendLog(`创建FFmpeg实例完成耗时: ${Date.now() - ffmpegCreateStartTime}ms`);
const inputFileName = `${randomUUID()}.mp4`;
try {
// 写入文件
const writeStartTime = Date.now();
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath));
sendLog(`写入文件到FFmpeg完成耗时: ${Date.now() - writeStartTime}ms`);
ffmpegInstance.setLogging(true);
let duration = 60; // 默认值
ffmpegInstance.setLogger((_level, ...msg) => {
const message = msg.join(' ');
const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/);
if (durationMatch) {
const hours = parseInt(durationMatch[1] ?? '0', 10);
const minutes = parseInt(durationMatch[2] ?? '0', 10);
const seconds = parseFloat(durationMatch[3] ?? '0');
duration = hours * 3600 + minutes * 60 + seconds;
}
});
// 执行FFmpeg
const runStartTime = Date.now();
await withTimeout(
ffmpegInstance.run('-i', inputFileName),
20000,
'获取视频时长'
);
sendLog(`执行FFmpeg命令完成耗时: ${Date.now() - runStartTime}ms`);
sendLog(`任务2(获取视频时长)完成,总耗时: ${Date.now() - task2StartTime}ms`);
return { time: duration };
} finally {
try {
ffmpegInstance.fs.unlink(inputFileName);
} catch (error) {
sendLog(`清理输入文件失败: ${(error as Error).message}`);
}
}
})()
]);
// 合并结果并返回
const totalDuration = Date.now() - startTime;
sendLog(`获取视频信息完成,总耗时: ${totalDuration}ms`);
return {
width: fileInfo.width,
height: fileInfo.height,
time: durationInfo.time,
format: fileInfo.format,
size: fileInfo.size,
filePath: videoPath
};
}
}
type FFmpegMethod = 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
interface FFmpegTask {
method: FFmpegMethod;
args: any[];
}
export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise<any> {
switch (method) {
case 'extractThumbnail':
return await FFmpegService.extractThumbnail(...args as [string, string]);
case 'convertFile':
return await FFmpegService.convertFile(...args as [string, string, string]);
case 'convert':
return await FFmpegService.convert(...args as [string, string]);
case 'getVideoInfo':
return await FFmpegService.getVideoInfo(...args as [string, string]);
default:
throw new Error(`Unknown method: ${method}`);
}
}
recvTask<FFmpegTask>(async ({ method, args }: FFmpegTask) => {
return await handleFFmpegTask({ method, args });
});

View File

@@ -1,195 +1,36 @@
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 { 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 isLocalExeExists = existsSync(path.join(currentPath, 'ffmpeg', exeName));
return isLocalExeExists ? path.join(currentPath, 'ffmpeg', exeName) : exeName;
}
return tool;
/* eslint-disable @typescript-eslint/no-explicit-any */
import { VideoInfo } from './video';
import path from 'path';
import { fileURLToPath } from 'url';
import { runTask } from './worker';
type EncodeArgs = {
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
args: any[];
};
export let FFMPEG_CMD = getFFmpegPath('ffmpeg');
export let FFPROBE_CMD = getFFmpegPath('ffprobe');
type EncodeResult = any;
function getWorkerPath() {
return path.join(path.dirname(fileURLToPath(import.meta.url)), './ffmpeg-worker.mjs');
}
export class FFmpegService {
// 确保目标目录存在
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);
}
}
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> {
try {
this.ensureDirExists(thumbnailPath);
const { stderr } = await execFileAsync(FFMPEG_CMD, [
'-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}`);
}
await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'extractThumbnail', args: [videoPath, thumbnailPath] });
}
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}`);
}
await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'convertFile', args: [inputFile, outputFile, format] });
}
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}`);
}
const result = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'convert', args: [filePath, pcmPath] });
return result;
}
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: fileInfo.width,
height: fileInfo.height,
time: duration,
format: fileInfo.format,
size: fileInfo.size,
filePath: videoPath
};
return result;
} catch (error) {
throw error;
}
const result = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'getVideoInfo', args: [videoPath, thumbnailPath] });
return result;
}
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 {
format: fileType?.ext ?? 'mp4',
size: fileSize,
width: dimensions.width ?? 100,
height: dimensions.height ?? 100
};
} catch (error) {
return {
format: fileType?.ext ?? 'mp4',
size: fileSize,
width: 100,
height: 100
};
}
}
private static async getVideoDuration(videoPath: string): Promise<number> {
try {
// 使用FFprobe获取时长
const { stdout } = await execFileAsync(FFPROBE_CMD, [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
videoPath
]);
const duration = parseFloat(stdout.trim());
return isNaN(duration) ? 60 : duration;
} catch (error) {
return 60; // 默认时长
}
}
}
}

View File

@@ -115,7 +115,7 @@ async function tryDownload(options: string | HttpDownloadOptions, useReferer: bo
if (useReferer && !headers['Referer']) {
headers['Referer'] = url;
}
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
const fetchRes = await fetch(url, { headers }).catch((err) => {
if (err.cause) {
throw err.cause;
}
@@ -145,8 +145,8 @@ export enum FileUriType {
export async function checkUriType(Uri: string) {
const LocalFileRet = await solveProblem((uri: string) => {
if (fs.existsSync(path.normalize(uri))) {
return { Uri: path.normalize(uri), Type: FileUriType.Local };
if (fs.existsSync(uri)) {
return { Uri: uri, Type: FileUriType.Local };
}
return undefined;
}, Uri);

View File

@@ -1 +1 @@
export const napCatVersion = '4.7.62';
export const napCatVersion = '4.7.13';

View File

@@ -17,6 +17,8 @@ import fs from 'fs';
import fsPromises from 'fs/promises';
import { InstanceContext, NapCatCore, SearchResultItem } from '@/core';
import { fileTypeFromFile } from 'file-type';
import imageSize from 'image-size';
import { ISizeCalculationResult } from 'image-size/dist/types/interface';
import { RkeyManager } from '@/core/helper/rkey';
import { calculateFileMD5 } from '@/common/file';
import pathLib from 'node:path';
@@ -26,9 +28,6 @@ import { SendMessageContext } from '@/onebot/api';
import { getFileTypeForSendType } from '../helper/msg';
import { FFmpegService } from '@/common/ffmpeg';
import { rkeyDataType } from '../types/file';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { FileId } from '../packet/transformer/proto/misc/fileid';
import { imageSizeFallBack } from '@/image-size';
export class NTQQFileApi {
context: InstanceContext;
@@ -42,8 +41,8 @@ export class NTQQFileApi {
this.context = context;
this.core = core;
this.rkeyManager = new RkeyManager([
'https://secret-service.bietiaop.com/rkeys',
'http://ss.xingzhige.com/music_card/rkey',
'https://ss.xingzhige.com/music_card/rkey', // 国内
'https://secret-service.bietiaop.com/rkeys',//国内
],
this.context.logger
);
@@ -64,76 +63,6 @@ export class NTQQFileApi {
}
}
async getFileUrl(chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined) {
if (this.core.apis.PacketApi.available) {
try {
if (chatType === ChatType.KCHATTYPEGROUP && fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetGroupFileUrl(+peer, fileUUID);
} else if (file10MMd5 && fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(peer, fileUUID, file10MMd5);
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('fileUUID or file10MMd5 is undefined');
}
async getPttUrl(peer: string, fileUUID?: string) {
if (this.core.apis.PacketApi.available && fileUUID) {
let appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
if (appid && appid === 1403) {
return this.core.apis.PacketApi.pkt.operation.GetGroupPttUrl(+peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
});
} else if (fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetPttUrl(peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
});
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('packet cant get ptt url');
}
async getVideoUrlPacket(peer: string, fileUUID?: string) {
if (this.core.apis.PacketApi.available && fileUUID) {
let appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
if (appid && appid === 1415) {
return this.core.apis.PacketApi.pkt.operation.GetGroupVideoUrl(+peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
});
} else if (fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetVideoUrl(peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
});
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('packet cant get video url');
}
async copyFile(filePath: string, destPath: string) {
await this.core.util.copyFile(filePath, destPath);
@@ -208,7 +137,7 @@ export class NTQQFileApi {
if (fileSize === 0) {
throw new Error('文件异常大小为0');
}
const imageSize = await imageSizeFallBack(picPath);
const imageSize = await this.core.apis.FileApi.getImageSize(picPath);
context.deleteAfterSentFiles.push(path);
return {
elementType: ElementType.PIC,
@@ -396,7 +325,6 @@ export class NTQQFileApi {
}
});
});
return res.flat();
}
async downloadMedia(msgId: string, chatType: ChatType, peerUid: string, elementId: string, thumbPath: string, sourcePath: string, timeout = 1000 * 60 * 2, force: boolean = false) {
@@ -417,7 +345,6 @@ export class NTQQFileApi {
'NodeIKernelMsgListener/onRichMediaDownloadComplete',
[{
fileModelId: '0',
downSourceType: 0,
downloadSourceType: 0,
triggerType: 1,
msgId: msgId,
@@ -436,6 +363,19 @@ export class NTQQFileApi {
return completeRetData.filePath;
}
async getImageSize(filePath: string): Promise<ISizeCalculationResult> {
return new Promise((resolve, reject) => {
imageSize(filePath, (err: Error | null, dimensions) => {
if (err) {
reject(new Error(err.message));
} else if (!dimensions) {
reject(new Error('获取图片尺寸失败'));
} else {
resolve(dimensions);
}
});
});
}
async searchForFile(keys: string[]): Promise<SearchResultItem | undefined> {
const randomResultId = 100000 + Math.floor(Math.random() * 10000);

View File

@@ -86,31 +86,4 @@ export class NTQQFriendApi {
accept,
});
}
async handleDoubtFriendRequest(friendUid: string, str1: string = '', str2: string = '') {
this.context.session.getBuddyService().approvalDoubtBuddyReq(friendUid, str1, str2);
}
async getDoubtFriendRequest(count: number) {
let date = Date.now().toString();
const [, ret] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelBuddyService/getDoubtBuddyReq',
'NodeIKernelBuddyListener/onDoubtBuddyReqChange',
[date, count, ''],
() => true,
(data) => data.reqId === date
);
let requests = Promise.all(ret.doubtList.map(async (item) => {
return {
flag: item.uid, //注意强制String 非isNumeric 不遵守则不符合设计
uin: await this.core.apis.UserApi.getUinByUidV2(item.uid) ?? 0,// 信息字段
nick: item.nick, // 信息字段 这个不是nickname 可能是来源的群内的昵称
source: item.source, // 信息字段
reason: item.reason, // 信息字段
msg: item.msg, // 信息字段
group_code: item.groupCode, // 信息字段
time: item.reqTime, // 信息字段
type: 'doubt' //保留字段
};
}))
return requests;
}
}

View File

@@ -218,10 +218,6 @@ export class NTQQGroupApi {
return this.context.session.getRichMediaService().deleteGroupFolder(groupCode, folderId);
}
async transGroupFile(groupCode: string, fileId: string) {
return this.context.session.getRichMediaService().transGroupFile(groupCode, fileId);
}
async addGroupEssence(groupCode: string, msgId: string) {
const MsgData = await this.context.session.getMsgService().getMsgsIncludeSelf({
chatType: 2,

View File

@@ -12,7 +12,7 @@ export class NTQQMsgApi {
this.context = context;
this.core = core;
}
async clickInlineKeyboardButton(...params: Parameters<NodeIKernelMsgService['clickInlineKeyboardButton']>) {
return this.context.session.getMsgService().clickInlineKeyboardButton(...params);
}
@@ -71,7 +71,6 @@ export class NTQQMsgApi {
async queryMsgsWithFilterExWithSeq(peer: Peer, msgSeq: string) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer,
//searchFields: 3,
filterMsgType: [],
filterSendersUid: [],
filterMsgToTime: '0',
@@ -85,7 +84,6 @@ export class NTQQMsgApi {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer,
filterMsgType: [],
//searchFields: 3,
filterSendersUid: SendersUid,
filterMsgToTime: MsgTime,
filterMsgFromTime: MsgTime,
@@ -102,7 +100,6 @@ export class NTQQMsgApi {
filterMsgToTime: '0',
filterMsgFromTime: '0',
isReverseOrder: false,
//searchFields: 3,
isIncludeCurrent: true,
pageLimit: 1,
});
@@ -113,7 +110,6 @@ export class NTQQMsgApi {
filterMsgType: [],
filterSendersUid: [],
filterMsgToTime: '0',
//searchFields: 3,
filterMsgFromTime: '0',
isReverseOrder: true,
isIncludeCurrent: true,
@@ -132,7 +128,6 @@ export class NTQQMsgApi {
chatInfo: peer,//此处为Peer 为关键查询参数 没有啥也没有 by mlik iowa
filterMsgType: [],
filterSendersUid: [],
//searchFields: 3,
filterMsgToTime: filterMsgToTime,
filterMsgFromTime: filterMsgFromTime,
isReverseOrder: false,
@@ -147,7 +142,6 @@ export class NTQQMsgApi {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: SendersUid,
//searchFields: 3,
filterMsgToTime: '0',
filterMsgFromTime: '0',
isReverseOrder: true,

View File

@@ -234,57 +234,5 @@
"3.2.16-33800": {
"appid": 537274009,
"qua": "V1_LNX_NQ_3.2.16_33800_GW_B"
},
"9.9.19-34231": {
"appid": 537279209,
"qua": "V1_WIN_NQ_9.9.19_34231_GW_B"
},
"3.2.17-34231": {
"appid": 537279245,
"qua": "V1_LNX_NQ_3.2.17_34231_GW_B"
},
"9.9.19-34362": {
"appid": 537279260,
"qua": "V1_WIN_NQ_9.9.19_34362_GW_B"
},
"3.2.17-34362": {
"appid": 537279296,
"qua": "V1_LNX_NQ_3.2.17_34362_GW_B"
},
"9.9.19-34467": {
"appid": 537282256,
"qua": "V1_WIN_NQ_9.9.19_34467_GW_B"
},
"3.2.17-34467": {
"appid": 537282292,
"qua": "V1_LNX_NQ_3.2.17_34467_GW_B"
},
"9.9.19-34566": {
"appid": 537282307,
"qua": "V1_WIN_NQ_9.9.19_34566_GW_B"
},
"3.2.17-34566": {
"appid": 537282343,
"qua": "V1_LNX_NQ_3.2.17_34566_GW_B"
},
"3.2.17-34606": {
"appid": 537282343,
"qua": "V1_LNX_NQ_3.2.17_34606_GW_B"
},
"9.9.19-34606": {
"appid": 537282307,
"qua": "V1_WIN_NQ_9.9.19_34606_GW_B"
},
"9.9.19-34740": {
"appid": 537290691,
"qua": "V1_WIN_NQ_9.9.19_34740_GW_B"
},
"3.2.17-34740": {
"appid": 537290727,
"qua": "V1_LNX_NQ_3.2.17_34740_GW_B"
},
"9.9.19-34958": {
"appid": 537290742,
"qua": "V1_WIN_NQ_9.9.19_34958_GW_B"
}
}

View File

@@ -314,57 +314,5 @@
"3.2.16-33800-arm64": {
"send": "7262BB0",
"recv": "72664E0"
},
"9.9.19-34231-x64": {
"send": "3BD73D0",
"recv": "3BDBBD0"
},
"3.2.17-34231-x64": {
"send": "AD787E0",
"recv": "AD7C200"
},
"3.2.17-34231-arm64": {
"send": "770CDC0",
"recv": "77106F0"
},
"9.9.19-34362-x64": {
"send": "3BD80D0",
"recv": "3BDC8D0"
},
"9.9.19-34467-x64": {
"send": "3BD8690",
"recv": "3BDCE90"
},
"9.9.19-34566-x64": {
"send": "3BDA110",
"recv": "3BDE910"
},
"9.9.19-34606-x64": {
"send": "3BDA110",
"recv": "3BDE910"
},
"3.2.17-34606-x64": {
"send": "AD7DC60",
"recv": "AD81680"
},
"3.2.17-34606-arm64": {
"send": "7711270",
"recv": "7714BA0"
},
"9.9.19-34740-x64": {
"send": "3BDD8D0",
"recv": "3BE20D0"
},
"3.2.17-34740-x64": {
"send": "ADDF0A0",
"recv": "ADE2AC0"
},
"3.2.17-34740-arm64": {
"send": "7753BB8",
"recv": "77574E8"
},
"9.9.19-34958-x64": {
"send": "3BDD8D0",
"recv": "3BE20D0"
}
}

View File

@@ -6,17 +6,6 @@ interface ServerRkeyData {
private_rkey: string;
expired_time: number;
}
interface OneBotApiRet {
status: string,
retcode: number,
data: ServerRkeyData,
message: string,
wording: string,
}
interface UrlFailureInfo {
count: number;
lastTimestamp: number;
}
export class RkeyManager {
serverUrl: string[] = [];
@@ -26,8 +15,9 @@ export class RkeyManager {
private_rkey: '',
expired_time: 0,
};
private urlFailures: Map<string, UrlFailureInfo> = new Map();
private readonly FAILURE_LIMIT: number = 4;
private failureCount: number = 0;
private lastFailureTimestamp: number = 0;
private readonly FAILURE_LIMIT: number = 8;
private readonly ONE_DAY: number = 24 * 60 * 60 * 1000;
constructor(serverUrl: string[], logger: LogWrapper) {
@@ -36,92 +26,50 @@ export class RkeyManager {
}
async getRkey() {
const availableUrls = this.getAvailableUrls();
if (availableUrls.length === 0) {
this.logger.logError('[Rkey] 所有服务均已禁用, 图片使用FallBack机制');
throw new Error('获取rkey失败所有服务URL均已被禁用');
const now = new Date().getTime();
if (now - this.lastFailureTimestamp > this.ONE_DAY) {
this.failureCount = 0; // 重置失败计数器
}
if (this.failureCount >= this.FAILURE_LIMIT) {
this.logger.logError('[Rkey] 服务存在异常, 图片使用FallBack机制');
throw new Error('获取rkey失败次数过多请稍后再试');
}
if (this.isExpired()) {
try {
await this.refreshRkey();
} catch (e) {
throw new Error(`${e}`);
throw new Error(`${e}`);//外抛
}
}
return this.rkeyData;
}
private getAvailableUrls(): string[] {
return this.serverUrl.filter(url => !this.isUrlDisabled(url));
}
private isUrlDisabled(url: string): boolean {
const failureInfo = this.urlFailures.get(url);
if (!failureInfo) return false;
const now = new Date().getTime();
// 如果已经过了一天,重置失败计数
if (now - failureInfo.lastTimestamp > this.ONE_DAY) {
failureInfo.count = 0;
this.urlFailures.set(url, failureInfo);
return false;
}
return failureInfo.count >= this.FAILURE_LIMIT;
}
private updateUrlFailure(url: string) {
const now = new Date().getTime();
const failureInfo = this.urlFailures.get(url) || { count: 0, lastTimestamp: 0 };
// 如果已经过了一天,重置失败计数
if (now - failureInfo.lastTimestamp > this.ONE_DAY) {
failureInfo.count = 1;
} else {
failureInfo.count++;
}
failureInfo.lastTimestamp = now;
this.urlFailures.set(url, failureInfo);
if (failureInfo.count >= this.FAILURE_LIMIT) {
this.logger.logError(`[Rkey] URL ${url} 已被禁用,失败次数达到 ${this.FAILURE_LIMIT}`);
}
}
isExpired(): boolean {
const now = new Date().getTime() / 1000;
return now > this.rkeyData.expired_time;
}
async refreshRkey() {
const availableUrls = this.getAvailableUrls();
if (availableUrls.length === 0) {
this.logger.logError('[Rkey] 所有服务均已禁用');
throw new Error('获取rkey失败所有服务URL均已被禁用');
}
for (const url of availableUrls) {
//刷新rkey
for (const url of this.serverUrl) {
try {
let temp = await RequestUtil.HttpGetJson<ServerRkeyData>(url, 'GET');
if ('retcode' in temp) {
// 支持Onebot Ret风格
temp = (temp as unknown as OneBotApiRet).data;
}
const temp = await RequestUtil.HttpGetJson<ServerRkeyData>(url, 'GET');
this.rkeyData = {
group_rkey: temp.group_rkey.slice(6),
private_rkey: temp.private_rkey.slice(6),
expired_time: temp.expired_time
};
this.failureCount = 0;
return;
} catch (e) {
this.logger.logError(`[Rkey] 异常服务 ${url} 异常 / `, e);
this.updateUrlFailure(url);
if (url === availableUrls[availableUrls.length - 1]) {
throw new Error(`获取rkey失败: ${e}`);
this.failureCount++;
this.lastFailureTimestamp = new Date().getTime();
//是否为最后一个url
if (url === this.serverUrl[this.serverUrl.length - 1]) {
throw new Error(`获取rkey失败: ${e}`);//外抛
}
}
}

View File

@@ -3,75 +3,57 @@ import { BuddyCategoryType, FriendRequestNotify } from '@/core/types';
export type OnBuddyChangeParams = BuddyCategoryType[];
export class NodeIKernelBuddyListener {
onBuddyListChangedV2(_arg: unknown): any {
onBuddyListChangedV2(arg: unknown): any {
}
onAddBuddyNeedVerify(_arg: unknown): any {
onAddBuddyNeedVerify(arg: unknown): any {
}
onAddMeSettingChanged(_arg: unknown): any {
onAddMeSettingChanged(arg: unknown): any {
}
onAvatarUrlUpdated(_arg: unknown): any {
onAvatarUrlUpdated(arg: unknown): any {
}
onBlockChanged(_arg: unknown): any {
onBlockChanged(arg: unknown): any {
}
onBuddyDetailInfoChange(_arg: unknown): any {
onBuddyDetailInfoChange(arg: unknown): any {
}
onBuddyInfoChange(_arg: unknown): any {
onBuddyInfoChange(arg: unknown): any {
}
onBuddyListChange(_arg: OnBuddyChangeParams): any {
onBuddyListChange(arg: OnBuddyChangeParams): any {
}
onBuddyRemarkUpdated(_arg: unknown): any {
onBuddyRemarkUpdated(arg: unknown): any {
}
onBuddyReqChange(_arg: FriendRequestNotify): any {
onBuddyReqChange(arg: FriendRequestNotify): any {
}
onBuddyReqUnreadCntChange(_arg: unknown): any {
onBuddyReqUnreadCntChange(arg: unknown): any {
}
onCheckBuddySettingResult(_arg: unknown): any {
onCheckBuddySettingResult(arg: unknown): any {
}
onDelBatchBuddyInfos(_arg: unknown): any {
console.log('onDelBatchBuddyInfos not implemented', ...arguments);
onDelBatchBuddyInfos(arg: unknown): any {
}
onDoubtBuddyReqChange(_arg:
{
reqId: string;
cookie: string;
doubtList: Array<{
uid: string;
nick: string;
age: number,
sex: number;
commFriendNum: number;
reqTime: string;
msg: string;
source: string;
reason: string;
groupCode: string;
nameMore?: null;
}>;
}): void | Promise<void> {
onDoubtBuddyReqChange(arg: unknown): any {
}
onDoubtBuddyReqUnreadNumChange(_num: number): void | Promise<void> {
onDoubtBuddyReqUnreadNumChange(arg: unknown): any {
}
onNickUpdated(_arg: unknown): any {
onNickUpdated(arg: unknown): any {
}
onSmartInfos(_arg: unknown): any {
onSmartInfos(arg: unknown): any {
}
onSpacePermissionInfos(_arg: unknown): any {
onSpacePermissionInfos(arg: unknown): any {
}
}

View File

@@ -21,8 +21,7 @@ export interface OnRichMediaDownloadCompleteParams {
clientMsg: string,
businessId: number,
userTotalSpacePerDay: unknown,
userUsedSpacePerDay: unknown,
chatType: number,
userUsedSpacePerDay: unknown
}
export interface GroupFileInfoUpdateParamType {
@@ -98,112 +97,112 @@ export interface TempOnRecvParams {
}
export class NodeIKernelMsgListener {
onAddSendMsg(_msgRecord: RawMessage): any {
onAddSendMsg(msgRecord: RawMessage): any {
}
onBroadcastHelperDownloadComplete(_broadcastHelperTransNotifyInfo: unknown): any {
onBroadcastHelperDownloadComplete(broadcastHelperTransNotifyInfo: unknown): any {
}
onBroadcastHelperProgressUpdate(_broadcastHelperTransNotifyInfo: unknown): any {
onBroadcastHelperProgressUpdate(broadcastHelperTransNotifyInfo: unknown): any {
}
onChannelFreqLimitInfoUpdate(_contact: unknown, _z: unknown, _freqLimitInfo: unknown): any {
onChannelFreqLimitInfoUpdate(contact: unknown, z: unknown, freqLimitInfo: unknown): any {
}
onContactUnreadCntUpdate(_hashMap: unknown): any {
onContactUnreadCntUpdate(hashMap: unknown): any {
}
onCustomWithdrawConfigUpdate(_customWithdrawConfig: unknown): any {
onCustomWithdrawConfigUpdate(customWithdrawConfig: unknown): any {
}
onDraftUpdate(_contact: unknown, _arrayList: unknown, _j2: unknown): any {
onDraftUpdate(contact: unknown, arrayList: unknown, j2: unknown): any {
}
onEmojiDownloadComplete(_emojiNotifyInfo: unknown): any {
onEmojiDownloadComplete(emojiNotifyInfo: unknown): any {
}
onEmojiResourceUpdate(_emojiResourceInfo: unknown): any {
onEmojiResourceUpdate(emojiResourceInfo: unknown): any {
}
onFeedEventUpdate(_firstViewDirectMsgNotifyInfo: unknown): any {
onFeedEventUpdate(firstViewDirectMsgNotifyInfo: unknown): any {
}
onFileMsgCome(_arrayList: unknown): any {
onFileMsgCome(arrayList: unknown): any {
}
onFirstViewDirectMsgUpdate(_firstViewDirectMsgNotifyInfo: unknown): any {
onFirstViewDirectMsgUpdate(firstViewDirectMsgNotifyInfo: unknown): any {
}
onFirstViewGroupGuildMapping(_arrayList: unknown): any {
onFirstViewGroupGuildMapping(arrayList: unknown): any {
}
onGrabPasswordRedBag(_i2: unknown, _str: unknown, _i3: unknown, _recvdOrder: unknown, _msgRecord: unknown): any {
onGrabPasswordRedBag(i2: unknown, str: unknown, i3: unknown, recvdOrder: unknown, msgRecord: unknown): any {
}
onGroupFileInfoAdd(_groupItem: unknown): any {
onGroupFileInfoAdd(groupItem: unknown): any {
}
onGroupFileInfoUpdate(_groupFileListResult: GroupFileInfoUpdateParamType): any {
onGroupFileInfoUpdate(groupFileListResult: GroupFileInfoUpdateParamType): any {
}
onGroupGuildUpdate(_groupGuildNotifyInfo: unknown): any {
onGroupGuildUpdate(groupGuildNotifyInfo: unknown): any {
}
onGroupTransferInfoAdd(_groupItem: unknown): any {
onGroupTransferInfoAdd(groupItem: unknown): any {
}
onGroupTransferInfoUpdate(_groupFileListResult: unknown): any {
onGroupTransferInfoUpdate(groupFileListResult: unknown): any {
}
onGuildInteractiveUpdate(_guildInteractiveNotificationItem: unknown): any {
onGuildInteractiveUpdate(guildInteractiveNotificationItem: unknown): any {
}
onGuildMsgAbFlagChanged(_guildMsgAbFlag: unknown): any {
onGuildMsgAbFlagChanged(guildMsgAbFlag: unknown): any {
}
onGuildNotificationAbstractUpdate(_guildNotificationAbstractInfo: unknown): any {
onGuildNotificationAbstractUpdate(guildNotificationAbstractInfo: unknown): any {
}
onHitCsRelatedEmojiResult(_downloadRelateEmojiResultInfo: unknown): any {
onHitCsRelatedEmojiResult(downloadRelateEmojiResultInfo: unknown): any {
}
onHitEmojiKeywordResult(_hitRelatedEmojiWordsResult: unknown): any {
onHitEmojiKeywordResult(hitRelatedEmojiWordsResult: unknown): any {
}
onHitRelatedEmojiResult(_relatedWordEmojiInfo: unknown): any {
onHitRelatedEmojiResult(relatedWordEmojiInfo: unknown): any {
}
onImportOldDbProgressUpdate(_importOldDbMsgNotifyInfo: unknown): any {
onImportOldDbProgressUpdate(importOldDbMsgNotifyInfo: unknown): any {
}
onInputStatusPush(_inputStatusInfo: {
onInputStatusPush(inputStatusInfo: {
chatType: number;
eventType: number;
fromUin: string;
@@ -216,55 +215,55 @@ export class NodeIKernelMsgListener {
}
onKickedOffLine(_kickedInfo: KickedOffLineInfo): any {
onKickedOffLine(kickedInfo: KickedOffLineInfo): any {
}
onLineDev(_arrayList: unknown): any {
onLineDev(arrayList: unknown): any {
}
onLogLevelChanged(_j2: unknown): any {
onLogLevelChanged(j2: unknown): any {
}
onMsgAbstractUpdate(_arrayList: unknown): any {
onMsgAbstractUpdate(arrayList: unknown): any {
}
onMsgBoxChanged(_arrayList: unknown): any {
onMsgBoxChanged(arrayList: unknown): any {
}
onMsgDelete(_contact: unknown, _arrayList: unknown): any {
onMsgDelete(contact: unknown, arrayList: unknown): any {
}
onMsgEventListUpdate(_hashMap: unknown): any {
onMsgEventListUpdate(hashMap: unknown): any {
}
onMsgInfoListAdd(_arrayList: unknown): any {
onMsgInfoListAdd(arrayList: unknown): any {
}
onMsgInfoListUpdate(_msgList: RawMessage[]): any {
onMsgInfoListUpdate(msgList: RawMessage[]): any {
}
onMsgQRCodeStatusChanged(_i2: unknown): any {
onMsgQRCodeStatusChanged(i2: unknown): any {
}
onMsgRecall(_chatType: ChatType, _uid: string, _msgSeq: string): any {
onMsgRecall(chatType: ChatType, uid: string, msgSeq: string): any {
}
onMsgSecurityNotify(_msgRecord: unknown): any {
onMsgSecurityNotify(msgRecord: unknown): any {
}
onMsgSettingUpdate(_msgSetting: unknown): any {
onMsgSettingUpdate(msgSetting: unknown): any {
}
@@ -280,108 +279,108 @@ export class NodeIKernelMsgListener {
}
onReadFeedEventUpdate(_firstViewDirectMsgNotifyInfo: unknown): any {
onReadFeedEventUpdate(firstViewDirectMsgNotifyInfo: unknown): any {
}
onRecvGroupGuildFlag(_i2: unknown): any {
onRecvGroupGuildFlag(i2: unknown): any {
}
onRecvMsg(_arrayList: RawMessage[]): any {
onRecvMsg(arrayList: RawMessage[]): any {
}
onRecvMsgSvrRspTransInfo(_j2: unknown, _contact: unknown, _i2: unknown, _i3: unknown, _str: unknown, _bArr: unknown): any {
onRecvMsgSvrRspTransInfo(j2: unknown, contact: unknown, i2: unknown, i3: unknown, str: unknown, bArr: unknown): any {
}
onRecvOnlineFileMsg(_arrayList: unknown): any {
onRecvOnlineFileMsg(arrayList: unknown): any {
}
onRecvS2CMsg(_arrayList: unknown): any {
onRecvS2CMsg(arrayList: unknown): any {
}
onRecvSysMsg(_arrayList: Array<number>): any {
onRecvSysMsg(arrayList: Array<number>): any {
}
onRecvUDCFlag(_i2: unknown): any {
onRecvUDCFlag(i2: unknown): any {
}
onRichMediaDownloadComplete(_fileTransNotifyInfo: OnRichMediaDownloadCompleteParams): any {
onRichMediaDownloadComplete(fileTransNotifyInfo: OnRichMediaDownloadCompleteParams): any {
}
onRichMediaProgerssUpdate(_fileTransNotifyInfo: unknown): any {
onRichMediaProgerssUpdate(fileTransNotifyInfo: unknown): any {
}
onRichMediaUploadComplete(_fileTransNotifyInfo: unknown): any {
onRichMediaUploadComplete(fileTransNotifyInfo: unknown): any {
}
onSearchGroupFileInfoUpdate(_searchGroupFileResult: unknown): any {
onSearchGroupFileInfoUpdate(searchGroupFileResult: unknown): any {
}
onSendMsgError(_j2: unknown, _contact: unknown, _i2: unknown, _str: unknown): any {
onSendMsgError(j2: unknown, contact: unknown, i2: unknown, str: unknown): any {
}
onSysMsgNotification(_i2: unknown, _j2: unknown, _j3: unknown, _arrayList: unknown): any {
onSysMsgNotification(i2: unknown, j2: unknown, j3: unknown, arrayList: unknown): any {
}
onTempChatInfoUpdate(_tempChatInfo: TempOnRecvParams): any {
onTempChatInfoUpdate(tempChatInfo: TempOnRecvParams): any {
}
onUnreadCntAfterFirstView(_hashMap: unknown): any {
onUnreadCntAfterFirstView(hashMap: unknown): any {
}
onUnreadCntUpdate(_hashMap: unknown): any {
onUnreadCntUpdate(hashMap: unknown): any {
}
onUserChannelTabStatusChanged(_z: unknown): any {
onUserChannelTabStatusChanged(z: unknown): any {
}
onUserOnlineStatusChanged(_z: unknown): any {
onUserOnlineStatusChanged(z: unknown): any {
}
onUserTabStatusChanged(_arrayList: unknown): any {
onUserTabStatusChanged(arrayList: unknown): any {
}
onlineStatusBigIconDownloadPush(_i2: unknown, _j2: unknown, _str: unknown): any {
onlineStatusBigIconDownloadPush(i2: unknown, j2: unknown, str: unknown): any {
}
onlineStatusSmallIconDownloadPush(_i2: unknown, _j2: unknown, _str: unknown): any {
onlineStatusSmallIconDownloadPush(i2: unknown, j2: unknown, str: unknown): any {
}
// 第一次发现于Linux
onUserSecQualityChanged(..._args: unknown[]): any {
onUserSecQualityChanged(...args: unknown[]): any {
}
onMsgWithRichLinkInfoUpdate(..._args: unknown[]): any {
onMsgWithRichLinkInfoUpdate(...args: unknown[]): any {
}
onRedTouchChanged(..._args: unknown[]): any {
onRedTouchChanged(...args: unknown[]): any {
}
// 第一次发现于Win 9.9.9-23159
onBroadcastHelperProgerssUpdate(..._args: unknown[]): any {
onBroadcastHelperProgerssUpdate(...args: unknown[]): any {
}
}

View File

@@ -6,14 +6,13 @@ import {
PacketMsgFileElement,
PacketMsgPicElement,
PacketMsgPttElement,
PacketMsgReplyElement,
PacketMsgVideoElement,
PacketMsgVideoElement
} from '@/core/packet/message/element';
import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/core';
import { MiniAppRawData, MiniAppReqParams } from '@/core/packet/entities/miniApp';
import { AIVoiceChatType } from '@/core/packet/entities/aiChat';
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import { IndexNode, LongMsgResult, MsgInfo, PushMsgBody } from '@/core/packet/transformer/proto';
import { IndexNode, LongMsgResult, MsgInfo } from '@/core/packet/transformer/proto';
import { OidbPacket } from '@/core/packet/transformer/base';
import { ImageOcrResult } from '@/core/packet/entities/ocrResult';
import { gunzipSync } from 'zlib';
@@ -77,24 +76,22 @@ export class PacketOperationContext {
async UploadResources(msg: PacketMsg[], groupUin: number = 0) {
const chatType = groupUin ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C;
const peerUid = groupUin ? String(groupUin) : this.context.napcore.basicInfo.uid;
const reqList = msg.flatMap((m) =>
m.msg
.map((e) => {
if (e instanceof PacketMsgPicElement) {
return this.context.highway.uploadImage({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgVideoElement) {
return this.context.highway.uploadVideo({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgPttElement) {
return this.context.highway.uploadPtt({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgFileElement) {
return this.context.highway.uploadFile({ chatType, peerUid }, e);
}
return null;
})
.filter(Boolean)
const reqList = msg.flatMap(m =>
m.msg.map(e => {
if (e instanceof PacketMsgPicElement) {
return this.context.highway.uploadImage({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgVideoElement) {
return this.context.highway.uploadVideo({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgPttElement) {
return this.context.highway.uploadPtt({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgFileElement) {
return this.context.highway.uploadFile({ chatType, peerUid }, e);
}
return null;
}).filter(Boolean)
);
const res = await Promise.allSettled(reqList);
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}`);
this.context.logger.info(`上传资源${res.length}个,失败${res.filter(r => r.status === 'rejected').length}`);
res.forEach((result, index) => {
if (result.status === 'rejected') {
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
@@ -103,13 +100,10 @@ export class PacketOperationContext {
}
async UploadImage(img: PacketMsgPicElement) {
await this.context.highway.uploadImage(
{
chatType: ChatType.KCHATTYPEC2C,
peerUid: this.context.napcore.basicInfo.uid,
},
img
);
await this.context.highway.uploadImage({
chatType: ChatType.KCHATTYPEC2C,
peerUid: this.context.napcore.basicInfo.uid
}, img);
const index = img.msgInfo?.msgInfoBody?.at(0)?.index;
if (!index) {
throw new Error('img.msgInfo?.msgInfoBody![0].index! is undefined');
@@ -124,20 +118,6 @@ export class PacketOperationContext {
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetPttUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>) {
const req = trans.DownloadPtt.build(selfUid, node);
const resp = await this.context.client.sendOidbPacket(req, true);
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>) {
const req = trans.DownloadVideo.build(selfUid, node);
const resp = await this.context.client.sendOidbPacket(req, true);
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>) {
const req = trans.DownloadGroupImage.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true);
@@ -145,21 +125,6 @@ export class PacketOperationContext {
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
const req = trans.DownloadGroupPtt.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.DownloadImage.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetGroupVideoUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
const req = trans.DownloadGroupVideo.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.DownloadImage.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async ImageOCR(imgUrl: string) {
const req = trans.ImageOCR.build(imgUrl);
const resp = await this.context.client.sendOidbPacket(req, true);
@@ -172,86 +137,29 @@ export class PacketOperationContext {
coordinates: item.polygon.coordinates.map((c) => {
return {
x: c.x,
y: c.y,
y: c.y
};
}),
};
}),
language: res.ocrRspBody.language,
language: res.ocrRspBody.language
} as ImageOcrResult;
}
private async SendPreprocess(msg: PacketMsg[], groupUin: number = 0) {
const ps = msg.map((m) => {
return m.msg.map(async (e) => {
if (e instanceof PacketMsgReplyElement && !e.targetElems) {
this.context.logger.debug(`Cannot find reply element's targetElems, prepare to fetch it...`);
if (!e.targetPeer?.peerUid) {
this.context.logger.error(`targetPeer is undefined!`);
}
let targetMsg: NapProtoEncodeStructType<typeof PushMsgBody>[] | undefined;
if (e.isGroupReply) {
targetMsg = await this.FetchGroupMessage(+(e.targetPeer?.peerUid ?? 0), e.targetMessageSeq, e.targetMessageSeq);
} else {
targetMsg = await this.FetchC2CMessage(await this.context.napcore.basicInfo.uin2uid(e.targetUin), e.targetMessageSeq, e.targetMessageSeq);
}
e.targetElems = targetMsg.at(0)?.body?.richText?.elems;
e.targetSourceMsg = targetMsg.at(0);
}
});
}).flat();
await Promise.all(ps)
await this.UploadResources(msg, groupUin);
}
async FetchGroupMessage(groupUin: number, startSeq: number, endSeq: number): Promise<NapProtoDecodeStructType<typeof PushMsgBody>[]> {
const req = trans.FetchGroupMessage.build(groupUin, startSeq, endSeq);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.FetchGroupMessage.parse(resp);
return res.body.messages
}
async FetchC2CMessage(targetUid: string, startSeq: number, endSeq: number): Promise<NapProtoDecodeStructType<typeof PushMsgBody>[]> {
const req = trans.FetchC2CMessage.build(targetUid, startSeq, endSeq);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.FetchC2CMessage.parse(resp);
return res.messages
}
async UploadForwardMsg(msg: PacketMsg[], groupUin: number = 0) {
await this.SendPreprocess(msg, groupUin);
await this.UploadResources(msg, groupUin);
const req = trans.UploadForwardMsg.build(this.context.napcore.basicInfo.uid, msg, groupUin);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.UploadForwardMsg.parse(resp);
return res.result.resId;
}
async MoveGroupFile(
groupUin: number,
fileUUID: string,
currentParentDirectory: string,
targetParentDirectory: string
) {
const req = trans.MoveGroupFile.build(groupUin, fileUUID, currentParentDirectory, targetParentDirectory);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.MoveGroupFile.parse(resp);
return res.move.retCode;
}
async RenameGroupFile(groupUin: number, fileUUID: string, currentParentDirectory: string, newName: string) {
const req = trans.RenameGroupFile.build(groupUin, fileUUID, currentParentDirectory, newName);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.RenameGroupFile.parse(resp);
return res.rename.retCode;
}
async GetGroupFileUrl(groupUin: number, fileUUID: string) {
const req = trans.DownloadGroupFile.build(groupUin, fileUUID);
const resp = await this.context.client.sendOidbPacket(req, true);
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) {
const req = trans.DownloadPrivateFile.build(self_id, fileUUID, md5);
const resp = await this.context.client.sendOidbPacket(req, true);
@@ -259,6 +167,13 @@ export class PacketOperationContext {
return `http://${res.body?.result?.server}:${res.body?.result?.port}${res.body?.result?.url?.slice(8)}&isthumb=0`;
}
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
const req = trans.DownloadGroupPtt.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.DownloadGroupPtt.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetMiniAppAdaptShareInfo(param: MiniAppReqParams) {
const req = trans.GetMiniAppAdaptShareInfo.build(param);
const resp = await this.context.client.sendOidbPacket(req, true);
@@ -274,17 +189,12 @@ export class PacketOperationContext {
return res.content.map((item) => {
return {
category: item.category,
voices: item.voices,
voices: item.voices
};
});
}
async GetAiVoice(
groupUin: number,
voiceId: string,
text: string,
chatType: AIVoiceChatType
): Promise<NapProtoDecodeStructType<typeof MsgInfo>> {
async GetAiVoice(groupUin: number, voiceId: string, text: string, chatType: AIVoiceChatType): Promise<NapProtoDecodeStructType<typeof MsgInfo>> {
let reqTime = 0;
const reqMaxTime = 30;
const sessionId = crypto.randomBytes(4).readUInt32BE(0);
@@ -312,7 +222,6 @@ export class PacketOperationContext {
if (!main?.actionData.msgBody) {
throw new Error('msgBody is empty');
}
this.context.logger.debug('rawChains ', inflate.toString('hex'));
const messagesPromises = main.actionData.msgBody.map(async (msg) => {
if (!msg?.body?.richText?.elems) {
@@ -328,12 +237,12 @@ export class PacketOperationContext {
const groupUin = msg?.responseHead.grp?.groupUin ?? 0;
element.picElement = {
...element.picElement,
originImageUrl: await this.GetGroupImageUrl(groupUin, index!),
originImageUrl: await this.GetGroupImageUrl(groupUin, index!)
};
} else {
element.picElement = {
...element.picElement,
originImageUrl: await this.GetImageUrl(this.context.napcore.basicInfo.uid, index!),
originImageUrl: await this.GetImageUrl(this.context.napcore.basicInfo.uid, index!)
};
}
return element;
@@ -346,7 +255,7 @@ export class PacketOperationContext {
elements: elements,
guildId: '',
isOnlineMsg: false,
msgId: '7467703692092974645', // TODO: no necessary
msgId: '7467703692092974645', // TODO: no necessary
msgRandom: '0',
msgSeq: String(msg.contentHead.sequence ?? 0),
msgTime: String(msg.contentHead.timeStamp ?? 0),

View File

@@ -24,15 +24,12 @@ export class PacketMsgBuilder {
}
return {
responseHead: {
fromUin: node.senderUin,
type: 0,
sigMap: 0,
toUin: 0,
fromUid: '',
fromUin: node.senderUin,
toUid: node.groupId ? undefined : selfUid,
forward: node.groupId ? undefined : {
friendName: node.senderName,
},
toUid: node.groupId ? undefined : selfUid,
grp: node.groupId ? {
groupUin: node.groupId,
memberName: node.senderName,
@@ -43,13 +40,16 @@ export class PacketMsgBuilder {
type: node.groupId ? 82 : 9,
subType: node.groupId ? undefined : 4,
divSeq: node.groupId ? undefined : 4,
autoReply: 0,
msgId: crypto.randomBytes(4).readUInt32LE(0),
sequence: crypto.randomBytes(4).readUInt32LE(0),
timeStamp: +node.time.toString().substring(0, 10),
field7: BigInt(1),
field8: 0,
field9: 0,
forward: {
field1: 0,
field2: 0,
field3: node.groupId ? 1 : 2,
field3: node.groupId ? 0 : 2,
unknownBase64: avatar,
avatar: avatar
}

View File

@@ -10,7 +10,6 @@ import {
MsgInfo,
NotOnlineImage,
OidbSvcTrpcTcp0XE37_800Response,
PushMsgBody,
QBigFaceExtra,
QSmallFaceExtra,
} from '@/core/packet/transformer/proto';
@@ -30,8 +29,7 @@ import {
SendReplyElement,
SendMultiForwardMsgElement,
SendTextElement,
SendVideoElement,
Peer
SendVideoElement
} from '@/core';
import {ForwardMsgBuilder} from '@/common/forward-msg-builder';
import {PacketMsg, PacketSendMsgElement} from '@/core/packet/message/message';
@@ -148,40 +146,41 @@ export class PacketMsgAtElement extends PacketMsgTextElement {
}
export class PacketMsgReplyElement extends IPacketMsgElement<SendReplyElement> {
time: number;
targetMessageId: bigint;
targetMessageSeq: number;
targetMessageClientSeq: number;
messageId: bigint;
messageSeq: number;
messageClientSeq: number;
targetUin: number;
targetUid: string;
targetElems?: NapProtoEncodeStructType<typeof Elem>[];
targetSourceMsg?: NapProtoEncodeStructType<typeof PushMsgBody>;
targetPeer?: Peer;
time: number;
elems: PacketMsg[];
constructor(element: SendReplyElement) {
super(element);
this.time = +(element.replyElement.replyMsgTime ?? Math.floor(Date.now() / 1000));
this.targetMessageId = BigInt(element.replyElement.replayMsgId ?? 0);
this.targetMessageSeq = +(element.replyElement.replayMsgSeq ?? 0);
this.targetMessageClientSeq = +(element.replyElement.replyMsgClientSeq ?? 0);
this.messageId = BigInt(element.replyElement.replayMsgId ?? 0);
this.messageSeq = +(element.replyElement.replayMsgSeq ?? 0);
this.messageClientSeq = +(element.replyElement.replyMsgClientSeq ?? 0);
this.targetUin = +(element.replyElement.senderUin ?? 0);
this.targetUid = element.replyElement.senderUidStr ?? '';
this.targetPeer = element.replyElement._replyMsgPeer;
this.time = +(element.replyElement.replyMsgTime ?? 0);
this.elems = []; // TODO: in replyElement.sourceMsgTextElems
}
get isGroupReply(): boolean {
return this.targetMessageClientSeq === 0;
return this.messageClientSeq === 0;
}
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
return [{
srcMsg: {
origSeqs: [this.isGroupReply ? this.targetMessageSeq : this.targetMessageClientSeq],
origSeqs: [this.isGroupReply ? this.messageClientSeq : this.messageSeq],
senderUin: BigInt(this.targetUin),
time: this.time,
elems: this.targetElems ?? [],
sourceMsg: new NapProtoMsg(PushMsgBody).encode(this.targetSourceMsg ?? {}),
toUin: BigInt(0),
elems: [], // TODO: in replyElement.sourceMsgTextElems
pbReserve: {
messageId: this.messageId,
},
toUin: BigInt(this.targetUin),
type: 1,
}
}];
}

View File

@@ -1,35 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
class MoveGroupFile extends PacketTransformer<typeof proto.OidbSvcTrpcTcp0x6D6Response> {
constructor() {
super();
}
build(groupUin: number, fileUUID: string, currentParentDirectory: string, targetParentDirectory: string): OidbPacket {
const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6).encode({
move: {
groupUin: groupUin,
appId: 5,
busId: 102,
fileId: fileUUID,
parentDirectory: currentParentDirectory,
targetDirectory: targetParentDirectory,
}
});
return OidbBase.build(0x6D6, 5, body, true, false);
}
parse(data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
const res = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6Response).decode(oidbBody);
if (res.move.retCode !== 0) {
throw new Error(`sendGroupFileMoveReq error: ${res.move.clientWording} (code=${res.move.retCode})`);
}
return res;
}
}
export default new MoveGroupFile();

View File

@@ -1,34 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
class RenameGroupFile extends PacketTransformer<typeof proto.OidbSvcTrpcTcp0x6D6Response> {
constructor() {
super();
}
build(groupUin: number, fileUUID: string, currentParentDirectory: string, newName: string): OidbPacket {
const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6).encode({
rename: {
groupUin: groupUin,
busId: 102,
fileId: fileUUID,
parentFolder: currentParentDirectory,
newFileName: newName,
}
});
return OidbBase.build(0x6D6, 4, body, true, false);
}
parse(data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
const res = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6Response).decode(oidbBody);
if (res.rename.retCode !== 0) {
throw new Error(`sendGroupFileRenameReq error: ${res.rename.clientWording} (code=${res.rename.retCode})`);
}
return res;
}
}
export default new RenameGroupFile();

View File

@@ -6,5 +6,3 @@ export { default as GetStrangerInfo } from './GetStrangerInfo';
export { default as SendPoke } from './SendPoke';
export { default as SetSpecialTitle } from './SetSpecialTitle';
export { default as ImageOCR } from './ImageOCR';
export { default as MoveGroupFile } from './MoveGroupFile';
export { default as RenameGroupFile } from './RenameGroupFile';

View File

@@ -1,50 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
import { IndexNode } from '@/core/packet/transformer/proto';
class DownloadGroupVideo extends PacketTransformer<typeof proto.NTV2RichMediaResp> {
constructor() {
super();
}
build(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>): OidbPacket {
const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({
reqHead: {
common: {
requestId: 1,
command: 200
},
scene: {
requestType: 2,
businessType: 2,
sceneType: 2,
group: {
groupUin: groupUin
}
},
client: {
agentType: 2,
}
},
download: {
node: node,
download: {
video: {
busiType: 0,
sceneType: 0
}
}
}
});
return OidbBase.build(0x11EA, 200, body, true, false);
}
parse(data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody);
}
}
export default new DownloadGroupVideo();

View File

@@ -1,51 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
import { IndexNode } from '@/core/packet/transformer/proto';
class DownloadPtt extends PacketTransformer<typeof proto.NTV2RichMediaResp> {
constructor() {
super();
}
build(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>): OidbPacket {
const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({
reqHead: {
common: {
requestId: 1,
command: 200
},
scene: {
requestType: 1,
businessType: 3,
sceneType: 1,
c2C: {
accountType: 2,
targetUid: selfUid
},
},
client: {
agentType: 2,
}
},
download: {
node: node,
download: {
video: {
busiType: 0,
sceneType: 0
}
}
}
});
return OidbBase.build(0x126D, 200, body, true, false);
}
parse(data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody);
}
}
export default new DownloadPtt();

View File

@@ -1,51 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
import { IndexNode } from '@/core/packet/transformer/proto';
class DownloadVideo extends PacketTransformer<typeof proto.NTV2RichMediaResp> {
constructor() {
super();
}
build(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>): OidbPacket {
const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({
reqHead: {
common: {
requestId: 1,
command: 200
},
scene: {
requestType: 2,
businessType: 2,
sceneType: 1,
c2C: {
accountType: 2,
targetUid: selfUid
},
},
client: {
agentType: 2,
}
},
download: {
node: node,
download: {
video: {
busiType: 0,
sceneType: 0
}
}
}
});
return OidbBase.build(0x11E9, 200, body, true, false);
}
parse(data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody);
}
}
export default new DownloadVideo();

View File

@@ -13,6 +13,3 @@ export { default as UploadPrivatePtt } from './UploadPrivatePtt';
export { default as UploadPrivateVideo } from './UploadPrivateVideo';
export { default as DownloadImage } from './DownloadImage';
export { default as DownloadGroupImage } from './DownloadGroupImage';
export { default as DownloadVideo } from './DownloadVideo';
export { default as DownloadGroupVideo } from './DownloadGroupVideo';
export { default as DownloadPtt } from './DownloadPtt';

View File

@@ -1,27 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
class FetchC2CMessage extends PacketTransformer<typeof proto.SsoGetC2cMsgResponse> {
constructor() {
super();
}
build(targetUid: string, startSeq: number, endSeq: number): OidbPacket {
const req = new NapProtoMsg(proto.SsoGetC2cMsg).encode({
friendUid: targetUid,
startSequence: startSeq,
endSequence: endSeq,
});
return {
cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetC2cMsg',
data: PacketHexStrBuilder(req)
};
}
parse(data: Buffer) {
return new NapProtoMsg(proto.SsoGetC2cMsgResponse).decode(data);
}
}
export default new FetchC2CMessage();

View File

@@ -1,30 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
class FetchGroupMessage extends PacketTransformer<typeof proto.SsoGetGroupMsgResponse> {
constructor() {
super();
}
build(groupUin: number, startSeq: number, endSeq: number): OidbPacket {
const req = new NapProtoMsg(proto.SsoGetGroupMsg).encode({
info: {
groupUin: groupUin,
startSequence: startSeq,
endSequence: endSeq
},
direction: true
});
return {
cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetGroupMsg',
data: PacketHexStrBuilder(req)
};
}
parse(data: Buffer) {
return new NapProtoMsg(proto.SsoGetGroupMsgResponse).decode(data);
}
}
export default new FetchGroupMessage();

View File

@@ -1,4 +1,2 @@
export { default as UploadForwardMsg } from './UploadForwardMsg';
export { default as FetchGroupMessage } from './FetchGroupMessage';
export { default as FetchC2CMessage } from './FetchC2CMessage';
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
export { default as DownloadForwardMsg } from './DownloadForwardMsg';

View File

@@ -13,15 +13,13 @@ import {
export const ContentHead = {
type: ProtoField(1, ScalarType.UINT32),
subType: ProtoField(2, ScalarType.UINT32, true),
c2cCmd: ProtoField(3, ScalarType.UINT32, true),
ranDom: ProtoField(4, ScalarType.UINT32, true),
divSeq: ProtoField(3, ScalarType.UINT32, true),
msgId: ProtoField(4, ScalarType.UINT32, true),
sequence: ProtoField(5, ScalarType.UINT32, true),
timeStamp: ProtoField(6, ScalarType.UINT32, true),
pkgNum: ProtoField(7, ScalarType.UINT64, true),
pkgIndex: ProtoField(8, ScalarType.UINT32, true),
divSeq: ProtoField(9, ScalarType.UINT32, true),
autoReply: ProtoField(10, ScalarType.UINT32),
ntMsgSeq: ProtoField(10, ScalarType.UINT32, true),
field7: ProtoField(7, ScalarType.UINT64, true),
field8: ProtoField(8, ScalarType.UINT32, true),
field9: ProtoField(9, ScalarType.UINT32, true),
newId: ProtoField(12, ScalarType.UINT64, true),
forward: ProtoField(15, () => ForwardHead, true),
};

View File

@@ -1,6 +0,0 @@
import { ProtoField, ScalarType } from '@napneko/nap-proto-core';
export const FileId = {
appid: ProtoField(4, ScalarType.UINT32, true),
ttl: ProtoField(10, ScalarType.UINT32, true),
};

View File

@@ -106,15 +106,15 @@ export interface NodeIKernelBuddyService {
getAddMeSetting(): unknown;
getDoubtBuddyReq(reqId: string, num: number,uk:string): Promise<GeneralCallResult>;
getDoubtBuddyReq(): unknown;
getDoubtBuddyUnreadNum(): number;
approvalDoubtBuddyReq(uid: string, str1: string, str2: string): void;
approvalDoubtBuddyReq(uid: number, isAgree: boolean): void;
delDoubtBuddyReq(uid: number): void;
delAllDoubtBuddyReq(): Promise<GeneralCallResult>;
delAllDoubtBuddyReq(): void;
reportDoubtBuddyReqUnread(): void;

View File

@@ -249,7 +249,7 @@ export interface NodeIKernelGroupService {
reqToJoinGroup(groupCode: string, arg: unknown): void;
setGroupShutUp(groupCode: string, shutUp: boolean): Promise<GeneralCallResult>;
setGroupShutUp(groupCode: string, shutUp: boolean): void;
getGroupShutUpMemberList(groupCode: string): Promise<GeneralCallResult>;

View File

@@ -148,11 +148,10 @@ export interface NodeIKernelMsgService {
msgList: RawMessage[]
}>;
// getMsgService/getMsgs { chatType: 2, peerUid: '975206796', privilegeFlag: 336068800 } 0 20 true
getMsgs(peer: Peer & { privilegeFlag: number }, msgId: string, count: number, queryOrder: boolean): Promise<GeneralCallResult & {
msgList: RawMessage[]
}>;
//@deprecated
getMsgs(peer: Peer, msgId: string, count: unknown, queryOrder: boolean): Promise<unknown>;
//@deprecated
getMsgsIncludeSelf(peer: Peer, msgId: string, count: number, queryOrder: boolean): Promise<GeneralCallResult & {
msgList: RawMessage[]
}>;
@@ -426,20 +425,7 @@ export interface NodeIKernelMsgService {
switchToOfflineGetRichMediaElement(...args: unknown[]): unknown;
downloadRichMedia(args: {
fileModelId: string,
downSourceType: number,
triggerType: number,
msgId: string,
chatType: number,
peerUid: string,
elementId: string,
thumbSize: number,
downloadType: number,
filePath: string
} & {
downloadSourceType: number, //33800左右一下的老版本 新版34606已经完全上面格式
}): unknown;
downloadRichMedia(...args: unknown[]): unknown;
getFirstUnreadMsgSeq(args: {
peerUid: string

View File

@@ -1,5 +1,5 @@
import { AnyCnameRecord } from 'node:dns';
import { BizKey, ModifyProfileParams, NodeIKernelProfileListener, ProfileBizType, SimpleInfo, UserDetailInfoByUin, UserDetailInfoListenerArg, UserDetailSource } from '@/core';
import { BizKey, ModifyProfileParams, NodeIKernelProfileListener, ProfileBizType, SimpleInfo, UserDetailInfoByUin, UserDetailSource } from '@/core';
import { GeneralCallResult } from '@/core/services/common';
export interface NodeIKernelProfileService {
@@ -15,13 +15,7 @@ export interface NodeIKernelProfileService {
getCoreAndBaseInfo(callfrom: string, uids: string[]): Promise<Map<string, SimpleInfo>>;
fetchUserDetailInfo(trace: string, uids: string[], source: UserDetailSource, bizType: ProfileBizType[]): Promise<GeneralCallResult &
{
source: UserDetailSource,
// uid -> detail
detail: Map<string, UserDetailInfoListenerArg>,
}
>;
fetchUserDetailInfo(trace: string, uids: string[], source: UserDetailSource, bizType: ProfileBizType[]): Promise<GeneralCallResult>;
addKernelProfileListener(listener: NodeIKernelProfileListener): number;

View File

@@ -198,29 +198,9 @@ export interface NodeIKernelRichMediaService {
renameGroupFile(arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown, arg5: unknown): unknown;
moveGroupFile(groupCode: string, busId: Array<number>, fileList: Array<string>, currentParentDirectory: string, targetParentDirectory: string): Promise<GeneralCallResult & {
moveGroupFileResult: {
result: {
retCode: number,
retMsg: symbol,
clientWording: string
},
successFileIdList: Array<string>,
failFileIdList: Array<string>
}
}>;
moveGroupFile(arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown, arg5: unknown): unknown;
transGroupFile(groupCode: string, fileId: string): Promise<GeneralCallResult & {
transGroupFileResult: {
result: {
retCode: number
retMsg: string
clientWording: string
}
saveBusId: number
saveFilePath: string
}
}>;
transGroupFile(arg1: unknown, arg2: unknown): unknown;
searchGroupFile(
keywords: Array<string>,

View File

@@ -1,15 +1,4 @@
import {
ElementType,
MessageElement,
NTGrayTipElementSubTypeV2,
PicSubType,
PicType,
TipAioOpGrayTipElement,
TipGroupElement,
NTVideoType,
FaceType,
Peer
} from './msg';
import { ElementType, MessageElement, NTGrayTipElementSubTypeV2, PicSubType, PicType, TipAioOpGrayTipElement, TipGroupElement, NTVideoType, FaceType } from './msg';
type ElementFullBase = Omit<MessageElement, 'elementType' | 'elementId' | 'extBufForUI'>;
@@ -224,9 +213,6 @@ export interface ReplyElement {
senderUidStr?: string;
replyMsgTime?: string;
replyMsgClientSeq?: string;
// HACK: Attributes that were not originally available,
// but were added due to NTQQ and NapCat's internal implementation, are used to supplement NapCat
_replyMsgPeer?: Peer;
}
export interface CalendarElement {

View File

@@ -403,7 +403,7 @@ export interface NTGroupGrayMember {
}
/**
* 群灰色提示邀请者和被邀请者接口
*
*
* */
export interface NTGroupGrayInviterAndInvite {
invited: NTGroupGrayMember;
@@ -501,15 +501,13 @@ export interface RawMessage {
elements: MessageElement[];// 消息元素
sourceType: MsgSourceType;// 消息来源类型
isOnlineMsg: boolean;// 是否为在线消息
clientSeq?: string;
}
/**
* 查询消息参数接口
*/
export interface QueryMsgsParams {
chatInfo: Peer & { privilegeFlag?: number };
//searchFields: number;
chatInfo: Peer;
filterMsgType: Array<{ type: NTMsgType, subType: Array<number> }>;
filterSendersUid: string[];
filterMsgFromTime: string;
@@ -567,4 +565,4 @@ export enum FaceType {
AniSticke = 3, // 动画贴纸
Lottie = 4,// 新格式表情
Poke = 5 // 可变Poke
}
}

View File

@@ -132,26 +132,18 @@ export enum BuddyReqType {
KMEINITIATORWAITPEERCONFIRM = 13
}
// 其中 ? 代表新版本参数
export interface FriendRequest {
isBuddy?: boolean;
isInitiator?: boolean;
isDecide: boolean;
friendUid: string;
reqType: BuddyReqType,
reqTime: string; // 时间戳 秒
flag?: number; // 0
preGroupingId?: number; // 0
commFriendNum?: number; // 共同好友数
extWords: string; // 申请人填写的验证消息
isUnread: boolean;
isDoubt?: boolean; // 是否是可疑的好友请求
nameMore?: string;
friendNick: string;
sourceId: number;
groupCode: string;
isBuddy?: boolean;
isAgreed?: boolean;
relation?: number;
groupCode: string
}
export interface FriendRequestNotify {

View File

@@ -115,7 +115,7 @@ export interface GroupEssenceMsg {
add_digest_uin: string;
add_digest_nick: string;
add_digest_time: number;
msg_content: { msg_type: number, text?: string, image_url?: string }[];
msg_content: unknown[];
can_be_removed: true;
}

View File

@@ -9,8 +9,6 @@ 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';
//Framework ES入口文件
export async function getWebUiUrl() {
@@ -38,15 +36,6 @@ export async function NCoreInitFramework(
const logger = new LogWrapper(pathWrapper.logsPath);
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion());
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 = await new Promise<SelfInfo>((resolveSelfInfo) => {
const loginListener = new NodeIKernelLoginListener();

View File

@@ -1,426 +0,0 @@
import * as fs from 'fs';
import { ReadStream } from 'fs';
export interface ImageSize {
width: number;
height: number;
}
export enum ImageType {
JPEG = 'jpeg',
PNG = 'png',
BMP = 'bmp',
GIF = 'gif',
WEBP = 'webp',
UNKNOWN = 'unknown',
}
interface ImageParser {
readonly type: ImageType;
canParse(buffer: Buffer): boolean;
parseSize(stream: ReadStream): Promise<ImageSize | undefined>;
}
// 魔术匹配
function matchMagic(buffer: Buffer, magic: number[], offset = 0): boolean {
if (buffer.length < offset + magic.length) {
return false;
}
for (let i = 0; i < magic.length; i++) {
if (buffer[offset + i] !== magic[i]) {
return false;
}
}
return true;
}
// PNG解析器
class PngParser implements ImageParser {
readonly type = ImageType.PNG;
// PNG 魔术头89 50 4E 47 0D 0A 1A 0A
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
canParse(buffer: Buffer): boolean {
return matchMagic(buffer, this.PNG_SIGNATURE);
}
async parseSize(stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(24) as Buffer;
if (!buf || buf.length < 24) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
// JPEG解析器
class JpegParser implements ImageParser {
readonly type = ImageType.JPEG;
// JPEG 魔术头FF D8
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
// JPEG标记常量
private readonly SOF_MARKERS = {
SOF0: 0xC0, // 基线DCT
SOF1: 0xC1, // 扩展顺序DCT
SOF2: 0xC2, // 渐进式DCT
SOF3: 0xC3, // 无损
} as const;
// 非SOF标记
private readonly NON_SOF_MARKERS: number[] = [
0xC4, // DHT
0xC8, // JPEG扩展
0xCC, // DAC
] as const;
canParse(buffer: Buffer): boolean {
return matchMagic(buffer, this.JPEG_SIGNATURE);
}
isSOFMarker(marker: number): boolean {
return (
marker === this.SOF_MARKERS.SOF0 ||
marker === this.SOF_MARKERS.SOF1 ||
marker === this.SOF_MARKERS.SOF2 ||
marker === this.SOF_MARKERS.SOF3
);
}
isNonSOFMarker(marker: number): boolean {
return this.NON_SOF_MARKERS.includes(marker);
}
async parseSize(stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise<ImageSize | undefined>((resolve, reject) => {
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
let buffer = Buffer.alloc(0);
let offset = 0;
let found = false;
// 处理错误
stream.on('error', (err) => {
stream.destroy();
reject(err);
});
// 处理数据块
stream.on('data', (chunk: Buffer | string) => {
// 追加新数据到缓冲区
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
offset = 0;
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
const bufferSize = buffer.length;
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
// 从JPEG头部后开始扫描
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
// 寻找FF标记
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
const marker = buffer[offset + 1];
if (!marker) {
break;
}
// 跳过非SOF标记
if (this.isNonSOFMarker(marker)) {
offset += 2;
continue;
}
// 处理SOF标记 (包含尺寸信息)
if (this.isSOFMarker(marker)) {
// 确保缓冲区中有足够数据读取尺寸
if (offset + 9 < bufferSize) {
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
found = true;
stream.destroy();
resolve({ width, height });
return;
} else {
// 如果缓冲区内数据不够,保留当前位置等待更多数据
break;
}
}
}
offset++;
}
// 缓冲区管理: 如果处理了许多数据但没找到标记,
// 保留最后N字节用于跨块匹配丢弃之前的数据
if (offset > BUFFER_SIZE) {
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
if (offset > KEEP_BYTES) {
buffer = buffer.subarray(offset - KEEP_BYTES);
offset = KEEP_BYTES;
}
}
});
// 处理流结束
stream.on('end', () => {
if (!found) {
resolve(undefined);
}
});
});
}
}
// BMP解析器
class BmpParser implements ImageParser {
readonly type = ImageType.BMP;
// BMP 魔术头42 4D (BM)
private readonly BMP_SIGNATURE = [0x42, 0x4D];
canParse(buffer: Buffer): boolean {
return matchMagic(buffer, this.BMP_SIGNATURE);
}
async parseSize(stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(26) as Buffer;
if (!buf || buf.length < 26) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt32LE(18);
const height = buf.readUInt32LE(22);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
// GIF解析器
class GifParser implements ImageParser {
readonly type = ImageType.GIF;
// GIF87a 魔术头47 49 46 38 37 61
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
// GIF89a 魔术头47 49 46 38 39 61
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
canParse(buffer: Buffer): boolean {
return (
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
matchMagic(buffer, this.GIF89A_SIGNATURE)
);
}
async parseSize(stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(10) as Buffer;
if (!buf || buf.length < 10) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt16LE(6);
const height = buf.readUInt16LE(8);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
class WebpParser implements ImageParser {
readonly type = ImageType.WEBP;
// WEBP RIFF 头52 49 46 46 (RIFF)
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
// WEBP 魔术头57 45 42 50 (WEBP)
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
// WEBP 块头
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
canParse(buffer: Buffer): boolean {
return (
buffer.length >= 12 &&
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
);
}
isChunkType(buffer: Buffer, offset: number, chunkType: number[]): boolean {
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
}
async parseSize(stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
// 需要读取足够的字节来检测所有三种格式
const MAX_HEADER_SIZE = 32;
let totalBytes = 0;
let buffer = Buffer.alloc(0);
stream.on('error', reject);
stream.on('data', (chunk: Buffer | string) => {
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
buffer = Buffer.concat([buffer, chunkBuffer]);
totalBytes += chunk.length;
// 检查是否有足够的字节进行格式检测
if (totalBytes >= MAX_HEADER_SIZE) {
stream.destroy();
// 检查基本的WEBP签名
if (!this.canParse(buffer)) {
return resolve(undefined);
}
// 检查chunk头部位于字节12-15
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
// VP8格式 - 标准WebP
// 宽度和高度在帧头中
const width = buffer.readUInt16LE(26) & 0x3FFF;
const height = buffer.readUInt16LE(28) & 0x3FFF;
return resolve({ width, height });
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
// VP8L格式 - 无损WebP
// 1字节标记后是14位宽度和14位高度
const bits = buffer.readUInt32LE(21);
const width = 1 + (bits & 0x3FFF);
const height = 1 + ((bits >> 14) & 0x3FFF);
return resolve({ width, height });
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
// VP8X格式 - 扩展WebP
// 24位宽度和高度(减去1)
if (!buffer[24] || !buffer[25] || !buffer[26] || !buffer[27] || !buffer[28] || !buffer[29]) {
return resolve(undefined);
}
const width = 1 + ((buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) & 0xFFFFFF);
const height = 1 + ((buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) & 0xFFFFFF);
return resolve({ width, height });
} else {
// 未知的WebP子格式
return resolve(undefined);
}
}
});
stream.on('end', () => {
// 如果没有读到足够的字节
if (totalBytes < MAX_HEADER_SIZE) {
resolve(undefined);
}
});
});
}
}
const parsers: ReadonlyArray<ImageParser> = [
new PngParser(),
new JpegParser(),
new BmpParser(),
new GifParser(),
new WebpParser(),
];
export async function detectImageType(filePath: string): Promise<ImageType> {
return new Promise((resolve, reject) => {
const stream = fs.createReadStream(filePath, {
highWaterMark: 64, // 优化读取buffer大小
start: 0,
end: 63
});
let buffer: Buffer | null = null;
stream.once('error', (err) => {
stream.destroy();
reject(err);
});
stream.once('readable', () => {
buffer = stream.read(64) as Buffer;
stream.destroy();
if (!buffer) {
return resolve(ImageType.UNKNOWN);
}
for (const parser of parsers) {
if (parser.canParse(buffer)) {
return resolve(parser.type);
}
}
resolve(ImageType.UNKNOWN);
});
stream.once('end', () => {
if (!buffer) {
resolve(ImageType.UNKNOWN);
}
});
});
}
export async function imageSizeFromFile(filePath: string): Promise<ImageSize | undefined> {
try {
// 先检测类型
const type = await detectImageType(filePath);
const parser = parsers.find(p => p.type === type);
if (!parser) {
return undefined;
}
// 用流式方式解析尺寸
const stream = fs.createReadStream(filePath);
try {
return await parser.parseSize(stream);
} catch (err) {
console.error(`解析图片尺寸出错: ${err}`);
return undefined;
} finally {
if (!stream.destroyed) {
stream.destroy();
}
}
} catch (err) {
console.error(`检测图片类型出错: ${err}`);
return undefined;
}
}
export async function imageSizeFallBack(
filePath: string,
fallback: ImageSize = {
width: 1024,
height: 1024,
}
): Promise<ImageSize> {
return await imageSizeFromFile(filePath) ?? fallback;
}

View File

@@ -1,372 +0,0 @@
/*!
* on-finished
* Copyright(c) 2013 Jonathan Ong
* Copyright(c) 2014 Douglas Christopher Wilson
* MIT Licensed
*/
'use strict'
/**
* 定义消息对象接口
*/
interface Message {
socket?: Socket;
finished?: boolean;
complete?: boolean;
readable?: boolean;
upgrade?: boolean;
__onFinished?: Listener;
on?: (event: string, callback: (socket: Socket) => void) => void;
removeListener?: (event: string, callback: Function) => void;
assignSocket?: (socket: Socket) => void;
}
/**
* 定义Socket接口
*/
interface Socket {
writable: boolean;
readable: boolean;
on?: (event: string, callback: Function) => void;
removeListener?: (event: string, callback: Function) => void;
}
/**
* 事件处理器类型
*/
type EventHandler = (err?: Error) => void;
/**
* 事件元组: [对象, ...事件名称]
*/
type EventTuple = [unknown, ...string[]];
/**
* 取消函数类型
*/
interface CancelableListener {
cancel: () => void;
}
/**
* 监听器函数类型
*/
type ListenerCallback = (err: Error | null, msg: Message) => void;
/**
* 修改监听器接口,使其参数类型更通用
*/
interface Listener {
(err: Error | null | undefined): void;
queue: ListenerCallback[] | null;
}
/**
* 模块导出
* @public
*/
export { onFinished as default, isFinished };
/**
* 模块依赖
* @private
*/
const asyncHooks = tryRequireAsyncHooks();
/**
* 变量
* @private
*/
const defer = function(fn: ListenerCallback, err: Error | null, msg: Message): void {
setImmediate(fn, err, msg);
};
/**
* 实现 ee-first 功能:监听多个事件,当第一个事件触发时执行回调并移除所有监听器
* @param {Array<EventTuple>} stuff - 事件元组数组
* @param {EventHandler} done - 完成时的回调
* @returns {CancelableListener} 可取消的监听器对象
* @private
*/
function first(stuff: EventTuple[], done: EventHandler): CancelableListener {
if (!Array.isArray(stuff)) {
throw new TypeError('参数 "stuff" 必须是数组');
}
if (typeof done !== 'function') {
throw new TypeError('参数 "done" 必须是函数');
}
const cleanups: Function[] = [];
let finished = false;
// 处理所有传入的对象和事件
for (const tuple of stuff) {
if (!Array.isArray(tuple) || tuple.length < 2) {
throw new TypeError('每个事件元组必须是长度大于等于2的数组');
}
const obj = tuple[0];
if (!obj || typeof obj !== 'object') {
throw new TypeError('监听目标必须是对象');
}
// 使用类型断言确保obj有on和removeListener方法
const target = obj as {
on: (event: string, callback: Function) => void;
removeListener: (event: string, callback: Function) => void;
};
if (typeof target.on !== 'function' || typeof target.removeListener !== 'function') {
throw new TypeError('监听目标必须支持 on/removeListener 方法');
}
const events = tuple.slice(1);
// 为每个事件创建一个监听器
for (const event of events) {
if (typeof event !== 'string') {
throw new TypeError('事件名称必须是字符串');
}
const listener = createEventListener(target, event, cleanup);
cleanups.push(listener.cleanup);
target.on(event, listener.callback);
}
}
// 创建可取消的监听器对象
return {
cancel: cleanup
};
// 清理函数
function cleanup(): void {
if (finished) return;
finished = true;
// 执行所有清理函数
for (const cleanup of cleanups) {
try {
cleanup();
} catch (err) {
console.error('清理事件监听器时出错:', err);
}
}
}
// 创建单个事件监听器
function createEventListener(
obj: { on: Function; removeListener: Function },
event: string,
cleanup: Function
): {
callback: (arg?: unknown) => void;
cleanup: () => void;
} {
const callback = (arg?: unknown): void => {
cleanup();
done(arg instanceof Error ? arg : undefined);
};
return {
callback,
cleanup: () => {
obj.removeListener(event, callback);
}
};
}
}
/**
* 当响应结束时调用回调,用于资源清理
*
* @param {Message} msg
* @param {ListenerCallback} listener
* @return {Message}
* @public
*/
function onFinished(msg: Message, listener: ListenerCallback): Message {
if (!msg || typeof msg !== 'object') {
throw new TypeError('参数 "msg" 必须是对象');
}
if (typeof listener !== 'function') {
throw new TypeError('参数 "listener" 必须是函数');
}
if (isFinished(msg) !== false) {
defer(listener, null, msg);
return msg;
}
// 将监听器附加到消息
attachListener(msg, wrap(listener));
return msg;
}
/**
* 确定消息是否已完成
*
* @param {Message} msg
* @return {boolean | undefined}
* @public
*/
function isFinished(msg: Message): boolean | undefined {
if (!msg || typeof msg !== 'object') {
throw new TypeError('参数 "msg" 必须是对象');
}
const socket = msg.socket;
if (typeof msg.finished === 'boolean') {
// OutgoingMessage
return Boolean(msg.finished || (socket && !socket.writable));
}
if (typeof msg.complete === 'boolean') {
// IncomingMessage
return Boolean(msg.upgrade || !socket || !socket.readable || (msg.complete && !msg.readable));
}
// 不确定状态
return undefined;
}
/**
* 将完成监听器附加到消息
*
* @param {Message} msg
* @param {Function} callback
* @private
*/
function attachFinishedListener(msg: Message, callback: (error?: Error) => void): void {
let eeMsg: CancelableListener;
let eeSocket: CancelableListener;
let finished = false;
function onFinish(error?: Error): void {
if (!eeMsg || !eeSocket) {
return; // 防御性检查
}
eeMsg.cancel();
eeSocket.cancel();
finished = true;
callback(error);
}
// 在第一个消息事件上完成
eeMsg = eeSocket = first([[msg, 'end', 'finish']], onFinish);
function onSocket(socket: Socket): void {
// 移除监听器
if (msg.removeListener && typeof msg.removeListener === 'function') {
msg.removeListener('socket', onSocket);
}
if (finished) return;
if (eeMsg !== eeSocket) return;
if (!socket || typeof socket !== 'object') {
return; // 防御性检查
}
// 在第一个socket事件上完成
eeSocket = first([[socket, 'error', 'close']], onFinish);
}
if (msg.socket) {
// socket已分配
onSocket(msg.socket);
return;
}
// 等待socket分配
if (msg.on && typeof msg.on === 'function') {
msg.on('socket', onSocket);
}
}
/**
* 将监听器附加到消息
*
* @param {Message} msg
* @param {ListenerCallback} listener
* @private
*/
function attachListener(msg: Message, listener: ListenerCallback): void {
let attached = msg.__onFinished;
// 创建私有单一监听器和队列
if (!attached || !attached.queue) {
attached = msg.__onFinished = createListener(msg);
attachFinishedListener(msg, attached);
}
if (attached.queue) {
attached.queue.push(listener);
}
}
/**
* 在消息上创建监听器
*
* @param {Message} msg
* @return {Listener}
* @private
*/
function createListener(msg: Message): Listener {
const listener: Listener = function listener(err: Error | null | undefined): void {
if (msg.__onFinished === listener) msg.__onFinished = undefined;
if (!listener.queue) return;
const queue = listener.queue;
listener.queue = null;
for (let i = 0; i < queue.length; i++) {
queue[i]?.(err || null, msg);
}
};
listener.queue = [];
return listener;
}
/**
* 尝试引入async_hooks
* @private
*/
function tryRequireAsyncHooks(): { AsyncResource?: typeof import('async_hooks').AsyncResource } {
try {
return require('async_hooks');
} catch (e) {
return {};
}
}
/**
* 如果可能用async resource包装函数
* @private
*/
function wrap(fn: ListenerCallback): ListenerCallback {
if (typeof fn !== 'function') {
throw new TypeError('参数必须是函数');
}
// Node.js 16+ 总是有 AsyncResource
if (!asyncHooks.AsyncResource) {
return fn;
}
// 创建匿名资源
const res = new asyncHooks.AsyncResource(fn.name || 'bound-anonymous-fn');
// 返回绑定函数
return (res.runInAsyncScope.bind(res, fn, null) as ListenerCallback);
}

View File

@@ -1,33 +0,0 @@
import { ActionName } from '@/onebot/action/router';
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]),
file_id: Type.String(),
current_parent_directory: Type.String(),
target_parent_directory: Type.String(),
});
type Payload = Static<typeof SchemaData>;
interface MoveGroupFileResponse {
ok: boolean;
}
export class MoveGroupFile extends GetPacketStatusDepends<Payload, MoveGroupFileResponse> {
override actionName = ActionName.MoveGroupFile;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id);
if (contextMsgFile?.fileUUID) {
await this.core.apis.PacketApi.pkt.operation.MoveGroupFile(+payload.group_id, contextMsgFile.fileUUID, payload.current_parent_directory, payload.target_parent_directory);
return {
ok: true,
};
}
throw new Error('real fileUUID not found!');
}
}

View File

@@ -1,33 +0,0 @@
import { ActionName } from '@/onebot/action/router';
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]),
file_id: Type.String(),
current_parent_directory: Type.String(),
new_name: Type.String(),
});
type Payload = Static<typeof SchemaData>;
interface RenameGroupFileResponse {
ok: boolean;
}
export class RenameGroupFile extends GetPacketStatusDepends<Payload, RenameGroupFileResponse> {
override actionName = ActionName.RenameGroupFile;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id);
if (contextMsgFile?.fileUUID) {
await this.core.apis.PacketApi.pkt.operation.RenameGroupFile(+payload.group_id, contextMsgFile.fileUUID, payload.current_parent_directory, payload.new_name);
return {
ok: true,
};
}
throw new Error('real fileUUID not found!');
}
}

View File

@@ -1,34 +0,0 @@
import { ActionName } from '@/onebot/action/router';
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]),
file_id: Type.String(),
});
type Payload = Static<typeof SchemaData>;
interface TransGroupFileResponse {
ok: boolean;
}
export class TransGroupFile extends GetPacketStatusDepends<Payload, TransGroupFileResponse> {
override actionName = ActionName.TransGroupFile;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id);
if (contextMsgFile?.fileUUID) {
const result = await this.core.apis.GroupApi.transGroupFile(payload.group_id.toString(), contextMsgFile.fileUUID);
if (result.transGroupFileResult.result.retCode === 0) {
return {
ok: true
};
}
throw new Error(result.transGroupFileResult.result.retMsg);
}
throw new Error('real fileUUID not found!');
}
}

View File

@@ -4,14 +4,14 @@ import { ActionName } from '@/onebot/action/router';
import { MessageUnique } from '@/common/message-unique';
import { Static, Type } from '@sinclair/typebox';
import { ChatType, ElementType, MsgSourceType, NTMsgType, RawMessage } from '@/core';
import { isNumeric } from '@/common/helper';
const SchemaData = Type.Object({
message_id: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
message_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
});
type Payload = Static<typeof SchemaData>;
export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
messages: OB11Message[] | undefined;
}> {
@@ -53,21 +53,19 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
}
async _handle(payload: Payload) {
// 1. 检查消息ID是否存在
const msgId = payload.message_id || payload.id;
if (!msgId) {
throw new Error('message_id is required');
}
// 2. 定义辅助函数 - 创建伪转发消息对象
const createFakeForwardMsg = (resId: string): RawMessage => {
const fakeForwardMsg = (res_id: string) => {
return {
chatType: ChatType.KCHATTYPEGROUP,
elements: [{
elementType: ElementType.MULTIFORWARD,
elementId: '',
multiForwardMsgElement: {
resId: resId,
resId: res_id,
fileName: '',
xmlContent: '',
}
@@ -98,9 +96,8 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
} as RawMessage;
};
// 3. 定义协议回退逻辑函数
const protocolFallbackLogic = async (resId: string) => {
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId)))?.arrayMsg;
const protocolFallbackLogic = async (res_id: string) => {
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(fakeForwardMsg(res_id)))?.arrayMsg;
if (ob) {
return {
messages: (ob?.message?.[0] as OB11MessageForward)?.data?.content
@@ -108,37 +105,31 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
}
throw new Error('protocolFallbackLogic: 找不到相关的聊天记录');
};
// 4. 尝试通过正常渠道获取消息
// 如果是数字ID优先使用getMsgsByMsgId获取
if (!isNumeric(msgId)) {
let ret = await protocolFallbackLogic(msgId);
if (ret.messages) {
return ret;
}
throw new Error('ResId无效: 找不到相关的聊天记录');
}
const rootMsgId = MessageUnique.getShortIdByMsgId(msgId.toString());
const rootMsg = MessageUnique.getMsgIdAndPeerByShortId(rootMsgId ?? +msgId);
if (rootMsg) {
// 5. 获取消息内容
const data = await this.core.apis.MsgApi.getMsgsByMsgId(rootMsg.Peer, [rootMsg.MsgId]);
if (data && data.result === 0 && data.msgList.length > 0) {
const singleMsg = data.msgList[0];
if (!singleMsg) {
throw new Error('消息不存在或已过期');
}
// 6. 解析消息内容
const resMsg = (await this.obContext.apis.MsgApi.parseMessageV2(singleMsg))?.arrayMsg;
const forwardContent = (resMsg?.message?.[0] as OB11MessageForward)?.data?.content;
if (forwardContent) {
return { messages: forwardContent };
}
}
if (!rootMsg) {
return await protocolFallbackLogic(msgId.toString());
}
// 说明消息已过期或者为内层消息 NapCat 一次返回不处理内层消息
throw new Error('消息已过期或者为内层消息,无法获取转发消息');
const data = await this.core.apis.MsgApi.getMsgsByMsgId(rootMsg.Peer, [rootMsg.MsgId]);
if (!data || data.result !== 0) {
return await protocolFallbackLogic(msgId.toString());
}
const singleMsg = data.msgList[0];
if (!singleMsg) {
return await protocolFallbackLogic(msgId.toString());
}
const resMsg = (await this.obContext.apis.MsgApi.parseMessageV2(singleMsg))?.arrayMsg;//强制array 以便处理
if (!(resMsg?.message?.[0] as OB11MessageForward)?.data?.content) {
return await protocolFallbackLogic(msgId.toString());
}
return {
messages: (resMsg?.message?.[0] as OB11MessageForward)?.data?.content
};
//}
// return { message: resMsg };
}
}

View File

@@ -11,10 +11,10 @@ interface Response {
messages: OB11Message[];
}
const SchemaData = Type.Object({
user_id: Type.String(),
message_seq: Type.Optional(Type.String()),
count: Type.Number({ default: 20 }),
reverseOrder: Type.Boolean({ default: false })
user_id: Type.Union([Type.Number(), Type.String()]),
message_seq: Type.Optional(Type.Union([Type.Number(), Type.String()])),
count: Type.Union([Type.Number(), Type.String()], { default: 20 }),
reverseOrder: Type.Optional(Type.Union([Type.Boolean(), Type.String()]))
});
@@ -27,14 +27,18 @@ export default class GetFriendMsgHistory extends OneBotAction<Payload, Response>
async _handle(payload: Payload, _adapter: string, config: NetworkAdapterConfig): Promise<Response> {
//处理参数
const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
const isReverseOrder = typeof payload.reverseOrder === 'string' ? payload.reverseOrder === 'true' : !!payload.reverseOrder;
if (!uid) throw new Error(`记录${payload.user_id}不存在`);
const friend = await this.core.apis.FriendApi.isBuddy(uid);
const peer = { chatType: friend ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid: uid };
const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0');
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
const msgList = hasMessageSeq ?
(await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
(await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
//翻转消息
if (isReverseOrder) msgList.reverse();
//转换序号
await Promise.all(msgList.map(async msg => {
msg.id = MessageUnique.createUniqueMsgId({ guildId: '', chatType: msg.chatType, peerUid: msg.peerUid }, msg.msgId);

View File

@@ -11,10 +11,10 @@ interface Response {
}
const SchemaData = Type.Object({
group_id: Type.String(),
message_seq: Type.Optional(Type.String()),
count: Type.Number({ default: 20 }),
reverseOrder: Type.Boolean({ default: false })
group_id: Type.Union([Type.Number(), Type.String()]),
message_seq: Type.Optional(Type.Union([Type.Number(), Type.String()])),
count: Type.Union([Type.Number(), Type.String()], { default: 20 }),
reverseOrder: Type.Optional(Type.Union([Type.Boolean(), Type.String()]))
});
@@ -26,13 +26,17 @@ export default class GoCQHTTPGetGroupMsgHistory extends OneBotAction<Payload, Re
override payloadSchema = SchemaData;
async _handle(payload: Payload, _adapter: string, config: NetworkAdapterConfig): Promise<Response> {
//处理参数
const isReverseOrder = typeof payload.reverseOrder === 'string' ? payload.reverseOrder === 'true' : !!payload.reverseOrder;
const peer: Peer = { chatType: ChatType.KCHATTYPEGROUP, peerUid: payload.group_id.toString() };
const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0');
//拉取消息
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
const msgList = hasMessageSeq ?
(await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
(await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
//翻转消息
if (isReverseOrder) msgList.reverse();
//转换序号
await Promise.all(msgList.map(async msg => {
msg.id = MessageUnique.createUniqueMsgId({ guildId: '', chatType: msg.chatType, peerUid: msg.peerUid }, msg.msgId);

View File

@@ -3,7 +3,7 @@ import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
model: Type.Optional(Type.String()),
model: Type.String(),
});
type Payload = Static<typeof SchemaData>;

View File

@@ -38,7 +38,6 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, null>
deleteAfterSentFiles: []
};
const sendFileEle = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id);
msgContext.deleteAfterSentFiles.push(downloadResult.path);
await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles);
return null;
}

View File

@@ -23,7 +23,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, nul
if (payload.user_id) {
const peerUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
if (!peerUid) {
throw new Error(`私聊${payload.user_id}不存在`);
throw new Error( `私聊${payload.user_id}不存在`);
}
const isBuddy = await this.core.apis.FriendApi.isBuddy(peerUid);
return { chatType: isBuddy ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid };
@@ -48,7 +48,6 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, nul
deleteAfterSentFiles: []
};
const sendFileEle: SendFileElement = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name);
msgContext.deleteAfterSentFiles.push(downloadResult.path);
await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles);
return null;
}

View File

@@ -20,7 +20,6 @@ class GetGroupInfo extends OneBotAction<Payload, OB11Group> {
const data = await this.core.apis.GroupApi.fetchGroupDetail(payload.group_id.toString());
return {
...data,
group_all_shut: data.shutUpAllTimestamp > 0 ? -1 : 0,
group_remark: '',
group_id: +payload.group_id,
group_name: data.groupName,

View File

@@ -15,10 +15,7 @@ export default class SetGroupWholeBan extends OneBotAction<Payload, null> {
async _handle(payload: Payload): Promise<null> {
const enable = payload.enable?.toString() !== 'false';
let res = await this.core.apis.GroupApi.banGroup(payload.group_id.toString(), enable);
if (res.result !== 0) {
throw new Error(`SetGroupWholeBan failed: ${res.errMsg} ${res.result}`);
}
await this.core.apis.GroupApi.banGroup(payload.group_id.toString(), enable);
return null;
}
}

View File

@@ -109,24 +109,10 @@ import { ClickInlineKeyboardButton } from './extends/ClickInlineKeyboardButton';
import { GetPrivateFileUrl } from './file/GetPrivateFileUrl';
import { GetUnidirectionalFriendList } from './extends/GetUnidirectionalFriendList';
import SetGroupRemark from './extends/SetGroupRemark';
import { MoveGroupFile } from './extends/MoveGroupFile';
import { TransGroupFile } from './extends/TransGroupFile';
import { RenameGroupFile } from './extends/RenameGroupFile';
import { GetRkeyServer } from './packet/GetRkeyServer';
import { GetRkeyEx } from './packet/GetRkeyEx';
import { CleanCache } from './system/CleanCache';
import SetFriendRemark from './user/SetFriendRemark';
import { SetDoubtFriendsAddRequest } from './new/SetDoubtFriendsAddRequest';
import { GetDoubtFriendsAddRequest } from './new/GetDoubtFriendsAddRequest';
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
const actionHandlers = [
new SetDoubtFriendsAddRequest(obContext, core),
new GetDoubtFriendsAddRequest(obContext, core),
new SetFriendRemark(obContext, core),
new GetRkeyEx(obContext, core),
new GetRkeyServer(obContext, core),
new SetGroupRemark(obContext, core),
new GetGroupInfoEx(obContext, core),
new FetchEmojiLike(obContext, core),
@@ -146,9 +132,6 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new SetGroupSign(obContext, core),
new SendGroupSign(obContext, core),
new GetClientkey(obContext, core),
new MoveGroupFile(obContext, core),
new RenameGroupFile(obContext, core),
new TransGroupFile(obContext, core),
// onebot11
new SendLike(obContext, core),
new GetMsg(obContext, core),
@@ -248,7 +231,6 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new ClickInlineKeyboardButton(obContext, core),
new GetPrivateFileUrl(obContext, core),
new GetUnidirectionalFriendList(obContext, core),
new CleanCache(obContext, core),
];
type HandlerUnion = typeof actionHandlers[number];

View File

@@ -38,7 +38,7 @@ export function normalize(message: OB11MessageMixType, autoEscape = false): OB11
export async function createContext(core: NapCatCore, payload: OB11PostContext | undefined, contextMode: ContextMode = ContextMode.Normal): Promise<Peer> {
if (!payload) {
throw new Error('请传递请求内容');
throw new Error('请指定 group_id 或 user_id');
}
if ((contextMode === ContextMode.Group || contextMode === ContextMode.Normal) && payload.group_id) {
return {
@@ -48,16 +48,7 @@ export async function createContext(core: NapCatCore, payload: OB11PostContext |
}
if ((contextMode === ContextMode.Private || contextMode === ContextMode.Normal) && payload.user_id) {
const Uid = await core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
if (!Uid) {
if (payload.group_id) {
return {
chatType: ChatType.KCHATTYPEGROUP,
peerUid: payload.group_id.toString(),
guildId: ''
}
}
throw new Error('无法获取用户信息');
}
if (!Uid) throw new Error('无法获取用户信息');
const isBuddy = await core.apis.FriendApi.isBuddy(Uid);
if (!isBuddy) {
const ret = await core.apis.MsgApi.getTempChatInfo(ChatType.KCHATTYPETEMPC2CFROMGROUP, Uid);
@@ -87,13 +78,7 @@ export async function createContext(core: NapCatCore, payload: OB11PostContext |
guildId: '',
};
}
if (contextMode === ContextMode.Private && payload.group_id) {
throw new Error('当前私聊发送,请指定 user_id 而不是 group_id');
}
if (contextMode === ContextMode.Group && payload.user_id) {
throw new Error('当前群聊发送,请指定 group_id 而不是 user_id');
}
throw new Error('请指定正确的 group_id 或 user_id');
throw new Error('请指定 group_id 或 user_id');
}
function getSpecialMsgNum(payload: OB11PostSendMsg, msgType: OB11MessageDataType): number {
@@ -174,11 +159,9 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
nickname: string,
}, dp: number = 0): Promise<{
finallySendElements: SendArkElement,
res_id?: string,
deleteAfterSentFiles: string[],
res_id?: string
} | null> {
const packetMsg: PacketMsg[] = [];
let delFiles: string[] = [];
for (const node of messageNodes) {
if (dp >= 3) {
this.core.context.logger.logWarn('转发消息深度超过3层将停止解析');
@@ -194,11 +177,9 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
nickname: (node.data.nickname || node.data.name) ?? parentMeta?.nickname ?? 'QQ用户',
}, dp + 1);
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || []));
} else {
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
sendElements = sendElementsCreateReturn.sendElements;
delFiles.push(...sendElementsCreateReturn.deleteAfterSentFiles);
}
const packetMsgElements: rawMsgWithSendMsg = {
@@ -222,8 +203,7 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
const msg = (await this.core.apis.MsgApi.getMsgsByMsgId(nodeMsg.Peer, [nodeMsg.MsgId])).msgList[0];
this.core.context.logger.logDebug(`handleForwardedNodesPacket[PureRaw] 开始转换 ${stringifyWithBigInt(msg)}`);
if (msg) {
let msgCache = await this.core.apis.FileApi.downloadRawMsgMedia([msg]);
delFiles.push(...msgCache);
await this.core.apis.FileApi.downloadRawMsgMedia([msg]);
const transformedMsg = this.core.apis.PacketApi.pkt.msgConverter.rawMsgToPacketMsg(msg, msgPeer);
this.core.context.logger.logDebug(`handleForwardedNodesPacket[PureRaw] 转换为 ${stringifyWithBigInt(transformedMsg)}`);
packetMsg.push(transformedMsg);
@@ -239,7 +219,6 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsg(packetMsg, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt);
return {
deleteAfterSentFiles: delFiles,
finallySendElements: {
elementType: ElementType.ARK,
elementId: '',
@@ -261,7 +240,7 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
const res_id = uploadReturnData?.res_id;
const finallySendElements = uploadReturnData?.finallySendElements;
if (!finallySendElements) throw Error('转发消息失败,生成节点为空');
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(msgPeer, [finallySendElements], uploadReturnData.deleteAfterSentFiles || []).catch(() => undefined);
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(msgPeer, [finallySendElements], []).catch(() => undefined);
return { message: returnMsg ?? null, res_id: res_id! };
}

View File

@@ -1,18 +0,0 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
count: Type.Number({ default: 50 }),
});
type Payload = Static<typeof SchemaData>;
export class GetDoubtFriendsAddRequest extends OneBotAction<Payload, unknown> {
override actionName = ActionName.GetDoubtFriendsAddRequest;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
return await this.core.apis.FriendApi.getDoubtFriendRequest(payload.count);
}
}

View File

@@ -1,21 +0,0 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
flag: Type.String(),
//注意强制String 非isNumeric 不遵守则不符合设计
approve: Type.Boolean({ default: true }),
//该字段没有语义 仅做保留 强制为True
});
type Payload = Static<typeof SchemaData>;
export class SetDoubtFriendsAddRequest extends OneBotAction<Payload, unknown> {
override actionName = ActionName.SetDoubtFriendsAddRequest;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
return await this.core.apis.FriendApi.handleDoubtFriendRequest(payload.flag);
}
}

View File

@@ -1,18 +0,0 @@
import { ActionName } from '@/onebot/action/router';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
export class GetRkeyEx extends GetPacketStatusDepends<void, unknown> {
override actionName = ActionName.GetRkeyEx;
async _handle() {
let rkeys = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
return rkeys.map(rkey => {
return {
type: rkey.type === 10 ? "private" : "group",
rkey: rkey.rkey,
created_at: rkey.time,
ttl: rkey.ttl,
};
});
}
}

View File

@@ -1,38 +0,0 @@
import { ActionName } from '@/onebot/action/router';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
export class GetRkeyServer extends GetPacketStatusDepends<void, { private_rkey?: string; group_rkey?: string; expired_time?: number; name: string }> {
override actionName = ActionName.GetRkeyServer;
private rkeyCache: {
private_rkey?: string;
group_rkey?: string;
expired_time?: number;
name: string;
} | null = null;
private expiryTime: number | null = null;
async _handle() {
// 检查缓存是否有效
if (this.expiryTime && this.expiryTime > Math.floor(Date.now() / 1000) && this.rkeyCache) {
return this.rkeyCache;
}
// 获取新的 Rkey
let rkeys = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
let privateRkeyItem = rkeys.filter(rkey => rkey.type === 10)[0];
let groupRkeyItem = rkeys.filter(rkey => rkey.type === 20)[0];
this.expiryTime = Math.floor(Date.now() / 1000) + Math.min(+groupRkeyItem!.ttl.toString(),+privateRkeyItem!.ttl.toString());
// 更新缓存
this.rkeyCache = {
private_rkey: privateRkeyItem ? privateRkeyItem.rkey : undefined,
group_rkey: groupRkeyItem ? groupRkeyItem.rkey : undefined,
expired_time: this.expiryTime,
name: "NapCat 4"
};
return this.rkeyCache;
}
}

View File

@@ -10,12 +10,6 @@ export interface InvalidCheckResult {
}
export const ActionName = {
// new extends 完全差异OneBot类别
GetDoubtFriendsAddRequest: 'get_doubt_friends_add_request',
SetDoubtFriendsAddRequest: 'set_doubt_friends_add_request',
// napcat
GetRkeyEx: 'get_rkey',
GetRkeyServer: 'get_rkey_server',
SetGroupRemark: 'set_group_remark',
NapCat_GetPrivateFileUrl: 'get_private_file_url',
ClickInlineKeyboardButton: 'click_inline_keyboard_button',
@@ -39,7 +33,6 @@ export const ActionName = {
SetGroupLeave: 'set_group_leave',
SetSpecialTitle: 'set_group_special_title',
SetFriendAddRequest: 'set_friend_add_request',
SetFriendRemark: 'set_friend_remark',
SetGroupAddRequest: 'set_group_add_request',
GetLoginInfo: 'get_login_info',
GoCQHTTP_GetStrangerInfo: 'get_stranger_info',
@@ -59,7 +52,7 @@ export const ActionName = {
GetStatus: 'get_status',
GetVersionInfo: 'get_version_info',
// Reboot : 'set_restart',
CleanCache : 'clean_cache',
// CleanCache : 'clean_cache',
Exit: 'bot_exit',
// go-cqhttp
SetQQProfile: 'set_qq_profile',
@@ -137,10 +130,6 @@ export const ActionName = {
GetRkey: 'nc_get_rkey',
GetGroupShutList: 'get_group_shut_list',
MoveGroupFile: 'move_group_file',
TransGroupFile: 'trans_group_file',
RenameGroupFile: 'rename_group_file',
GetGuildList: 'get_guild_list',
GetGuildProfile: 'get_guild_service_profile',

View File

@@ -1,38 +0,0 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { unlink, readdir } from 'fs/promises';
import { join } from 'path';
export class CleanCache extends OneBotAction<void, void> {
override actionName = ActionName.CleanCache;
async _handle() {
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);
this.core.context.logger.log(`临时文件夹清理完成: ${tempPath}`);
} catch (err: unknown) {
this.core.context.logger.log(`清理缓存失败: ${(err as Error).message}`);
throw err;
}
}
}

View File

@@ -1,25 +0,0 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
user_id: Type.String(),
remark: Type.String()
});
type Payload = Static<typeof SchemaData>;
export default class SetFriendRemark extends OneBotAction<Payload, null> {
override actionName = ActionName.SetFriendRemark;
override payloadSchema = SchemaData;
async _handle(payload: Payload): Promise<null> {
let friendUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id);
let is_friend = await this.core.apis.FriendApi.isBuddy(friendUid);
if (!is_friend) {
throw new Error(`用户 ${payload.user_id} 不是好友`);
}
await this.core.apis.FriendApi.setBuddyRemark(friendUid, payload.remark);
return null;
}
}

View File

@@ -114,6 +114,7 @@ export class OneBotGroupApi {
async parseCardChangedEvent(msg: RawMessage) {
if (msg.senderUin && msg.senderUin !== '0') {
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUid, msg.senderUin);
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
if (member && member.cardName !== msg.sendMemberName) {
const newCardName = msg.sendMemberName ?? '';
const event = new OB11GroupCardEvent(this.core, parseInt(msg.peerUid), parseInt(msg.senderUin), newCardName, member.cardName);
@@ -129,6 +130,7 @@ export class OneBotGroupApi {
async parsePaiYiPai(msg: RawMessage, jsonStr: string) {
const json = JSON.parse(jsonStr);
//判断业务类型
//Poke事件
const pokedetail: Array<{ uid: string }> = json.items;
@@ -250,34 +252,7 @@ export class OneBotGroupApi {
'invite'
);
}
async parse51TypeEvent(msg: RawMessage, grayTipElement: GrayTipElement) {
// 神经腾讯 没了妈妈想出来的
// Warn 下面存在高并发危险
if (grayTipElement.jsonGrayTipElement.jsonStr) {
const json: {
align: string,
items: Array<{ txt: string, type: string }>
} = JSON.parse(grayTipElement.jsonGrayTipElement.jsonStr);
if (json.items.length === 1 && json.items[0]?.txt.endsWith('加入群')) {
let old_members = structuredClone(this.core.apis.GroupApi.groupMemberCache.get(msg.peerUid));
if (!old_members) return;
let new_members_map = await this.core.apis.GroupApi.refreshGroupMemberCache(msg.peerUid, true);
if (!new_members_map) return;
let new_members = Array.from(new_members_map.values());
// 对比members查找新成员
let new_member = new_members.find((member) => old_members.get(member.uid) == undefined);
if (!new_member) return;
return new OB11GroupIncreaseEvent(
this.core,
+msg.peerUid,
+new_member.uin,
0,
'invite',
);
}
}
return;
}
async parseGrayTipElement(msg: RawMessage, grayTipElement: GrayTipElement) {
if (grayTipElement.subElementType === NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_GROUP) {
// 解析群组事件 由sysmsg解析
@@ -309,9 +284,6 @@ export class OneBotGroupApi {
return await this.parsePaiYiPai(msg, grayTipElement.jsonGrayTipElement.jsonStr);
} else if (grayTipElement.jsonGrayTipElement.busiId == JsonGrayBusiId.AIO_GROUP_ESSENCE_MSG_TIP) {
return await this.parseEssenceMsg(msg, grayTipElement.jsonGrayTipElement.jsonStr);
} else if (+(grayTipElement.jsonGrayTipElement.busiId ?? 0) == 51) {
// 51是什么{"align":"center","items":[{"txt":"下一秒起床通过王者荣耀加入群","type":"nor"}]
return await this.parse51TypeEvent(msg, grayTipElement);
} else {
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement.jsonStr, this.core.context);
}

View File

@@ -85,6 +85,9 @@ export class OneBotMsgApi {
textElement: async element => {
if (element.atType === NTMsgAtType.ATTYPEUNKNOWN) {
let text = element.content;
if (!text.trim()) {
return null;
}
// 兼容 9.7.x 换行符
if (text.indexOf('\n') === -1 && text.indexOf('\r\n') === -1) {
text = text.replace(/\r/g, '\n');
@@ -97,7 +100,7 @@ export class OneBotMsgApi {
let qq: string = 'all';
if (element.atType !== NTMsgAtType.ATTYPEALL) {
const { atNtUid, atUid } = element;
qq = !atUid || atUid === '0' ? await this.core.apis.UserApi.getUinByUidV2(atNtUid) : String(Number(atUid) >>> 0);
qq = !atUid || atUid === '0' ? await this.core.apis.UserApi.getUinByUidV2(atNtUid) : atUid;
}
return {
type: OB11MessageDataType.at,
@@ -147,31 +150,12 @@ export class OneBotMsgApi {
};
FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileUuid);
FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName);
if (this.core.apis.PacketApi.available) {
let url;
try {
url = await this.core.apis.FileApi.getFileUrl(msg.chatType, msg.peerUid, element.fileUuid, element.file10MMd5)
} catch (error) {
url = '';
}
if (url) {
return {
type: OB11MessageDataType.file,
data: {
file: element.fileName,
file_id: element.fileUuid,
file_size: element.fileSize,
url: url,
},
}
}
}
return {
type: OB11MessageDataType.file,
data: {
file: element.fileName,
file_id: element.fileUuid,
file_size: element.fileSize
file_size: element.fileSize,
},
};
},
@@ -241,13 +225,17 @@ export class OneBotMsgApi {
},
replyElement: async (element, msg) => {
const records = msg.records.find(msgRecord => msgRecord.msgId === element?.sourceMsgIdInRecords);
const peer = {
chatType: msg.chatType,
peerUid: msg.peerUid,
guildId: '',
};
if (!records || !element.replyMsgTime || !element.senderUidStr) {
this.core.context.logger.logError('似乎是旧版客户端,获取不到引用的消息', element.replayMsgSeq);
return null;
}
// 创建回复数据的通用方法
const createReplyData = (msgId: string): OB11MessageData => ({
type: OB11MessageDataType.reply,
data: {
@@ -255,96 +243,48 @@ export class OneBotMsgApi {
},
});
// 查找记录
const records = msg.records.find(msgRecord => msgRecord.msgId === element?.sourceMsgIdInRecords);
// 特定账号的特殊处理
if (records && (records.peerUin === '284840486' || records.peerUin === '1094950020')) {
if (records.peerUin === '284840486' || records.peerUin === '1094950020') {
return createReplyData(records.msgId);
}
let replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV2(peer, element.replayMsgSeq, records.msgTime, [element.senderUidStr])).msgList;
let replyMsg = replyMsgList.find(msg => msg.msgRandom === records.msgRandom);
// 获取消息的通用方法组
const tryFetchMethods = async (msgSeq: string, senderUid?: string, msgTime?: string, msgRandom?: string): Promise<RawMessage | undefined> => {
try {
// 方法1通过序号和时间筛选
if (senderUid && msgTime) {
const replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV2(
peer, msgSeq, msgTime, [senderUid]
)).msgList;
const replyMsg = msgRandom
? replyMsgList.find(msg => msg.msgRandom === msgRandom)
: replyMsgList.find(msg => msg.msgSeq === msgSeq);
if (replyMsg) return replyMsg;
this.core.context.logger.logWarn(`方法1查询失败序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
}
// 方法2直接通过序号获取
const replyMsgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount(
peer, msgSeq, 1, true, true
)).msgList;
const replyMsg = msgRandom
? replyMsgList.find(msg => msg.msgRandom === msgRandom)
: replyMsgList.find(msg => msg.msgSeq === msgSeq);
if (replyMsg) return replyMsg;
this.core.context.logger.logWarn(`方法2查询失败序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
// 方法3另一种筛选方式
if (senderUid) {
const replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV3(
peer, msgSeq, [senderUid]
)).msgList;
const replyMsg = msgRandom
? replyMsgList.find(msg => msg.msgRandom === msgRandom)
: replyMsgList.find(msg => msg.msgSeq === msgSeq);
if (replyMsg) return replyMsg;
this.core.context.logger.logWarn(`方法3查询失败序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
}
return undefined;
} catch (error) {
this.core.context.logger.logError('查询回复消息出错', error);
return undefined;
}
};
// 有记录情况下,使用完整信息查询
if (records && element.replyMsgTime && element.senderUidStr) {
const replyMsg = await tryFetchMethods(
if (!replyMsg || records.msgRandom !== replyMsg.msgRandom) {
this.core.context.logger.logError(
'筛选结果,筛选消息失败,将使用Fallback-1 Seq: ',
element.replayMsgSeq,
element.senderUidStr,
records.msgTime,
records.msgRandom
',消息长度:',
replyMsgList.length
);
if (replyMsg) {
return createReplyData(replyMsg.msgId);
}
this.core.context.logger.logError('所有查找方法均失败,获取不到带记录的引用消息', element.replayMsgSeq);
} else {
// 旧版客户端或不完整记录的情况,也尝试使用相同流程
this.core.context.logger.logWarn('似乎是旧版客户端,尝试仅通过序号获取引用消息', element.replayMsgSeq);
const replyMsg = await tryFetchMethods(element.replayMsgSeq);
if (replyMsg) {
return createReplyData(replyMsg.msgId);
}
this.core.context.logger.logError('所有查找方法均失败,获取不到旧客户端的引用消息', element.replayMsgSeq);
replyMsgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount(peer, element.replayMsgSeq, 1, true, true)).msgList;
replyMsg = replyMsgList.find(msg => msg.msgRandom === records.msgRandom);
}
return null;
if (!replyMsg || records.msgRandom !== replyMsg.msgRandom) {
this.core.context.logger.logWarn(
'筛选消息失败,将使用Fallback-2 Seq:',
element.replayMsgSeq,
',消息长度:',
replyMsgList.length
);
replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV3(peer, element.replayMsgSeq, [element.senderUidStr])).msgList;
replyMsg = replyMsgList.find(msg => msg.msgRandom === records.msgRandom);
}
// 丢弃该消息段
if (!replyMsg || records.msgRandom !== replyMsg.msgRandom) {
this.core.context.logger.logError(
'最终筛选结果,筛选消息失败,获取不到引用的消息 Seq: ',
element.replayMsgSeq,
',消息长度:',
replyMsgList.length
);
return null;
}
return createReplyData(replyMsg.msgId);
},
videoElement: async (element, msg, elementWrapper) => {
const peer = {
chatType: msg.chatType,
@@ -391,17 +331,7 @@ export class OneBotMsgApi {
//开始兜底
if (!videoDownUrl) {
if (this.core.apis.PacketApi.available) {
try {
videoDownUrl = await this.core.apis.FileApi.getVideoUrlPacket(msg.peerUid, element.fileUuid);
} catch (e) {
this.core.context.logger.logError('获取视频url失败', (e as Error).stack);
videoDownUrl = element.filePath;
}
} else {
videoDownUrl = element.filePath;
}
videoDownUrl = element.filePath;
}
const fileCode = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName);
return {
@@ -421,28 +351,6 @@ export class OneBotMsgApi {
guildId: '',
};
const fileCode = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, '', element.fileName);
let pttUrl = '';
if (this.core.apis.PacketApi.available) {
try {
pttUrl = await this.core.apis.FileApi.getPttUrl(msg.peerUid, element.fileUuid);
} catch (e) {
this.core.context.logger.logError('获取语音url失败', (e as Error).stack);
pttUrl = element.filePath;
}
} else {
pttUrl = element.filePath;
}
if (pttUrl) {
return {
type: OB11MessageDataType.voice,
data: {
file: fileCode,
path: element.filePath,
url: pttUrl,
file_size: element.fileSize,
},
}
}
return {
type: OB11MessageDataType.voice,
data: {
@@ -559,8 +467,6 @@ export class OneBotMsgApi {
replayMsgId: replyMsg.msgId, // raw.msgId
senderUin: replyMsg.senderUin,
senderUinStr: replyMsg.senderUin,
replyMsgClientSeq: replyMsg.clientSeq,
_replyMsgPeer: replyMsgM.Peer
},
} :
undefined;
@@ -1001,10 +907,10 @@ export class OneBotMsgApi {
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin);
resMsg.group_id = parseInt(ret.tmpChatInfo!.groupCode);
resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话';
resMsg.temp_source = 0;
resMsg.temp_source = resMsg.group_id;
} else {
resMsg.group_id = 284840486;
resMsg.temp_source = 0;
resMsg.temp_source = resMsg.group_id;
resMsg.sender.nickname = '临时会话';
}
}
@@ -1199,8 +1105,6 @@ export class OneBotMsgApi {
return 'kick';
case 3:
return 'kick_me';
case 129:
return 'disband';
default:
return 'kick';
}

View File

@@ -1,7 +1,7 @@
import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent';
import { NapCatCore } from '@/core';
export type GroupDecreaseSubType = 'leave' | 'kick' | 'kick_me' | 'disband';
export type GroupDecreaseSubType = 'leave' | 'kick' | 'kick_me';
export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent {
notice_type = 'group_decrease';
@@ -11,7 +11,7 @@ export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent {
constructor(core: NapCatCore, groupId: number, userId: number, operatorId: number, subType: GroupDecreaseSubType = 'leave') {
super(core, groupId, userId);
this.group_id = groupId;
this.operator_id = operatorId;
this.operator_id = operatorId;
this.user_id = userId;
this.sub_type = subType;
}

View File

@@ -93,7 +93,6 @@ export class OB11Construct {
static group(group: Group): OB11Group {
return {
group_all_shut: (+group.groupShutupExpireTime > 0 )? -1 : 0,
group_remark: group.remarkName,
group_id: +group.groupCode,
group_name: group.groupName,

View File

@@ -334,7 +334,7 @@ export class NapCatOneBot11Adapter {
for (let i = 0; i < reqs.unreadNums; i++) {
const req = reqs.buddyReqs[i];
if (!req) continue;
if (!!req.isInitiator || (req.isDecide && req.reqType !== BuddyReqType.KMEINITIATORWAITPEERCONFIRM) || !req.isUnread) {
if (!!req.isInitiator || (req.isDecide && req.reqType !== BuddyReqType.KMEINITIATORWAITPEERCONFIRM)) {
continue;
}
try {
@@ -352,6 +352,7 @@ export class NapCatOneBot11Adapter {
}
}
};
this.context.session
.getBuddyService()
.addKernelBuddyListener(proxiedListenerOf(buddyListener, this.context.logger));

View File

@@ -39,11 +39,8 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
wsClient.close();
return;
}
// 鉴权 close 不会立刻销毁 当前返回可避免挂载message事件 close 并未立刻关闭 而是存在timer操作后关闭
// 引发高危漏洞
if (!this.authorize(this.config.token, wsClient, wsReq)) {
return;
}
//鉴权
this.authorize(this.config.token, wsClient, wsReq);
const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
if (!isApiConnect) {
@@ -148,16 +145,15 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
}
private authorize(token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
if (!token || token.length == 0) return true;//客户端未设置密钥
if (!token || token.length == 0) return;//客户端未设置密钥
const QueryClientToken = urlParse.parse(wsReq?.url || '', true).query['access_token'];
const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || '';
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken;
if (ClientToken === token) {
return true;
return;
}
wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
wsClient.close();
return false;
}
private checkStateAndReply<T>(data: T, wsClient: WebSocket) {

View File

@@ -63,7 +63,6 @@ export interface OB11GroupMember {
}
export interface OB11Group {
group_all_shut: number; // 群全员禁言
group_remark: string; // 群备注
group_id: number; // 群ID
group_name: string; // 群名称

View File

@@ -31,9 +31,7 @@ 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';
// NapCat Shell App ES 入口文件
async function handleUncaughtExceptions(logger: LogWrapper) {
process.on('uncaughtException', (err) => {
@@ -313,16 +311,6 @@ export async function NCoreInitShell() {
const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath);
handleUncaughtExceptions(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.getFullQQVesion());

View File

@@ -1,2 +1,4 @@
import { convertAudio } from '@/common/audio-pure';
import { NCoreInitShell } from './base';
await convertAudio("F:\\BVideo\\陈致逸,HOYO-MiX - 蒲苇如丝 Lovers' Oath.mp3", "F:\\BVideo\\陈致逸,HOYO-MiX - 蒲苇如丝 Lovers' Oath.wav", "wav");
NCoreInitShell();

View File

@@ -1,123 +0,0 @@
import { LogWrapper } from '@/common/log';
import * as net from 'net';
import * as process from 'process';
import { Writable } from 'stream';
/**
* 连接到命名管道并重定向stdout
* @param logger 日志记录器
* @param timeoutMs 连接超时时间(毫秒)默认5000ms
* @returns Promise连接成功时resolve失败时reject
*/
export function connectToNamedPipe(logger: LogWrapper, timeoutMs: number = 5000): Promise<{ disconnect: () => void }> {
return new Promise((resolve, reject) => {
if (process.platform !== 'win32') {
// 非Windows平台不reject而是返回一个空的disconnect函数
return resolve({ disconnect: () => { } });
}
const pid = process.pid;
const pipePath = `\\\\.\\pipe\\NapCat_${pid}`;
// 设置连接超时
const timeoutId = setTimeout(() => {
reject(new Error(`连接命名管道超时: ${pipePath}`));
}, timeoutMs);
try {
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const pipeSocket = net.connect(pipePath, () => {
// 清除超时
clearTimeout(timeoutId);
// 优化网络性能设置
pipeSocket.setNoDelay(true); // 减少延迟
// 设置更高的高水位线,允许更多数据缓冲
logger.log(`[StdOut] 已重定向到命名管道: ${pipePath}`);
// 创建拥有更优雅背压处理的 Writable 流
const pipeWritable = new Writable({
highWaterMark: 1024 * 64, // 64KB 高水位线
write(chunk, encoding, callback) {
if (!pipeSocket.writable) {
// 如果管道不可写退回到原始stdout
logger.log('[StdOut] 管道不可写,回退到控制台输出');
return originalStdoutWrite(chunk, encoding, callback);
}
// 尝试写入数据到管道
const canContinue = pipeSocket.write(chunk, encoding, () => {
// 数据已被发送或放入内部缓冲区
});
if (canContinue) {
// 如果返回true表示可以继续写入更多数据
// 立即通知写入流可以继续
process.nextTick(callback);
} else {
// 如果返回false表示内部缓冲区已满
// 等待drain事件再恢复写入
pipeSocket.once('drain', () => {
callback();
});
}
// 明确返回true表示写入已处理
return true;
}
});
// 重定向stdout
process.stdout.write = (
chunk: any,
encoding?: BufferEncoding | (() => void),
cb?: () => void
): boolean => {
if (typeof encoding === 'function') {
cb = encoding;
encoding = undefined;
}
// 使用优化的writable流处理写入
return pipeWritable.write(chunk, encoding as BufferEncoding, cb as () => void);
};
// 提供断开连接的方法
const disconnect = () => {
process.stdout.write = originalStdoutWrite;
pipeSocket.end();
logger.log(`已手动断开命名管道连接: ${pipePath}`);
};
// 返回成功和断开连接的方法
resolve({ disconnect });
});
// 管道错误处理
pipeSocket.on('error', (err) => {
clearTimeout(timeoutId);
process.stdout.write = originalStdoutWrite;
logger.log(`连接命名管道 ${pipePath} 时出错:`, err);
reject(err);
});
// 管道关闭处理
pipeSocket.on('end', () => {
process.stdout.write = originalStdoutWrite;
logger.log('命名管道连接已关闭');
});
// 确保在连接意外关闭时恢复stdout
pipeSocket.on('close', () => {
process.stdout.write = originalStdoutWrite;
logger.log('命名管道连接已关闭');
});
} catch (error) {
clearTimeout(timeoutId);
logger.log(`尝试连接命名管道 ${pipePath} 时发生异常:`, error);
reject(error);
}
});
}

View File

@@ -4,7 +4,6 @@
import express from 'express';
import { createServer } from 'http';
import { createServer as createHttpsServer } from 'https';
import { LogWrapper } from '@/common/log';
import { NapCatPathWrapper } from '@/common/path';
import { WebUiConfigWrapper } from '@webapi/helper/config';
@@ -14,10 +13,11 @@ import { createUrl } from '@webapi/utils/url';
import { sendError } from '@webapi/utils/response';
import { join } from 'node:path';
import { terminalManager } from '@webapi/terminal/terminal_manager';
import multer from 'multer'; // 引入multer用于错误捕获
import multer from 'multer'; // 新增:引入multer用于错误捕获
// 实例化Express
const app = express();
const server = createServer(app);
/**
* 初始化并启动WebUI服务。
* 该函数配置了Express服务器以支持JSON解析和静态文件服务并监听6099端口。
@@ -29,7 +29,6 @@ export let webUiPathWrapper: NapCatPathWrapper;
const MAX_PORT_TRY = 100;
import * as net from 'node:net';
import { WebUiDataRuntime } from './src/helper/Data';
import { existsSync, readFileSync } from 'node:fs';
export let webUiRuntimePort = 6099;
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
try {
@@ -41,23 +40,7 @@ export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string,
return ['', 0, ''];
}
}
async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cert: string } | null> {
try {
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
if (existsSync(certPath) && existsSync(keyPath)) {
const cert = readFileSync(certPath, 'utf8');
const key = readFileSync(keyPath, 'utf8');
logger.log('[NapCat] [WebUi] 找到SSL证书将启用HTTPS模式');
return { cert, key };
}
return null;
} catch (error) {
logger.log('[NapCat] [WebUi] 检查SSL证书时出错: ' + error);
return null;
}
}
export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
webUiPathWrapper = pathWrapper;
WebUiConfig = new WebUiConfigWrapper();
@@ -122,13 +105,8 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// ------------挂载路由------------
// 挂载静态路由(前端),路径为 /webui
app.use('/webui', express.static(pathWrapper.staticPath, {
maxAge: '1d'
}));
app.use('/webui', express.static(pathWrapper.staticPath));
// 初始化WebSocket服务器
const sslCerts = await checkCertificates(logger);
const isHttps = !!sslCerts;
let server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app);
server.on('upgrade', (request, socket, head) => {
terminalManager.initialize(request, socket, head, logger);
});

View File

@@ -20,26 +20,25 @@ export const CheckDefaultTokenHandler: RequestHandler = async (_, res) => {
export const LoginHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
// 获取请求体中的hash
const { hash } = req.body;
// 获取请求体中的token
const { token } = req.body;
// 获取客户端IP
const clientIP = req.ip || req.socket.remoteAddress || '';
// 如果token为空返回错误信息
if (isEmpty(hash)) {
if (isEmpty(token)) {
return sendError(res, 'token is empty');
}
// 检查登录频率
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
return sendError(res, 'login rate limit');
}
//验证config.token hash是否等于token hash
if (!AuthHelper.comparePasswordHash(WebUiConfigData.token, hash)) {
//验证config.token是否等于token
if (WebUiConfigData.token !== token) {
return sendError(res, 'token is invalid');
}
// 签发凭证
const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(hash))).toString(
const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(WebUiConfigData.token))).toString(
'base64'
);
// 返回成功信息

View File

@@ -5,13 +5,13 @@ export class AuthHelper {
/**
* 签名凭证方法。
* @param hash 待签名的凭证字符串。
* @param token 待签名的凭证字符串。
* @returns 签名后的凭证对象。
*/
public static signCredential(hash: string): WebUiCredentialJson {
public static signCredential(token: string): WebUiCredentialJson {
const innerJson: WebUiCredentialInnerJson = {
CreatedTime: Date.now(),
HashEncoded: hash,
TokenEncoded: token,
};
const jsonString = JSON.stringify(innerJson);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
@@ -57,7 +57,8 @@ export class AuthHelper {
const currentTime = Date.now() / 1000;
const createdTime = credentialJson.Data.CreatedTime;
const timeDifference = currentTime - createdTime;
return timeDifference <= 3600 && credentialJson.Data.HashEncoded === AuthHelper.generatePasswordHash(token);
return timeDifference <= 3600 && credentialJson.Data.TokenEncoded === token;
}
/**
@@ -84,23 +85,4 @@ export class AuthHelper {
return store.exists(`revoked:${hmac}`) > 0;
}
/**
* 生成密码Hash
* @param password 密码
* @returns 生成的Hash值
*/
public static generatePasswordHash(password: string): string {
return crypto.createHash('sha256').update(password + '.napcat').digest().toString('hex')
}
/**
* 对比密码和Hash值
* @param password 密码
* @param hash Hash值
* @returns 布尔值表示密码是否匹配Hash值
*/
public static comparePasswordHash(password: string, hash: string): boolean {
return this.generatePasswordHash(password) === hash;
}
}

View File

@@ -21,18 +21,17 @@ export async function auth(req: Request, res: Response, next: NextFunction) {
return sendError(res, 'Unauthorized');
}
// 获取token
const hash = authorization[1];
if(!hash) return sendError(res, 'Unauthorized');
const token = authorization[1];
// 解析token
let Credential: WebUiCredentialJson;
try {
Credential = JSON.parse(Buffer.from(hash, 'base64').toString('utf-8'));
Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
} catch (e) {
return sendError(res, 'Unauthorized');
}
// 获取配置
const config = await WebUiConfig.GetWebUIConfig();
// 验证凭证在1小时内有效
// 验证凭证在1小时内有效且token与原始token相同
const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
if (credentialJson) {
// 通过验证

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import {
ListFilesHandler,
CreateDirHandler,
@@ -19,15 +20,15 @@ import {
const router = Router();
// const apiLimiter = rateLimit({
// windowMs: 1 * 60 * 1000, // 1分钟内
// max: 60, // 最大60个请求
// validate: {
// xForwardedForHeader: false,
// },
// });
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟内
max: 60, // 最大60个请求
validate: {
xForwardedForHeader: false,
},
});
// router.use(apiLimiter);
router.use(apiLimiter);
router.get('/list', ListFilesHandler);
router.post('/mkdir', CreateDirHandler);

View File

@@ -1,6 +1,6 @@
interface WebUiCredentialInnerJson {
CreatedTime: number;
HashEncoded: string;
TokenEncoded: string;
}
interface WebUiCredentialJson {

View File

@@ -3,12 +3,15 @@ import { defineConfig, PluginOption, UserConfig } from 'vite';
import { resolve } from 'path';
import nodeResolve from '@rollup/plugin-node-resolve';
import { builtinModules } from 'module';
import wasm from 'vite-plugin-wasm';
//依赖排除
const external = [
'silk-wasm',
'ws',
'express'
'express',
'@ffmpeg.wasm/core-mt',
'@breezystack/lamejs',
'audio-decode',
'wavefile'
];
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
@@ -22,7 +25,6 @@ if (process.env.NAPCAT_BUILDSYS == 'linux') {
}
const UniversalBaseConfigPlugin: PluginOption[] = [
wasm(),
cp({
targets: [
{ src: './manifest.json', dest: 'dist' },
@@ -46,7 +48,6 @@ const UniversalBaseConfigPlugin: PluginOption[] = [
];
const FrameworkBaseConfigPlugin: PluginOption[] = [
wasm(),
cp({
targets: [
{ src: './manifest.json', dest: 'dist' },
@@ -66,7 +67,6 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [
];
const ShellBaseConfigPlugin: PluginOption[] = [
wasm(),
cp({
targets: [
{ src: './src/native/packet', dest: 'dist/moehoo', flatten: false },
@@ -100,6 +100,7 @@ const UniversalBaseConfig = () =>
entry: {
napcat: 'src/universal/napcat.ts',
'audio-worker': 'src/common/audio-worker.ts',
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts',
},
formats: ['es'],
@@ -129,6 +130,7 @@ const ShellBaseConfig = () =>
entry: {
napcat: 'src/shell/napcat.ts',
'audio-worker': 'src/common/audio-worker.ts',
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts',
},
formats: ['es'],
@@ -158,6 +160,7 @@ const FrameworkBaseConfig = () =>
entry: {
napcat: 'src/framework/napcat.ts',
'audio-worker': 'src/common/audio-worker.ts',
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts',
},
formats: ['es'],