Compare commits

..

9 Commits

Author SHA1 Message Date
手瓜一十雪
a849b5edc0 feat: websocket server 2025-03-21 16:07:47 +08:00
手瓜一十雪
be2f3be4bd x 2025-03-21 15:59:15 +08:00
手瓜一十雪
f7cc25adc1 fix: 轻量版 2025-03-21 15:16:29 +08:00
手瓜一十雪
441a34e0bf fix: 完整tree-shaking 2025-03-21 15:04:53 +08:00
手瓜一十雪
7a4c82bded fix: network 2025-03-21 14:38:12 +08:00
手瓜一十雪
5fa2e9d8f5 fix: sse http 2025-03-21 14:35:41 +08:00
手瓜一十雪
b40873ada7 fix: http basic 2025-03-21 14:11:49 +08:00
手瓜一十雪
4db65cf860 Merge branch 'main' into refactor 2025-03-21 13:57:44 +08:00
手瓜一十雪
610e07ac32 refactor: express-> hono 2025-02-26 12:12:24 +08:00
84 changed files with 640 additions and 4501 deletions

View File

@@ -1,9 +1,9 @@
{ {
"name": "qq-chat", "name": "qq-chat",
"version": "9.9.18-32869", "version": "9.9.18-32793",
"verHash": "e735296c", "verHash": "d43f097e",
"linuxVersion": "3.2.16-32869", "linuxVersion": "3.2.16-32793",
"linuxVerHash": "4c192ba9", "linuxVerHash": "ee4bd910",
"private": true, "private": true,
"description": "QQ", "description": "QQ",
"productName": "QQ", "productName": "QQ",
@@ -34,9 +34,9 @@
"vuex@4.1.0": "patches/vuex@4.1.0.patch" "vuex@4.1.0": "patches/vuex@4.1.0.patch"
} }
}, },
"buildVersion": "32869", "buildVersion": "32793",
"isPureShell": true, "isPureShell": true,
"isByteCodeShell": true, "isByteCodeShell": true,
"platform": "win32", "platform": "win32",
"eleArch": "x64" "eleArch": "x64"
} }

View File

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

View File

@@ -79,7 +79,6 @@ export default function WebLoginPage() {
<CardBody className="flex gap-5 py-5 px-5 md:px-10"> <CardBody className="flex gap-5 py-5 px-5 md:px-10">
<Input <Input
isClearable isClearable
type="password"
classNames={{ classNames={{
label: 'text-black/50 dark:text-white/90', label: 'text-black/50 dark:text-white/90',
input: [ input: [

View File

@@ -2,7 +2,7 @@
"name": "napcat", "name": "napcat",
"private": true, "private": true,
"type": "module", "type": "module",
"version": "4.7.13", "version": "4.7.6",
"scripts": { "scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1", "build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1", "build:framework": "npm run build:webui && vite build --mode framework || exit 1",
@@ -23,6 +23,7 @@
"@eslint/js": "^9.14.0", "@eslint/js": "^9.14.0",
"@ffmpeg.wasm/main": "^0.13.1", "@ffmpeg.wasm/main": "^0.13.1",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
"@hono/node-server": "^1.13.8",
"@log4js-node/log4js-api": "^1.0.2", "@log4js-node/log4js-api": "^1.0.2",
"@napneko/nap-proto-core": "^0.0.4", "@napneko/nap-proto-core": "^0.0.4",
"@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-node-resolve": "^16.0.0",
@@ -32,7 +33,6 @@
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^22.0.1", "@types/node": "^22.0.1",
"@types/node-wav": "^0.0.4",
"@types/on-finished": "^2.3.4", "@types/on-finished": "^2.3.4",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@types/react-color": "^3.0.13", "@types/react-color": "^3.0.13",
@@ -53,25 +53,20 @@
"fast-xml-parser": "^4.3.6", "fast-xml-parser": "^4.3.6",
"file-type": "^20.0.0", "file-type": "^20.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"hono": "^4.7.2",
"image-size": "^1.1.1", "image-size": "^1.1.1",
"json5": "^2.2.3", "json5": "^2.2.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"napcat.protobuf": "^1.1.4", "napcat.protobuf": "^1.1.3",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.13.0", "typescript-eslint": "^8.13.0",
"vite": "^6.0.1", "vite": "^6.0.1",
"vite-plugin-cp": "^4.0.8", "vite-plugin-cp": "^4.0.8",
"vite-plugin-wasm": "^3.4.1",
"vite-tsconfig-paths": "^5.1.0", "vite-tsconfig-paths": "^5.1.0",
"silk-wasm": "^3.6.1",
"@hono/node-ws": "^1.1.0",
"winston": "^3.17.0" "winston": "^3.17.0"
}, },
"dependencies": { "dependencies": {}
"@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

@@ -1,145 +0,0 @@
// 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;
}
}

View File

@@ -1,814 +0,0 @@
/**
* 现代音频格式转换库 - 使用纯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,229 +0,0 @@
import fs from 'fs';
// generate Claude 3.7 Sonet Thinking
interface FileRecord {
filePath: string;
addedTime: number;
retries: number;
}
interface CleanupTask {
fileRecord: FileRecord;
timer: NodeJS.Timeout;
}
class CleanupQueue {
private tasks: Map<string, CleanupTask> = new Map();
private readonly MAX_RETRIES = 3;
private isProcessing: boolean = false;
private pendingOperations: Array<() => void> = [];
/**
* 执行队列中的待处理操作,确保异步安全
*/
private executeNextOperation(): void {
if (this.pendingOperations.length === 0) {
this.isProcessing = false;
return;
}
this.isProcessing = true;
const operation = this.pendingOperations.shift();
operation?.();
// 使用 setImmediate 允许事件循环继续,防止阻塞
setImmediate(() => this.executeNextOperation());
}
/**
* 安全执行操作,防止竞态条件
* @param operation 要执行的操作
*/
private safeExecute(operation: () => void): void {
this.pendingOperations.push(operation);
if (!this.isProcessing) {
this.executeNextOperation();
}
}
/**
* 检查文件是否存在
* @param filePath 文件路径
* @returns 文件是否存在
*/
private fileExists(filePath: string): boolean {
try {
return fs.existsSync(filePath);
} catch (error) {
//console.log(`检查文件存在出错: ${filePath}`, error);
return false;
}
}
/**
* 添加文件到清理队列
* @param filePath 文件路径
* @param cleanupDelay 清理延迟时间(毫秒)
*/
addFile(filePath: string, cleanupDelay: number): void {
this.safeExecute(() => {
// 如果文件已在队列中,取消原来的计时器
if (this.tasks.has(filePath)) {
this.cancelCleanup(filePath);
}
// 创建新的文件记录
const fileRecord: FileRecord = {
filePath,
addedTime: Date.now(),
retries: 0
};
// 设置计时器
const timer = setTimeout(() => {
this.cleanupFile(fileRecord, cleanupDelay);
}, cleanupDelay);
// 添加到任务队列
this.tasks.set(filePath, { fileRecord, timer });
});
}
/**
* 批量添加文件到清理队列
* @param filePaths 文件路径数组
* @param cleanupDelay 清理延迟时间(毫秒)
*/
addFiles(filePaths: string[], cleanupDelay: number): void {
this.safeExecute(() => {
for (const filePath of filePaths) {
// 内部直接处理,不通过 safeExecute 以保证批量操作的原子性
if (this.tasks.has(filePath)) {
// 取消已有的计时器,但不使用 cancelCleanup 方法以避免重复的安全检查
const existingTask = this.tasks.get(filePath);
if (existingTask) {
clearTimeout(existingTask.timer);
}
}
const fileRecord: FileRecord = {
filePath,
addedTime: Date.now(),
retries: 0
};
const timer = setTimeout(() => {
this.cleanupFile(fileRecord, cleanupDelay);
}, cleanupDelay);
this.tasks.set(filePath, { fileRecord, timer });
}
});
}
/**
* 清理文件
* @param record 文件记录
* @param delay 延迟时间,用于重试
*/
private cleanupFile(record: FileRecord, delay: number): void {
this.safeExecute(() => {
// 首先检查文件是否存在,不存在则视为清理成功
if (!this.fileExists(record.filePath)) {
//console.log(`文件已不存在,跳过清理: ${record.filePath}`);
this.tasks.delete(record.filePath);
return;
}
try {
// 尝试删除文件
fs.unlinkSync(record.filePath);
// 删除成功,从队列中移除任务
this.tasks.delete(record.filePath);
} catch (error) {
const err = error as NodeJS.ErrnoException;
// 明确处理文件不存在的情况
if (err.code === 'ENOENT') {
//console.log(`文件在删除时不存在,视为清理成功: ${record.filePath}`);
this.tasks.delete(record.filePath);
return;
}
// 文件没有访问权限等情况
if (err.code === 'EACCES' || err.code === 'EPERM') {
//console.error(`没有权限删除文件: ${record.filePath}`, err);
}
// 其他删除失败情况,考虑重试
if (record.retries < this.MAX_RETRIES - 1) {
// 还有重试机会,增加重试次数
record.retries++;
//console.log(`清理文件失败,将重试(${record.retries}/${this.MAX_RETRIES}): ${record.filePath}`);
// 设置相同的延迟时间再次尝试
const timer = setTimeout(() => {
this.cleanupFile(record, delay);
}, delay);
// 更新任务
this.tasks.set(record.filePath, { fileRecord: record, timer });
} else {
// 已达到最大重试次数,从队列中移除任务
this.tasks.delete(record.filePath);
//console.error(`清理文件失败,已达最大重试次数(${this.MAX_RETRIES}): ${record.filePath}`, error);
}
}
});
}
/**
* 取消文件的清理任务
* @param filePath 文件路径
* @returns 是否成功取消
*/
cancelCleanup(filePath: string): boolean {
let cancelled = false;
this.safeExecute(() => {
const task = this.tasks.get(filePath);
if (task) {
clearTimeout(task.timer);
this.tasks.delete(filePath);
cancelled = true;
}
});
return cancelled;
}
/**
* 获取队列中的文件数量
* @returns 文件数量
*/
getQueueSize(): number {
return this.tasks.size;
}
/**
* 获取所有待清理的文件
* @returns 文件路径数组
*/
getPendingFiles(): string[] {
return Array.from(this.tasks.keys());
}
/**
* 清空所有清理任务
*/
clearAll(): void {
this.safeExecute(() => {
// 取消所有定时器
for (const task of this.tasks.values()) {
clearTimeout(task.timer);
}
this.tasks.clear();
//console.log('已清空所有清理任务');
});
}
}
export const cleanTaskQueue = new CleanupQueue();

View File

@@ -16,9 +16,6 @@ export function recvTask<T>(cb: (taskData: T) => Promise<unknown>) {
} }
}); });
} }
export function sendLog(_log: string) {
//parentPort?.postMessage({ log });
}
class FFmpegService { class FFmpegService {
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> { public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
@@ -110,175 +107,35 @@ class FFmpegService {
} }
} }
} }
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> { public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
const startTime = Date.now(); await FFmpegService.extractThumbnail(videoPath, thumbnailPath);
sendLog(`开始获取视频信息: ${videoPath}`); const fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4';
const inputFileName = `${randomUUID()}.${fileType}`;
// 创建一个超时包装函数 const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number, taskName: string): Promise<T> => { ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath));
return Promise.race([ ffmpegInstance.setLogging(true);
promise, let duration = 60;
new Promise<T>((_, reject) => { ffmpegInstance.setLogger((_level, ...msg) => {
setTimeout(() => reject(new Error(`任务超时: ${taskName} (${timeoutMs}ms)`)), timeoutMs); 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');
const [fileInfo, durationInfo] = await Promise.all([ duration = hours * 3600 + minutes * 60 + seconds;
// 任务1: 获取文件信息和提取缩略图 }
(async () => { });
sendLog(`开始任务1: 获取文件信息和提取缩略图`); await ffmpegInstance.run('-i', inputFileName);
const image = imageSize(thumbnailPath);
// 获取文件信息 (并行) ffmpegInstance.fs.unlink(inputFileName);
const fileInfoStartTime = Date.now(); const fileSize = statSync(videoPath).size;
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 { return {
width: fileInfo.width, width: image.width ?? 100,
height: fileInfo.height, height: image.height ?? 100,
time: durationInfo.time, time: duration,
format: fileInfo.format, format: fileType,
size: fileInfo.size, size: fileSize,
filePath: videoPath filePath: videoPath
}; };
} }

View File

@@ -30,7 +30,7 @@ export class FFmpegService {
} }
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> { public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
const result = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'getVideoInfo', args: [videoPath, thumbnailPath] }); const result = await await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'getVideoInfo', args: [videoPath, thumbnailPath] });
return result; return result;
} }
} }

View File

@@ -76,7 +76,7 @@ export function calculateFileMD5(filePath: string): Promise<string> {
const stream = fs.createReadStream(filePath); const stream = fs.createReadStream(filePath);
const hash = crypto.createHash('md5'); const hash = crypto.createHash('md5');
stream.on('data', (data) => { stream.on('data', (data: Buffer) => {
// 当读取到数据时,更新哈希对象的状态 // 当读取到数据时,更新哈希对象的状态
hash.update(data); hash.update(data);
}); });
@@ -182,28 +182,28 @@ export async function uriToLocalFile(dir: string, uri: string, filename: string
const filePath = path.join(dir, filename); const filePath = path.join(dir, filename);
switch (UriType) { switch (UriType) {
case FileUriType.Local: { case FileUriType.Local: {
const fileExt = path.extname(HandledUri); const fileExt = path.extname(HandledUri);
const localFileName = path.basename(HandledUri, fileExt) + fileExt; const localFileName = path.basename(HandledUri, fileExt) + fileExt;
const tempFilePath = path.join(dir, filename + fileExt); const tempFilePath = path.join(dir, filename + fileExt);
fs.copyFileSync(HandledUri, tempFilePath); fs.copyFileSync(HandledUri, tempFilePath);
return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath }; return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath };
} }
case FileUriType.Remote: { case FileUriType.Remote: {
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} }); const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
fs.writeFileSync(filePath, buffer); fs.writeFileSync(filePath, buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath }; return { success: true, errMsg: '', fileName: filename, path: filePath };
} }
case FileUriType.Base64: { case FileUriType.Base64: {
const base64 = HandledUri.replace(/^base64:\/\//, ''); const base64 = HandledUri.replace(/^base64:\/\//, '');
const base64Buffer = Buffer.from(base64, 'base64'); const base64Buffer = Buffer.from(base64, 'base64');
fs.writeFileSync(filePath, base64Buffer); fs.writeFileSync(filePath, base64Buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath }; return { success: true, errMsg: '', fileName: filename, path: filePath };
} }
default: default:
return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' }; return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' };
} }
} }

View File

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

View File

@@ -5,12 +5,6 @@ export async function runTask<T, R>(workerScript: string, taskData: T): Promise<
try { try {
return await new Promise<R>((resolve, reject) => { return await new Promise<R>((resolve, reject) => {
worker.on('message', (result: R) => { worker.on('message', (result: R) => {
if ((result as any)?.log) {
console.error('Worker Log--->:', (result as { log: string }).log);
}
if ((result as any)?.error) {
reject(new Error("Worker error: " + (result as { error: string }).error));
}
resolve(result); resolve(result);
}); });

View File

@@ -44,7 +44,7 @@ export class NTQQFileApi {
'https://ss.xingzhige.com/music_card/rkey', // 国内 'https://ss.xingzhige.com/music_card/rkey', // 国内
'https://secret-service.bietiaop.com/rkeys',//国内 'https://secret-service.bietiaop.com/rkeys',//国内
], ],
this.context.logger this.context.logger
); );
} }
@@ -182,30 +182,23 @@ export class NTQQFileApi {
filePath = newFilePath; filePath = newFilePath;
const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO); const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO);
context.deleteAfterSentFiles.push(path);
if (fileSize === 0) { if (fileSize === 0) {
throw new Error('文件异常大小为0'); throw new Error('文件异常大小为0');
} }
const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`); const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true }); fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true });
const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`); const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`);
try {
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
} catch {
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
if (_diyThumbPath) { if (_diyThumbPath) {
try { try {
await this.copyFile(_diyThumbPath, thumbPath); await this.copyFile(_diyThumbPath, thumbPath);
} catch (e) { } catch (e) {
this.context.logger.logError('复制自定义缩略图失败', e); this.context.logger.logError('复制自定义缩略图失败', e);
} }
} else {
try {
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
if (!fs.existsSync(thumbPath)) {
this.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在'));
throw new Error('获取视频缩略图失败');
}
} catch (e) {
this.context.logger.logError('获取视频信息失败', e);
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
} }
context.deleteAfterSentFiles.push(thumbPath); context.deleteAfterSentFiles.push(thumbPath);
const thumbSize = (await fsPromises.stat(thumbPath)).size; const thumbSize = (await fsPromises.stat(thumbPath)).size;
@@ -231,7 +224,7 @@ export class NTQQFileApi {
}, },
}; };
} }
async createValidSendPttElement(_context: SendMessageContext, pttPath: string): Promise<SendPttElement> { async createValidSendPttElement(pttPath: string): Promise<SendPttElement> {
const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.NapCatTempPath, this.core.context.logger); const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.NapCatTempPath, this.core.context.logger);
if (!silkPath) { if (!silkPath) {
@@ -308,18 +301,18 @@ export class NTQQFileApi {
element.elementType === ElementType.FILE element.elementType === ElementType.FILE
) { ) {
switch (element.elementType) { switch (element.elementType) {
case ElementType.PIC: case ElementType.PIC:
element.picElement!.sourcePath = elementResults?.[elementIndex] ?? ''; element.picElement!.sourcePath = elementResults?.[elementIndex] ?? '';
break; break;
case ElementType.VIDEO: case ElementType.VIDEO:
element.videoElement!.filePath = elementResults?.[elementIndex] ?? ''; element.videoElement!.filePath = elementResults?.[elementIndex] ?? '';
break; break;
case ElementType.PTT: case ElementType.PTT:
element.pttElement!.filePath = elementResults?.[elementIndex] ?? ''; element.pttElement!.filePath = elementResults?.[elementIndex] ?? '';
break; break;
case ElementType.FILE: case ElementType.FILE:
element.fileElement!.filePath = elementResults?.[elementIndex] ?? ''; element.fileElement!.filePath = elementResults?.[elementIndex] ?? '';
break; break;
} }
elementIndex++; elementIndex++;
} }

View File

@@ -90,30 +90,7 @@ export class NTQQUserApi {
() => true, () => true,
(profile) => profile.uid === uid, (profile) => profile.uid === uid,
); );
return profile; const RetUser: User = {
}
async getUserDetailInfo(uid: string, no_cache: boolean = false): Promise<User> {
let profile = await solveAsyncProblem(async (uid) => this.fetchUserDetailInfo(uid, no_cache ? UserDetailSource.KSERVER : UserDetailSource.KDB), uid);
if (profile && profile.uin !== '0' && profile.commonExt) {
return {
...profile.simpleInfo.status,
...profile.simpleInfo.vasInfo,
...profile.commonExt,
...profile.simpleInfo.baseInfo,
...profile.simpleInfo.coreInfo,
qqLevel: profile.commonExt?.qqLevel,
age: profile.simpleInfo.baseInfo.age,
pendantId: '',
nick: profile.simpleInfo.coreInfo.nick || '',
};
}
this.context.logger.logDebug('[NapCat] [Mark] getUserDetailInfo Mode1 Failed.');
profile = await this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER);
if (profile && profile.uin === '0') {
profile.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0';
}
return {
...profile.simpleInfo.status, ...profile.simpleInfo.status,
...profile.simpleInfo.vasInfo, ...profile.simpleInfo.vasInfo,
...profile.commonExt, ...profile.commonExt,
@@ -124,6 +101,33 @@ export class NTQQUserApi {
pendantId: '', pendantId: '',
nick: profile.simpleInfo.coreInfo.nick || '', nick: profile.simpleInfo.coreInfo.nick || '',
}; };
return RetUser;
}
async getUserDetailInfo(uid: string): Promise<User> {
let retUser = await solveAsyncProblem(async (uid) => this.fetchUserDetailInfo(uid, UserDetailSource.KDB), uid);
if (retUser && retUser.uin !== '0') {
return retUser;
}
this.context.logger.logDebug('[NapCat] [Mark] getUserDetailInfo Mode1 Failed.');
retUser = await this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER);
if (retUser && retUser.uin === '0') {
retUser.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0';
}
return retUser;
}
async getUserDetailInfoV2(uid: string): Promise<User> {
const fallback = new Fallback<User>((user) => FallbackUtil.boolchecker(user, user !== undefined && user.uin !== '0'))
.add(() => this.fetchUserDetailInfo(uid, UserDetailSource.KDB))
.add(() => this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER));
const retUser = await fallback.run().then(async (user) => {
if (user && user.uin === '0') {
user.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0';
}
return user;
});
return retUser;
} }
async modifySelfProfile(param: ModifyProfileParams) { async modifySelfProfile(param: ModifyProfileParams) {

View File

@@ -226,13 +226,5 @@
"9.9.18-33139": { "9.9.18-33139": {
"appid": 537273874, "appid": 537273874,
"qua": "V1_WIN_NQ_9.9.18_33139_GW_B" "qua": "V1_WIN_NQ_9.9.18_33139_GW_B"
},
"9.9.18-33800": {
"appid": 537273974,
"qua": "V1_WIN_NQ_9.9.18_33800_GW_B"
},
"3.2.16-33800": {
"appid": 537274009,
"qua": "V1_LNX_NQ_3.2.16_33800_GW_B"
} }
} }

View File

@@ -302,17 +302,5 @@
"3.2.16-33139-arm64": { "3.2.16-33139-arm64": {
"send": "7262BB0", "send": "7262BB0",
"recv": "72664E0" "recv": "72664E0"
},
"9.9.18-33800-x64": {
"send": "39F5870",
"recv": "39FA070"
},
"3.2.16-33800-x64": {
"send": "A634F60",
"recv": "A638980"
},
"3.2.16-33800-arm64": {
"send": "7262BB0",
"recv": "72664E0"
} }
} }

View File

@@ -38,12 +38,12 @@ export class NativePacketClient extends IPacketClient {
return true; return true;
} }
async init(_pid: number, recv: string, send: string): Promise<void> { async init(pid: number, recv: string, send: string): Promise<void> {
const platform = process.platform + '.' + process.arch; const platform = process.platform + '.' + process.arch;
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './moehoo/MoeHoo.' + platform + '.node'); const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './moehoo/MoeHoo.' + platform + '.node');
process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY); process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY);
this.MoeHooExport.exports.InitHook?.(send, recv, (type: number, _uin: string, cmd: string, seq: number, hex_data: string) => { this.MoeHooExport.exports.InitHook?.(send, recv, (type: number, uin: string, cmd: string, seq: number, hex_data: string) => {
const trace_id = createHash('md5').update(Buffer.from(hex_data, 'hex')).digest('hex'); const trace_id = createHash('md5').update(Buffer.from(hex_data, 'hex')).digest('hex');
if (type === 0 && this.cb.get(trace_id + 'recv')) { if (type === 0 && this.cb.get(trace_id + 'recv')) {
//此时为send 提取seq //此时为send 提取seq

View File

@@ -0,0 +1,112 @@
import { IPacketClient, RecvPacket } from '@/core/packet/client/baseClient';
import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext';
export class WsPacketClient extends IPacketClient {
private websocket: WebSocket | null = null;
private reconnectAttempts: number = 0;
private readonly maxReconnectAttempts: number = 60; // 现在暂时不可配置
private readonly clientUrl: string;
private readonly clientUrlWrap: (url: string) => string = (url: string) => `ws://${url}/ws`;
private isInitialized: boolean = false;
private initPayload: { pid: number, recv: string, send: string } | null = null;
constructor(napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) {
super(napCore, logger, logStack);
this.clientUrl = this.napcore.config.packetServer
? this.clientUrlWrap(this.napcore.config.packetServer)
: this.clientUrlWrap('127.0.0.1:8083');
}
check(): boolean {
if (!this.napcore.config.packetServer) {
this.logStack.pushLogWarn('wsPacketClient 未配置服务器地址');
return false;
}
return true;
}
async init(pid: number, recv: string, send: string): Promise<void> {
this.initPayload = { pid, recv, send };
await this.connectWithRetry();
}
sendCommandImpl(cmd: string, data: string, trace_id: string): void {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify({
action: 'send',
cmd,
data,
trace_id
}));
} else {
this.logStack.pushLogWarn(`WebSocket 未连接,无法发送命令: ${cmd}`);
}
}
private async connectWithRetry(): Promise<void> {
while (this.reconnectAttempts < this.maxReconnectAttempts) {
try {
await this.connect();
return;
} catch {
this.reconnectAttempts++;
this.logStack.pushLogWarn(`${this.reconnectAttempts}/${this.maxReconnectAttempts} 次尝试重连失败!`);
await this.delay(5000);
}
}
this.logStack.pushLogError(`wsPacketClient 在 ${this.clientUrl} 达到最大重连次数 (${this.maxReconnectAttempts})`);
throw new Error(`无法连接到 WebSocket 服务器:${this.clientUrl}`);
}
private connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.websocket = new WebSocket(this.clientUrl);
this.websocket.onopen = () => {
this.available = true;
this.reconnectAttempts = 0;
this.logger.info(`wsPacketClient 已连接到 ${this.clientUrl}`);
if (!this.isInitialized && this.initPayload) {
this.websocket!.send(JSON.stringify({
action: 'init',
...this.initPayload
}));
this.isInitialized = true;
}
resolve();
};
this.websocket.onclose = () => {
this.available = false;
this.logger.warn('WebSocket 连接关闭,尝试重连...');
reject(new Error('WebSocket 连接关闭'));
};
this.websocket.onmessage = (ev: MessageEvent<any>) => this.handleMessage(ev).catch(err => {
this.logger.error(`处理消息时出错: ${err}`);
});
this.websocket.onerror = (event) => {
this.available = false;
this.logger.error(`WebSocket 出错: ${event}`);
this.websocket?.close();
reject(new Error(`WebSocket 出错: ${event}`));
};
});
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private async handleMessage(message: MessageEvent): Promise<void> {
try {
const json: RecvPacket = JSON.parse(message.data.toString());
const trace_id_md5 = json.trace_id_md5;
const action = json?.type ?? 'init';
const event = this.cb.get(`${trace_id_md5}${action}`);
if (event) await event(json.data);
} catch (error) {
this.logger.error(`解析ws消息时出错: ${(error as Error).message}`);
}
}
}

View File

@@ -1,5 +1,6 @@
import { IPacketClient } from '@/core/packet/client/baseClient'; import { IPacketClient } from '@/core/packet/client/baseClient';
import { NativePacketClient } from '@/core/packet/client/nativeClient'; import { NativePacketClient } from '@/core/packet/client/nativeClient';
import { WsPacketClient } from '@/core/packet/client/wsClient';
import { OidbPacket } from '@/core/packet/transformer/base'; import { OidbPacket } from '@/core/packet/transformer/base';
import { PacketLogger } from '@/core/packet/context/loggerContext'; import { PacketLogger } from '@/core/packet/context/loggerContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext'; import { NapCoreContext } from '@/core/packet/context/napCoreContext';
@@ -9,7 +10,8 @@ type clientPriorityType = {
} }
const clientPriority: clientPriorityType = { const clientPriority: clientPriorityType = {
10: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new NativePacketClient(napCore, logger, logStack) 10: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new NativePacketClient(napCore, logger, logStack),
1: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new WsPacketClient(napCore, logger, logStack),
}; };
export class LogStack { export class LogStack {
@@ -86,6 +88,10 @@ export class PacketClientContext {
this.logger.info('使用指定的 NativePacketClient 作为后端'); this.logger.info('使用指定的 NativePacketClient 作为后端');
client = new NativePacketClient(this.napCore, this.logger, this.logStack); client = new NativePacketClient(this.napCore, this.logger, this.logStack);
break; break;
case 'frida':
this.logger.info('[Core] [Packet] 使用指定的 FridaPacketClient 作为后端');
client = new WsPacketClient(this.napCore, this.logger, this.logStack);
break;
case 'auto': case 'auto':
case undefined: case undefined:
client = this.judgeClient(); client = this.judgeClient();

View File

@@ -68,8 +68,8 @@ export class PacketOperationContext {
} }
} }
async SetGroupSpecialTitle(groupUin: number, uid: string, title: string) { async SetGroupSpecialTitle(groupUin: number, uid: string, tittle: string) {
const req = trans.SetSpecialTitle.build(groupUin, uid, title); const req = trans.SetSpecialTitle.build(groupUin, uid, tittle);
await this.context.client.sendOidbPacket(req); await this.context.client.sendOidbPacket(req);
} }

View File

@@ -8,14 +8,14 @@ class SetSpecialTitle extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase>
super(); super();
} }
build(groupCode: number, uid: string, title: string): OidbPacket { build(groupCode: number, uid: string, tittle: string): OidbPacket {
const oidb_0x8FC_2 = new NapProtoMsg(proto.OidbSvcTrpcTcp0X8FC_2).encode({ const oidb_0x8FC_2 = new NapProtoMsg(proto.OidbSvcTrpcTcp0X8FC_2).encode({
groupUin: +groupCode, groupUin: +groupCode,
body: { body: {
targetUid: uid, targetUid: uid,
specialTitle: title, specialTitle: tittle,
expiredTime: -1, expiredTime: -1,
uinName: title uinName: tittle
} }
}); });
return OidbBase.build(0x8FC, 2, oidb_0x8FC_2, false, false); return OidbBase.build(0x8FC, 2, oidb_0x8FC_2, false, false);

View File

@@ -16,7 +16,7 @@ export interface NodeIKernelBuddyService {
getBuddyListFromCache(reqType: BuddyListReqType): Promise<Array< getBuddyListFromCache(reqType: BuddyListReqType): Promise<Array<
{ {
categoryId: number,//9999为特别关心 categoryId: number,//9999应该跳过 那是兜底数据吧
categorySortId: number,//排序方式 categorySortId: number,//排序方式
categroyName: string,//分类名 categroyName: string,//分类名
categroyMbCount: number,//不懂 categroyMbCount: number,//不懂

View File

@@ -207,7 +207,6 @@ interface PhotoWall {
// 简单信息 // 简单信息
export interface SimpleInfo { export interface SimpleInfo {
qqLevel?: QQLevel;//临时添加
uid?: string; uid?: string;
uin?: string; uin?: string;
coreInfo: CoreInfo; coreInfo: CoreInfo;

View File

@@ -7,13 +7,11 @@ import { SelfInfo } from '@/core/types';
import { NodeIKernelLoginListener } from '@/core/listeners'; import { NodeIKernelLoginListener } from '@/core/listeners';
import { NodeIKernelLoginService } from '@/core/services'; import { NodeIKernelLoginService } from '@/core/services';
import { NodeIQQNTWrapperSession, WrapperNodeApi } from '@/core/wrapper'; import { NodeIQQNTWrapperSession, WrapperNodeApi } from '@/core/wrapper';
import { InitWebUi, WebUiConfig, webUiRuntimePort } from '@/webui';
import { NapCatOneBot11Adapter } from '@/onebot'; import { NapCatOneBot11Adapter } from '@/onebot';
//Framework ES入口文件 //Framework ES入口文件
export async function getWebUiUrl() { export async function getWebUiUrl() {
const WebUiConfigData = (await WebUiConfig.GetWebUIConfig()); return 'http://127.0.0.1:' + 6099 + '/webui/?token=napcat';
return 'http://127.0.0.1:' + webUiRuntimePort + '/webui/?token=' + WebUiConfigData.token;
} }
export async function NCoreInitFramework( export async function NCoreInitFramework(
@@ -58,8 +56,6 @@ export async function NCoreInitFramework(
const loaderObject = new NapCatFramework(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper, pathWrapper); const loaderObject = new NapCatFramework(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper, pathWrapper);
await loaderObject.core.initCore(); await loaderObject.core.initCore();
//启动WebUi
InitWebUi(logger, pathWrapper).then().catch(e => logger.logError(e));
//初始化LLNC的Onebot实现 //初始化LLNC的Onebot实现
await new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper).InitOneBot(); await new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper).InitOneBot();
} }

View File

@@ -3,7 +3,6 @@ import Ajv, { ErrorObject, ValidateFunction } from 'ajv';
import { NapCatCore } from '@/core'; import { NapCatCore } from '@/core';
import { NapCatOneBot11Adapter, OB11Return } from '@/onebot'; import { NapCatOneBot11Adapter, OB11Return } from '@/onebot';
import { NetworkAdapterConfig } from '../config/config'; import { NetworkAdapterConfig } from '../config/config';
import { TSchema } from '@sinclair/typebox';
export class OB11Response { export class OB11Response {
private static createResponse<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null): OB11Return<T> { private static createResponse<T>(data: T, status: string, retcode: number, message: string = '', echo: unknown = null): OB11Return<T> {
@@ -34,7 +33,7 @@ export abstract class OneBotAction<PayloadType, ReturnDataType> {
actionName: typeof ActionName[keyof typeof ActionName] = ActionName.Unknown; actionName: typeof ActionName[keyof typeof ActionName] = ActionName.Unknown;
core: NapCatCore; core: NapCatCore;
private validate?: ValidateFunction<unknown> = undefined; private validate?: ValidateFunction<unknown> = undefined;
payloadSchema?: TSchema = undefined; payloadSchema?: unknown = undefined;
obContext: NapCatOneBot11Adapter; obContext: NapCatOneBot11Adapter;
constructor(obContext: NapCatOneBot11Adapter, core: NapCatCore) { constructor(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
@@ -44,7 +43,7 @@ export abstract class OneBotAction<PayloadType, ReturnDataType> {
protected async check(payload: PayloadType): Promise<BaseCheckResult> { protected async check(payload: PayloadType): Promise<BaseCheckResult> {
if (this.payloadSchema) { if (this.payloadSchema) {
this.validate = new Ajv({ allowUnionTypes: true, useDefaults: true, coerceTypes: true }).compile(this.payloadSchema); this.validate = new Ajv({ allowUnionTypes: true, useDefaults: true }).compile(this.payloadSchema);
} }
if (this.validate && !this.validate(payload)) { if (this.validate && !this.validate(payload)) {
const errors = this.validate.errors as ErrorObject[]; const errors = this.validate.errors as ErrorObject[];

View File

@@ -10,8 +10,8 @@ const SchemaData = Type.Object({
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
export class SetSpecialTitle extends GetPacketStatusDepends<Payload, void> { export class SetSpecialTittle extends GetPacketStatusDepends<Payload, void> {
override actionName = ActionName.SetSpecialTitle; override actionName = ActionName.SetSpecialTittle;
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle(payload: Payload) { async _handle(payload: Payload) {

View File

@@ -7,7 +7,6 @@ import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({ const SchemaData = Type.Object({
user_id: Type.Union([Type.Number(), Type.String()]), user_id: Type.Union([Type.Number(), Type.String()]),
no_cache: Type.Union([Type.Boolean(), Type.String()], { default: false }),
}); });
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
@@ -17,11 +16,10 @@ export default class GoCQHTTPGetStrangerInfo extends OneBotAction<Payload, OB11U
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle(payload: Payload) { async _handle(payload: Payload) {
const user_id = payload.user_id.toString(); const user_id = payload.user_id.toString();
const isNocache = typeof payload.no_cache === 'string' ? payload.no_cache === 'true' : !!payload.no_cache;
const extendData = await this.core.apis.UserApi.getUserDetailInfoByUin(user_id); const extendData = await this.core.apis.UserApi.getUserDetailInfoByUin(user_id);
let uid = (await this.core.apis.UserApi.getUidByUinV2(user_id)); let uid = (await this.core.apis.UserApi.getUidByUinV2(user_id));
if (!uid) uid = extendData.detail.uid; if (!uid) uid = extendData.detail.uid;
const info = (await this.core.apis.UserApi.getUserDetailInfo(uid, isNocache)); const info = (await this.core.apis.UserApi.getUserDetailInfo(uid));
return { return {
...extendData.detail.simpleInfo.coreInfo, ...extendData.detail.simpleInfo.coreInfo,
...extendData.detail.commonExt ?? {}, ...extendData.detail.commonExt ?? {},

View File

@@ -32,7 +32,7 @@ class GetGroupMemberInfo extends OneBotAction<Payload, OB11GroupMember> {
const [member, info] = await Promise.all([ const [member, info] = await Promise.all([
this.core.apis.GroupApi.getGroupMemberEx(payload.group_id.toString(), uid, isNocache), this.core.apis.GroupApi.getGroupMemberEx(payload.group_id.toString(), uid, isNocache),
this.core.apis.UserApi.getUserDetailInfo(uid, isNocache), this.core.apis.UserApi.getUserDetailInfo(uid),
]); ]);
if (!member || !groupMember) throw new Error(`群(${payload.group_id})成员${payload.user_id}不存在`); if (!member || !groupMember) throw new Error(`群(${payload.group_id})成员${payload.user_id}不存在`);

View File

@@ -81,7 +81,7 @@ import { GetGroupSystemMsg } from './system/GetSystemMsg';
import { GroupPoke } from './group/GroupPoke'; import { GroupPoke } from './group/GroupPoke';
import { GetUserStatus } from './extends/GetUserStatus'; import { GetUserStatus } from './extends/GetUserStatus';
import { GetRkey } from './extends/GetRkey'; import { GetRkey } from './extends/GetRkey';
import { SetSpecialTitle } from './extends/SetSpecialTitle'; import { SetSpecialTittle } from './extends/SetSpecialTittle';
import { GetGroupShutList } from './group/GetGroupShutList'; import { GetGroupShutList } from './group/GetGroupShutList';
import { GetGroupMemberList } from './group/GetGroupMemberList'; import { GetGroupMemberList } from './group/GetGroupMemberList';
import { GetGroupFileUrl } from '@/onebot/action/file/GetGroupFileUrl'; import { GetGroupFileUrl } from '@/onebot/action/file/GetGroupFileUrl';
@@ -215,7 +215,7 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new FriendPoke(obContext, core), new FriendPoke(obContext, core),
new GetUserStatus(obContext, core), new GetUserStatus(obContext, core),
new GetRkey(obContext, core), new GetRkey(obContext, core),
new SetSpecialTitle(obContext, core), new SetSpecialTittle(obContext, core),
new SetDiyOnlineStatus(obContext, core), new SetDiyOnlineStatus(obContext, core),
// new UploadForwardMsg(obContext, core), // new UploadForwardMsg(obContext, core),
new GetGroupShutList(obContext, core), new GetGroupShutList(obContext, core),

View File

@@ -31,7 +31,7 @@ export const ActionName = {
SetGroupCard: 'set_group_card', SetGroupCard: 'set_group_card',
SetGroupName: 'set_group_name', SetGroupName: 'set_group_name',
SetGroupLeave: 'set_group_leave', SetGroupLeave: 'set_group_leave',
SetSpecialTitle: 'set_group_special_title', SetSpecialTittle: 'set_group_special_title',
SetFriendAddRequest: 'set_friend_add_request', SetFriendAddRequest: 'set_friend_add_request',
SetGroupAddRequest: 'set_group_add_request', SetGroupAddRequest: 'set_group_add_request',
GetLoginInfo: 'get_login_info', GetLoginInfo: 'get_login_info',

View File

@@ -14,22 +14,8 @@ export default class GetFriendList extends OneBotAction<Payload, OB11User[]> {
override actionName = ActionName.GetFriendList; override actionName = ActionName.GetFriendList;
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle(_payload: Payload) { async _handle(payload: Payload) {
const buddyMap = await this.core.apis.FriendApi.getBuddyV2SimpleInfoMap(); //全新逻辑
const isNocache = typeof _payload.no_cache === 'string' ? _payload.no_cache === 'true' : !!_payload.no_cache; return OB11Construct.friends(await this.core.apis.FriendApi.getBuddy(typeof payload.no_cache === 'string' ? payload.no_cache === 'true' : !!payload.no_cache));
await Promise.all(
Array.from(buddyMap.values()).map(async (buddyInfo) => {
try {
const userDetail = await this.core.apis.UserApi.getUserDetailInfo(buddyInfo.coreInfo.uid, isNocache);
const data = buddyMap.get(buddyInfo.coreInfo.uid);
if (data) {
data.qqLevel = userDetail.qqLevel;
}
} catch (error) {
this.core.context.logger.logError('获取好友详细信息失败', error);
}
})
);
return OB11Construct.friends(Array.from(buddyMap.values()));
} }
} }

View File

@@ -151,15 +151,14 @@ export class OneBotGroupApi {
async parseOtherJsonEvent(msg: RawMessage, jsonStr: string, context: InstanceContext) { async parseOtherJsonEvent(msg: RawMessage, jsonStr: string, context: InstanceContext) {
const json = JSON.parse(jsonStr); const json = JSON.parse(jsonStr);
const type = json.items[json.items.length - 1]?.txt; const type = json.items[json.items.length - 1]?.txt;
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
if (type === '头衔') { if (type === '头衔') {
const memberUin = json.items[1].param[0]; const memberUin = json.items[1].param[0];
const title = json.items[3].txt; const title = json.items[3].txt;
context.logger.logDebug('收到群成员新头衔消息', json); context.logger.logDebug('收到群成员新头衔消息', json);
return new OB11GroupTitleEvent( return new OB11GroupTitleEvent(
this.core, this.core,
+msg.peerUid, parseInt(msg.peerUid),
+memberUin, parseInt(memberUin),
title, title,
); );
} else if (type === '移出') { } else if (type === '移出') {

View File

@@ -34,7 +34,7 @@ import { EventType } from '@/onebot/event/OneBotEvent';
import { encodeCQCode } from '@/onebot/helper/cqcode'; import { encodeCQCode } from '@/onebot/helper/cqcode';
import { uriToLocalFile } from '@/common/file'; import { uriToLocalFile } from '@/common/file';
import { RequestUtil } from '@/common/request'; import { RequestUtil } from '@/common/request';
import fsPromise from 'node:fs/promises'; import fsPromise, { constants } from 'node:fs/promises';
import { OB11FriendAddNoticeEvent } from '@/onebot/event/notice/OB11FriendAddNoticeEvent'; import { OB11FriendAddNoticeEvent } from '@/onebot/event/notice/OB11FriendAddNoticeEvent';
import { ForwardMsgBuilder } from '@/common/forward-msg-builder'; import { ForwardMsgBuilder } from '@/common/forward-msg-builder';
import { NapProtoMsg } from '@napneko/nap-proto-core'; import { NapProtoMsg } from '@napneko/nap-proto-core';
@@ -45,7 +45,6 @@ import { OB11GroupAdminNoticeEvent } from '../event/notice/OB11GroupAdminNoticeE
import { GroupChange, GroupChangeInfo, GroupInvite, PushMsgBody } from '@/core/packet/transformer/proto'; import { GroupChange, GroupChangeInfo, GroupInvite, PushMsgBody } from '@/core/packet/transformer/proto';
import { OB11GroupRequestEvent } from '../event/request/OB11GroupRequest'; import { OB11GroupRequestEvent } from '../event/request/OB11GroupRequest';
import { LRUCache } from '@/common/lru-cache'; import { LRUCache } from '@/common/lru-cache';
import { cleanTaskQueue } from '@/common/clean-task';
type RawToOb11Converters = { type RawToOb11Converters = {
[Key in keyof MessageElement as Key extends `${string}Element` ? Key : never]: ( [Key in keyof MessageElement as Key extends `${string}Element` ? Key : never]: (
@@ -373,8 +372,7 @@ export class OneBotMsgApi {
try { try {
multiMsgs = await this.core.apis.PacketApi.pkt.operation.FetchForwardMsg(element.resId); multiMsgs = await this.core.apis.PacketApi.pkt.operation.FetchForwardMsg(element.resId);
} catch (e) { } catch (e) {
this.core.context.logger.logError(`Protocol FetchForwardMsg fallback failed! this.core.context.logger.logError('Protocol FetchForwardMsg fallback failed!', e);
element = ${JSON.stringify(element)} , error=${e})`);
return null; return null;
} }
} }
@@ -556,7 +554,7 @@ export class OneBotMsgApi {
}, },
[OB11MessageDataType.voice]: async (sendMsg, context) => [OB11MessageDataType.voice]: async (sendMsg, context) =>
this.core.apis.FileApi.createValidSendPttElement(context, this.core.apis.FileApi.createValidSendPttElement(
(await this.handleOb11FileLikeMessage(sendMsg, context)).path), (await this.handleOb11FileLikeMessage(sendMsg, context)).path),
[OB11MessageDataType.json]: async ({ data: { data } }) => ({ [OB11MessageDataType.json]: async ({ data: { data } }) => ({
@@ -714,56 +712,6 @@ export class OneBotMsgApi {
this.obContext = obContext; this.obContext = obContext;
this.core = core; this.core = core;
} }
/**
* 解析带有JSON标记的文本
* @param text 要解析的文本
* @returns 解析后的结果数组,每个元素包含类型(text或json)和内容
*/
parseTextWithJson(text: string) {
// 匹配<{...}>格式的JSON
const regex = /<(\{.*?\})>/g;
const parts: Array<{ type: 'text' | 'json', content: string | object }> = [];
let lastIndex = 0;
let match;
// 查找所有匹配项
while ((match = regex.exec(text)) !== null) {
// 添加匹配前的文本
if (match.index > lastIndex) {
parts.push({
type: 'text',
content: text.substring(lastIndex, match.index)
});
}
// 添加JSON部分
try {
const jsonContent = JSON.parse(match[1] ?? '');
parts.push({
type: 'json',
content: jsonContent
});
} catch (e) {
// 如果JSON解析失败作为普通文本处理
parts.push({
type: 'text',
content: match[0]
});
}
lastIndex = regex.lastIndex;
}
// 添加最后一部分文本
if (lastIndex < text.length) {
parts.push({
type: 'text',
content: text.substring(lastIndex)
});
}
return parts;
}
async parsePrivateMsgEvent(msg: RawMessage, grayTipElement: GrayTipElement) { async parsePrivateMsgEvent(msg: RawMessage, grayTipElement: GrayTipElement) {
if (grayTipElement.subElementType == NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_JSON) { if (grayTipElement.subElementType == NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_JSON) {
@@ -1022,6 +970,7 @@ export class OneBotMsgApi {
}); });
const timeout = 10000 + (totalSize / 1024 / 256 * 1000); const timeout = 10000 + (totalSize / 1024 / 256 * 1000);
try { try {
const returnMsg = await this.core.apis.MsgApi.sendMsg(peer, sendElements, timeout); const returnMsg = await this.core.apis.MsgApi.sendMsg(peer, sendElements, timeout);
if (!returnMsg) throw new Error('发送消息失败'); if (!returnMsg) throw new Error('发送消息失败');
@@ -1034,19 +983,18 @@ export class OneBotMsgApi {
} catch (error) { } catch (error) {
throw new Error((error as Error).message); throw new Error((error as Error).message);
} finally { } finally {
cleanTaskQueue.addFiles(deleteAfterSentFiles, timeout); setTimeout(async () => {
// setTimeout(async () => { const deletePromises = deleteAfterSentFiles.map(async file => {
// const deletePromises = deleteAfterSentFiles.map(async file => { try {
// try { if (await fsPromise.access(file, constants.W_OK).then(() => true).catch(() => false)) {
// if (await fsPromise.access(file, constants.W_OK).then(() => true).catch(() => false)) { await fsPromise.unlink(file);
// await fsPromise.unlink(file); }
// } } catch (e) {
// } catch (e) { this.core.context.logger.logError('发送消息删除文件失败', e);
// this.core.context.logger.logError('发送消息删除文件失败', e); }
// } });
// }); await Promise.all(deletePromises);
// await Promise.all(deletePromises); }, 60000);
// }, 60000);
} }
} }
@@ -1265,41 +1213,6 @@ export class OneBotMsgApi {
} else if (SysMessage.contentHead.type == 528 && SysMessage.contentHead.subType == 39 && SysMessage.body?.msgContent) { } else if (SysMessage.contentHead.type == 528 && SysMessage.contentHead.subType == 39 && SysMessage.body?.msgContent) {
return await this.obContext.apis.UserApi.parseLikeEvent(SysMessage.body?.msgContent); return await this.obContext.apis.UserApi.parseLikeEvent(SysMessage.body?.msgContent);
} }
// else if (SysMessage.contentHead.type == 732 && SysMessage.contentHead.subType == 16 && SysMessage.body?.msgContent) {
// let data_wrap = PBString(2);
// let user_wrap = PBUint64(5);
// let group_wrap = PBUint64(4);
// ProtoBuf(class extends ProtoBufBase {
// group = group_wrap;
// content = ProtoBufIn(5, { data: data_wrap, user: user_wrap });
// }).decode(SysMessage.body?.msgContent.slice(7));
// let xml_data = UnWrap(data_wrap);
// let group = UnWrap(group_wrap).toString();
// //let user = UnWrap(user_wrap).toString();
// const parsedParts = this.parseTextWithJson(xml_data);
// //解析JSON
// if (parsedParts[1] && parsedParts[3]) {
// let set_user_id: string = (parsedParts[1].content as { data: string }).data;
// let uid = await this.core.apis.UserApi.getUidByUinV2(set_user_id);
// let new_title: string = (parsedParts[3].content as { text: string }).text;
// console.log(this.core.apis.GroupApi.groupMemberCache.get(group)?.get(uid)?.memberSpecialTitle, new_title)
// if (this.core.apis.GroupApi.groupMemberCache.get(group)?.get(uid)?.memberSpecialTitle == new_title) {
// return;
// }
// await this.core.apis.GroupApi.refreshGroupMemberCachePartial(group, uid);
// //let json_data_1_url_search = new URL((parsedParts[3].content as { url: string }).url).searchParams;
// //let is_new: boolean = json_data_1_url_search.get('isnew') === '1';
// //console.log(group, set_user_id, is_new, new_title);
// return new GroupMemberTitle(
// this.core,
// +group,
// +set_user_id,
// new_title
// );
// }
// }
return undefined; return undefined;
} }
} }

View File

@@ -20,36 +20,16 @@ export class OB11Construct {
static friends(friends: FriendV2[]): OB11User[] { static friends(friends: FriendV2[]): OB11User[] {
return friends.map(rawFriend => ({ return friends.map(rawFriend => ({
birthday_year: rawFriend.baseInfo.birthday_year, ...rawFriend.baseInfo,
birthday_month: rawFriend.baseInfo.birthday_month, ...rawFriend.coreInfo,
birthday_day: rawFriend.baseInfo.birthday_day,
user_id: parseInt(rawFriend.coreInfo.uin), user_id: parseInt(rawFriend.coreInfo.uin),
age: rawFriend.baseInfo.age,
phone_num: rawFriend.baseInfo.phoneNum,
email: rawFriend.baseInfo.eMail,
category_id: rawFriend.baseInfo.categoryId,
nickname: rawFriend.coreInfo.nick ?? '', nickname: rawFriend.coreInfo.nick ?? '',
remark: rawFriend.coreInfo.remark ?? rawFriend.coreInfo.nick, remark: rawFriend.coreInfo.remark ?? rawFriend.coreInfo.nick,
sex: this.sex(rawFriend.baseInfo.sex), sex: this.sex(rawFriend.baseInfo.sex),
level: rawFriend.qqLevel && calcQQLevel(rawFriend.qqLevel) || 0, level: 0,
})); }));
} }
static friend(friends: FriendV2): OB11User {
return {
birthday_year: friends.baseInfo.birthday_year,
birthday_month: friends.baseInfo.birthday_month,
birthday_day: friends.baseInfo.birthday_day,
user_id: parseInt(friends.coreInfo.uin),
age: friends.baseInfo.age,
phone_num: friends.baseInfo.phoneNum,
email: friends.baseInfo.eMail,
category_id: friends.baseInfo.categoryId,
nickname: friends.coreInfo.nick ?? '',
remark: friends.coreInfo.remark ?? friends.coreInfo.nick,
sex: this.sex(friends.baseInfo.sex),
level: 0,
};
}
static groupMemberRole(role: number): OB11GroupMemberRole | undefined { static groupMemberRole(role: number): OB11GroupMemberRole | undefined {
return { return {
4: OB11GroupMemberRole.owner, 4: OB11GroupMemberRole.owner,

View File

@@ -20,8 +20,7 @@ import {
OB11WebSocketClientAdapter, OB11WebSocketClientAdapter,
OB11NetworkManager, OB11NetworkManager,
OB11NetworkReloadType, OB11NetworkReloadType,
OB11HttpServerAdapter, OB11HttpServerAdapter
OB11WebSocketServerAdapter,
} from '@/onebot/network'; } from '@/onebot/network';
import { NapCatPathWrapper } from '@/common/path'; import { NapCatPathWrapper } from '@/common/path';
import { import {
@@ -32,7 +31,6 @@ import {
OneBotUserApi, OneBotUserApi,
} from '@/onebot/api'; } from '@/onebot/api';
import { ActionMap, createActionMap } from '@/onebot/action'; import { ActionMap, createActionMap } from '@/onebot/action';
import { WebUiDataRuntime } from '@/webui/src/helper/Data';
import { OB11InputStatusEvent } from '@/onebot/event/notice/OB11InputStatusEvent'; import { OB11InputStatusEvent } from '@/onebot/event/notice/OB11InputStatusEvent';
import { MessageUnique } from '@/common/message-unique'; import { MessageUnique } from '@/common/message-unique';
import { proxiedListenerOf } from '@/common/proxy-handler'; import { proxiedListenerOf } from '@/common/proxy-handler';
@@ -100,7 +98,7 @@ export class NapCatOneBot11Adapter {
const selfInfo = this.core.selfInfo; const selfInfo = this.core.selfInfo;
const ob11Config = this.configLoader.configData; const ob11Config = this.configLoader.configData;
this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid, false) this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid)
.then((user) => { .then((user) => {
selfInfo.nick = user.nick; selfInfo.nick = user.nick;
this.context.logger.setLogSelfInfo(selfInfo); this.context.logger.setLogSelfInfo(selfInfo);
@@ -139,15 +137,6 @@ export class NapCatOneBot11Adapter {
} }
for (const key of ob11Config.network.websocketServers) { for (const key of ob11Config.network.websocketServers) {
if (key.enable) { if (key.enable) {
this.networkManager.registerAdapter(
new OB11WebSocketServerAdapter(
key.name,
key,
this.core,
this,
this.actions
)
);
} }
} }
for (const key of ob11Config.network.websocketClients) { for (const key of ob11Config.network.websocketClients) {
@@ -168,64 +157,9 @@ export class NapCatOneBot11Adapter {
this.initMsgListener(); this.initMsgListener();
this.initBuddyListener(); this.initBuddyListener();
this.initGroupListener(); this.initGroupListener();
WebUiDataRuntime.setQQVersion(this.core.context.basicInfoWrapper.getFullQQVesion());
WebUiDataRuntime.setQQLoginInfo(selfInfo);
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setOnOB11ConfigChanged(async (newConfig) => {
const prev = this.configLoader.configData;
this.configLoader.save(newConfig);
//this.context.logger.log(`OneBot11 配置更改:${JSON.stringify(prev)} -> ${JSON.stringify(newConfig)}`);
await this.reloadNetwork(prev, newConfig);
});
} }
private async reloadNetwork(prev: OneBotConfig, now: OneBotConfig): Promise<void> {
const prevLog = await this.creatOneBotLog(prev);
const newLog = await this.creatOneBotLog(now);
this.context.logger.log(`[Notice] [OneBot11] 配置变更前:\n${prevLog}`);
this.context.logger.log(`[Notice] [OneBot11] 配置变更后:\n${newLog}`);
await this.handleConfigChange(prev.network.httpServers, now.network.httpServers, OB11HttpServerAdapter);
await this.handleConfigChange(prev.network.httpClients, now.network.httpClients, OB11HttpClientAdapter);
await this.handleConfigChange(prev.network.httpSseServers, now.network.httpSseServers, OB11HttpSSEServerAdapter);
await this.handleConfigChange(prev.network.websocketServers, now.network.websocketServers, OB11WebSocketServerAdapter);
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
}
private async handleConfigChange<CT extends NetworkAdapterConfig>(
prevConfig: NetworkAdapterConfig[],
nowConfig: NetworkAdapterConfig[],
adapterClass: new (
...args: ConstructorParameters<typeof IOB11NetworkAdapter<CT>>
) => IOB11NetworkAdapter<CT>
): Promise<void> {
// 比较旧的在新的找不到的回收
for (const adapterConfig of prevConfig) {
const existingAdapter = nowConfig.find((e) => e.name === adapterConfig.name);
if (!existingAdapter) {
const existingAdapter = this.networkManager.findSomeAdapter(adapterConfig.name);
if (existingAdapter) {
await this.networkManager.closeSomeAdaterWhenOpen([existingAdapter]);
}
}
}
// 通知新配置重载 删除关闭的 加入新开的
for (const adapterConfig of nowConfig) {
const existingAdapter = this.networkManager.findSomeAdapter(adapterConfig.name);
if (existingAdapter) {
const networkChange = await existingAdapter.reload(adapterConfig);
if (networkChange === OB11NetworkReloadType.NetWorkClose) {
await this.networkManager.closeSomeAdaterWhenOpen([existingAdapter]);
}
} else if (adapterConfig.enable) {
const newAdapter = new adapterClass(adapterConfig.name, adapterConfig as CT, this.core, this, this.actions);
await this.networkManager.registerAdapterAndOpen(newAdapter);
}
}
}
private initMsgListener() { private initMsgListener() {
const msgListener = new NodeIKernelMsgListener(); const msgListener = new NodeIKernelMsgListener();
msgListener.onRecvSysMsg = (msg) => { msgListener.onRecvSysMsg = (msg) => {

View File

@@ -1,33 +1,59 @@
import { OB11EmitEventContent } from './index'; import { OB11EmitEventContent } from './index';
import { Request, Response } from 'express';
import { OB11HttpServerAdapter } from './http-server'; import { OB11HttpServerAdapter } from './http-server';
import { Context } from 'hono';
import { SSEStreamingApi, streamSSE } from 'hono/streaming';
import { Mutex } from 'async-mutex';
import { LifeCycleSubType, OB11LifeCycleEvent } from '../event/meta/OB11LifeCycleEvent';
export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter { export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
private sseClients: Response[] = []; private sseClients: { context: Context; stream: SSEStreamingApi, mutex: Mutex }[] = [];
override async handleRequest(req: Request, res: Response) { override async actionHandler(c: Context): Promise<any> {
if (req.path === '/_events') { if (c.req.path === '/_events') {
this.createSseSupport(req, res); return await this.createSseSupport(c);
} else { } else {
super.httpApiRequest(req, res); return super.actionHandler(c);
} }
} }
private async createSseSupport(req: Request, res: Response) { private async createSseSupport(c: Context) {
res.setHeader('Content-Type', 'text/event-stream'); return streamSSE(c, async (stream) => {
res.setHeader('Cache-Control', 'no-cache'); const client = { context: c, stream, mutex: new Mutex() };
res.setHeader('Connection', 'keep-alive'); this.sseClients.push(client);
res.flushHeaders(); client.mutex.acquire();
this.sseClients.push(res); stream.onAbort(() => {
req.on('close', () => { this.removeClient(stream);
this.sseClients = this.sseClients.filter((client) => client !== res); client.mutex.release();
});
await stream.writeSSE({ data: JSON.stringify(new OB11LifeCycleEvent(this.core, LifeCycleSubType.CONNECT)) });
await client.mutex.waitForUnlock();
}); });
} }
private removeClient(stream: SSEStreamingApi) {
const index = this.sseClients.findIndex(client => client.stream === stream);
if (index !== -1) {
this.sseClients.splice(index, 1);
}
}
override onEvent<T extends OB11EmitEventContent>(event: T) { override onEvent<T extends OB11EmitEventContent>(event: T) {
this.sseClients.forEach((res) => { const eventData = JSON.stringify(event);
res.write(`data: ${JSON.stringify(event)}\n\n`);
Promise.all(
this.sseClients.map(async ({ stream, mutex }) => {
try {
await stream.writeSSE({ data: eventData });
} catch (error) {
mutex.release();
this.removeClient(stream);
}
})
).then().catch((error) => {
this.core.context.logger.logError('Error sending SSE event:', error);
}); });
} }
} }

View File

@@ -1,19 +1,17 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import express, { Express, NextFunction, Request, Response } from 'express'; import { Context, Hono, Next } from 'hono';
import http from 'http';
import { NapCatCore } from '@/core'; import { NapCatCore } from '@/core';
import { OB11Response } from '@/onebot/action/OneBotAction'; import { OB11Response } from '@/onebot/action/OneBotAction';
import { ActionMap } from '@/onebot/action'; import { ActionMap } from '@/onebot/action';
import cors from 'cors'; import { cors } from 'hono/cors';
import { HttpServerConfig } from '@/onebot/config/config'; import { HttpServerConfig } from '@/onebot/config/config';
import { NapCatOneBot11Adapter } from '@/onebot'; import { NapCatOneBot11Adapter } from '@/onebot';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter'; import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import json5 from 'json5'; import { serve } from '@hono/node-server';
import { isFinished } from 'on-finished';
import typeis from 'type-is';
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> { export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
private app: Express | undefined; private app: Hono | undefined;
private server: http.Server | undefined; private server: ReturnType<typeof serve> | undefined;
constructor(name: string, config: HttpServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) { constructor(name: string, config: HttpServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) {
super(name, config, core, obContext, actions); super(name, config, core, obContext, actions);
@@ -27,17 +25,14 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
open() { open() {
try { try {
if (this.isEnable) { if (this.isEnable) {
this.core.context.logger.logError('Cannot open a closed HTTP server'); this.core.context.logger.logError('[OneBot] [HTTP Server Adapter] 无法打开已经启动的HTTP服务器');
return; return;
} }
if (!this.isEnable) { this.initializeServer();
this.initializeServer(); this.isEnable = true;
this.isEnable = true;
}
} catch (e) { } catch (e) {
this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] Boot Error: ${e}`); this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] 启动错误: ${e}`);
} }
} }
async close() { async close() {
@@ -46,101 +41,159 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
this.app = undefined; this.app = undefined;
} }
private initializeServer() { private initializeServer() {
this.app = express(); this.app = new Hono();
this.server = http.createServer(this.app);
// 注册全局中间件
this.app.use(cors()); this.app.use(cors());
this.app.use(express.urlencoded({ extended: true, limit: '5000mb' })); this.app.use(this.authMiddleware.bind(this));
this.app.use(this.statusCheckMiddleware.bind(this));
this.app.use(this.payloadParserMiddleware.bind(this));
this.app.use((req, res, next) => { // 注册路由
if (isFinished(req)) { this.app.get('/', this.rootHandler.bind(this));
next(); this.app.all('/*', this.actionHandler.bind(this));
return;
} // 启动服务器
if (!typeis.hasBody(req)) { this.server = serve({
next(); fetch: this.app.fetch.bind(this.app),
return; port: this.config.port,
}
// 兼容处理没有带content-type的请求
req.headers['content-type'] = 'application/json';
let rawData = '';
req.on('data', (chunk) => {
rawData += chunk;
});
req.on('end', () => {
try {
req.body = { ...json5.parse(rawData || '{}'), ...req.body };
next();
} catch {
return res.status(400).send('Invalid JSON');
}
return;
});
req.on('error', () => {
return res.status(400).send('Invalid JSON');
});
});
//@ts-expect-error authorize
this.app.use((req, res, next) => this.authorize(this.config.token, req, res, next));
this.app.use(async (req, res) => {
await this.handleRequest(req, res);
});
this.server.listen(this.config.port, () => {
this.core.context.logger.log(`[OneBot] [HTTP Server Adapter] Start On Port ${this.config.port}`);
}); });
this.core.context.logger.log(`[OneBot] [HTTP Server Adapter] 服务器已启动于端口 ${this.config.port}`);
} }
private authorize(token: string | undefined, req: Request, res: Response, next: NextFunction) { /**
if (!token || token.length == 0) return next();//客户端未设置密钥 * 身份验证中间件
const HeaderClientToken = req.headers.authorization?.split('Bearer ').pop() || ''; */
const QueryClientToken = req.query['access_token']; private async authMiddleware(c: Context, next: Next) {
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken; const token = this.config.token;
if (ClientToken === token) { if (!token || token.length === 0) {
return next(); // 未配置token跳过验证
}
// 从请求头或查询参数获取token
const headerToken = c.req.header('authorization')?.split('Bearer ').pop() || '';
const queryToken = c.req.query('access_token');
const clientToken = typeof queryToken === 'string' && queryToken !== ''
? queryToken
: headerToken;
if (clientToken === token) {
return next(); return next();
} else { }
return res.status(403).send(JSON.stringify({ message: 'token verify failed!' }));
// 验证失败
c.status(403);
return c.json({ message: 'token验证失败' });
}
/**
* 服务器状态检查中间件
*/
private async statusCheckMiddleware(c: Context, next: Next) {
if (!this.isEnable) {
this.core.context.logger.log('[OneBot] [HTTP Server Adapter] 服务器已关闭');
return c.json(OB11Response.error('服务器已关闭', 200));
}
return next();
}
/**
* 请求参数解析中间件
* 按优先级解析请求参数JSON > 表单 > 查询参数
*/
private async payloadParserMiddleware(c: Context, next: Next) {
try {
// 初始化payload对象
let payload: Record<string, any> = {};
// 1. 提取查询参数
const queryParams = c.req.query();
if (Object.keys(queryParams).length > 0) {
payload = { ...queryParams };
}
// 2. 解析请求体
const contentType = c.req.header('content-type') || '';
let bodyData = {};
try {
// 优先尝试以JSON格式解析
if (contentType.includes('application/json') || contentType === '' || contentType.includes('text/plain')) {
try {
bodyData = await c.req.json();
} catch {
// JSON解析失败时尝试其他方式
}
}
// 如果JSON解析失败或不是JSON格式尝试其他格式
if (Object.keys(bodyData).length === 0) {
if (contentType.includes('application/x-www-form-urlencoded') ||
contentType.includes('multipart/form-data')) {
bodyData = await c.req.parseBody();
} else if (contentType) {
// 尝试通用解析
bodyData = await c.req.parseBody();
}
}
} catch (parseError) {
// 所有解析方式都失败,记录错误但继续处理
this.core.context.logger.log(`[OneBot] [HTTP Server Adapter] 请求体解析失败: ${parseError}`);
}
// 3. 合并参数
payload = { ...payload, ...bodyData };
// 4. 将解析结果保存到上下文
c.set('payload', payload);
return next();
} catch (error) {
this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] 请求处理错误: ${error}`);
return c.json(OB11Response.error(`参数解析失败: ${(error as Error)?.message || '未知错误'}`, 200));
} }
} }
async httpApiRequest(req: Request, res: Response) { /**
let payload = req.body; * 根路径处理器
if (req.method == 'get') { */
payload = req.query; private rootHandler(c: Context) {
} else if (req.query) { const response = OB11Response.ok({});
payload = { ...req.body, ...req.query }; response.message = 'NapCat4 Is Running';
} return c.json(response);
if (req.path === '' || req.path === '/') { }
const hello = OB11Response.ok({});
hello.message = 'NapCat4 Is Running'; /**
return res.json(hello); * API动作处理器
} */
const actionName = req.path.split('/')[1]; async actionHandler(c: Context) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any try {
const action = this.actions.get(actionName as any); const payload = c.get('payload') as Record<string, any>;
if (action) { const actionName = c.req.path.split('/')[1];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = this.actions.get(actionName as any);
if (!action) {
return c.json(OB11Response.error(`不支持的API: ${actionName}`, 200));
}
try { try {
const result = await action.handle(payload, this.name, this.config); const result = await action.handle(payload, this.name, this.config);
return res.json(result); return c.json(result);
} catch (error: unknown) { } catch (error: unknown) {
return res.json(OB11Response.error((error as Error)?.stack?.toString() || (error as Error)?.message || 'Error Handle', 200)); const errorMessage = (error as Error)?.stack || (error as Error)?.message || 'Error Handle';
this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] API处理错误: ${errorMessage}`);
return c.json(OB11Response.error(errorMessage, 200));
} }
} else { } catch (error: unknown) {
return res.json(OB11Response.error('不支持的Api ' + actionName, 200)); const errorMessage = (error as Error)?.message || '未知错误';
this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] 请求处理失败: ${errorMessage}`);
return c.json(OB11Response.error(`请求处理失败: ${errorMessage}`, 200));
} }
} }
async handleRequest(req: Request, res: Response) {
if (!this.isEnable) {
this.core.context.logger.log('[OneBot] [HTTP Server Adapter] Server is closed');
res.json(OB11Response.error('Server is closed', 200));
return;
}
this.httpApiRequest(req, res);
return;
}
async reload(newConfig: HttpServerConfig) { async reload(newConfig: HttpServerConfig) {
const wasEnabled = this.isEnable; const wasEnabled = this.isEnable;
const oldPort = this.config.port; const oldPort = this.config.port;
@@ -164,4 +217,4 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
return OB11NetworkReloadType.Normal; return OB11NetworkReloadType.Normal;
} }
} }

View File

@@ -103,5 +103,4 @@ export class OB11NetworkManager {
export * from './http-client'; export * from './http-client';
export * from './websocket-client'; export * from './websocket-client';
export * from './http-server'; export * from './http-server';
export * from './websocket-server';

View File

@@ -1,5 +1,4 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/onebot/network/index'; import { OB11EmitEventContent, OB11NetworkReloadType } from '@/onebot/network/index';
import { RawData, WebSocket } from 'ws';
import { OB11HeartbeatEvent } from '@/onebot/event/meta/OB11HeartbeatEvent'; import { OB11HeartbeatEvent } from '@/onebot/event/meta/OB11HeartbeatEvent';
import { NapCatCore } from '@/core'; import { NapCatCore } from '@/core';
import { ActionName } from '@/onebot/action/router'; import { ActionName } from '@/onebot/action/router';
@@ -10,10 +9,12 @@ import { WebsocketClientConfig } from '@/onebot/config/config';
import { NapCatOneBot11Adapter } from '@/onebot'; import { NapCatOneBot11Adapter } from '@/onebot';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter'; import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import json5 from 'json5'; import json5 from 'json5';
import { hc } from 'hono/client';
export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketClientConfig> { export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketClientConfig> {
private connection: WebSocket | null = null; private connection: WebSocket | null = null;
private heartbeatRef: NodeJS.Timeout | null = null; private heartbeatRef: NodeJS.Timeout | null = null;
private client = hc(this.config.url);
constructor(name: string, config: WebsocketClientConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) { constructor(name: string, config: WebsocketClientConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) {
super(name, config, core, obContext, actions); super(name, config, core, obContext, actions);
@@ -37,13 +38,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
}, this.config.heartInterval); }, this.config.heartInterval);
} }
this.isEnable = true; this.isEnable = true;
try { await this.tryConnect();
await this.tryConnect();
} catch (error) {
this.logger.logError('[OneBot] [WebSocket Client] TryConnect Error , info -> ', error);
}
} }
close() { close() {
@@ -71,37 +66,23 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
private async tryConnect() { private async tryConnect() {
if (!this.connection && this.isEnable) { if (!this.connection && this.isEnable) {
let isClosedByError = false; let isClosedByError = false;
let wsClientX = this.client['ws']?.$ws(0);
if (!wsClientX) throw new Error('WebSocket Client Error');
this.connection = wsClientX;
this.connection = new WebSocket(this.config.url, { this.connection.addEventListener('open', () => {
maxPayload: 1024 * 1024 * 1024,
handshakeTimeout: 2000,
perMessageDeflate: false,
headers: {
'X-Self-ID': this.core.selfInfo.uin,
'Authorization': `Bearer ${this.config.token}`,
'x-client-role': 'Universal', // 为koishi adpter适配
'User-Agent': 'OneBot/11',
},
});
this.connection.on('ping', () => {
this.connection?.pong();
});
this.connection.on('pong', () => {
//this.logger.logDebug('[OneBot] [WebSocket Client] 收到pong');
});
this.connection.on('open', () => {
try { try {
this.connectEvent(this.core); this.connectEvent(this.core);
} catch (e) { } catch (e) {
this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e); this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e);
} }
});
this.connection.addEventListener('message', (event) => {
this.handleMessage(event.data);
}); });
this.connection.on('message', (data) => {
this.handleMessage(data); this.connection.addEventListener('close', () => {
});
this.connection.once('close', () => {
if (!isClosedByError) { if (!isClosedByError) {
this.logger.logError(`[OneBot] [WebSocket Client] 反向WebSocket (${this.config.url}) 连接意外关闭`); this.logger.logError(`[OneBot] [WebSocket Client] 反向WebSocket (${this.config.url}) 连接意外关闭`);
this.logger.logError(`[OneBot] [WebSocket Client] 在 ${Math.floor(this.config.reconnectInterval / 1000)} 秒后尝试重新连接`); this.logger.logError(`[OneBot] [WebSocket Client] 在 ${Math.floor(this.config.reconnectInterval / 1000)} 秒后尝试重新连接`);
@@ -111,7 +92,8 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
} }
} }
}); });
this.connection.on('error', (err) => {
this.connection.addEventListener('error', (err) => {
isClosedByError = true; isClosedByError = true;
this.logger.logError(`[OneBot] [WebSocket Client] 反向WebSocket (${this.config.url}) 连接错误`, err); this.logger.logError(`[OneBot] [WebSocket Client] 反向WebSocket (${this.config.url}) 连接错误`, err);
this.logger.logError(`[OneBot] [WebSocket Client] 在 ${Math.floor(this.config.reconnectInterval / 1000)} 秒后尝试重新连接`); this.logger.logError(`[OneBot] [WebSocket Client] 在 ${Math.floor(this.config.reconnectInterval / 1000)} 秒后尝试重新连接`);
@@ -130,7 +112,8 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
this.logger.logError('[OneBot] [WebSocket Client] 发送生命周期失败', e); this.logger.logError('[OneBot] [WebSocket Client] 发送生命周期失败', e);
} }
} }
private async handleMessage(message: RawData) {
private async handleMessage(message: MessageEvent) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any } = { action: ActionName.Unknown, params: {} }; let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any } = { action: ActionName.Unknown, params: {} };
let echo = undefined; let echo = undefined;
@@ -154,6 +137,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config); const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config);
this.checkStateAndReply<unknown>({ ...retdata }); this.checkStateAndReply<unknown>({ ...retdata });
} }
async reload(newConfig: WebsocketClientConfig) { async reload(newConfig: WebsocketClientConfig) {
const wasEnabled = this.isEnable; const wasEnabled = this.isEnable;
const oldUrl = this.config.url; const oldUrl = this.config.url;
@@ -193,4 +177,4 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
return OB11NetworkReloadType.Normal; return OB11NetworkReloadType.Normal;
} }
} }

View File

@@ -1,196 +1,210 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import urlParse from 'url';
import { RawData, WebSocket, WebSocketServer } from 'ws';
import { Mutex } from 'async-mutex';
import { OB11Response } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { NapCatCore } from '@/core'; import { NapCatCore } from '@/core';
import { OB11HeartbeatEvent } from '@/onebot/event/meta/OB11HeartbeatEvent';
import { IncomingMessage } from 'http';
import { ActionMap } from '@/onebot/action'; import { ActionMap } from '@/onebot/action';
import { LifeCycleSubType, OB11LifeCycleEvent } from '@/onebot/event/meta/OB11LifeCycleEvent';
import { WebsocketServerConfig } from '@/onebot/config/config'; import { WebsocketServerConfig } from '@/onebot/config/config';
import { NapCatOneBot11Adapter } from '@/onebot'; import { NapCatOneBot11Adapter } from '@/onebot';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter'; import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import json5 from 'json5'; import { serve } from '@hono/node-server';
import { Context, Hono } from 'hono';
import { createNodeWebSocket } from '@hono/node-ws';
import { WSContext, WSMessageReceive } from 'hono/ws';
import { OB11Response } from '../action/OneBotAction';
import { ActionName } from '../action/router';
import { OB11HeartbeatEvent } from '@/onebot/event/meta/OB11HeartbeatEvent';
import { LifeCycleSubType, OB11LifeCycleEvent } from '@/onebot/event/meta/OB11LifeCycleEvent';
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> { export class OB11WebsocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
wsServer?: WebSocketServer; private app: Hono | undefined;
wsClients: WebSocket[] = []; private server: ReturnType<typeof serve> | undefined;
wsClientsMutex = new Mutex(); private clients: Set<WSContext<any>> = new Set();
private eventClients: Set<WSContext<any>> = new Set(); // 仅用于接收事件的客户端
private heartbeatIntervalId: NodeJS.Timeout | null = null; private heartbeatIntervalId: NodeJS.Timeout | null = null;
wsClientWithEvent: WebSocket[] = [];
constructor( constructor(name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) {
name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
) {
super(name, config, core, obContext, actions); super(name, config, core, obContext, actions);
this.wsServer = new WebSocketServer({
port: this.config.port,
host: this.config.host === '0.0.0.0' ? '' : this.config.host,
maxPayload: 1024 * 1024 * 1024,
});
this.createServer(this.wsServer);
} }
createServer(newServer: WebSocketServer) {
newServer.on('connection', async (wsClient, wsReq) => {
if (!this.isEnable) {
wsClient.close();
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) {
this.connectEvent(this.core, wsClient);
}
wsClient.on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Client Error:', err.message)); override onEvent<T extends OB11EmitEventContent>(event: T) {
wsClient.on('message', (message) => { if (!this.isEnable || this.eventClients.size === 0) return;
this.handleMessage(wsClient, message).then().catch(e => this.logger.logError(e));
});
wsClient.on('ping', () => {
wsClient.pong();
});
wsClient.on('pong', () => {
//this.logger.logDebug('[OneBot] [WebSocket Server] Pong received');
});
wsClient.once('close', () => {
this.wsClientsMutex.runExclusive(async () => {
const NormolIndex = this.wsClients.indexOf(wsClient);
if (NormolIndex !== -1) {
this.wsClients.splice(NormolIndex, 1);
}
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1);
}
});
});
await this.wsClientsMutex.runExclusive(async () => {
if (!isApiConnect) {
this.wsClientWithEvent.push(wsClient);
}
this.wsClients.push(wsClient);
});
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
}
connectEvent(core: NapCatCore, wsClient: WebSocket) {
try { try {
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient); const eventData = JSON.stringify(event);
} catch (e) { this.eventClients.forEach(client => {
this.logger.logError('[OneBot] [WebSocket Server] 发送生命周期失败', e); try {
} client.send(eventData);
} } catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 向客户端发送事件失败: ${e}`);
onEvent<T extends OB11EmitEventContent>(event: T) { }
this.wsClientsMutex.runExclusive(async () => {
this.wsClientWithEvent.forEach((wsClient) => {
wsClient.send(JSON.stringify(event));
}); });
});
if (this.config.debug) {
this.core.context.logger.logDebug(`[OneBot] [Websocket Server Adapter] 已广播事件到 ${this.eventClients.size} 个客户端`);
}
} catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 事件序列化失败: ${e}`);
}
} }
open() { open() {
if (this.isEnable) { try {
this.logger.logError('[OneBot] [WebSocket Server] Cannot open a opened WebSocket server'); if (this.isEnable) {
return; this.core.context.logger.logError('[OneBot] [Websocket Server Adapter] 无法打开已经启动的Websocket服务器');
} return;
const addressInfo = this.wsServer?.address(); }
this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port); this.initializeServer();
this.isEnable = true;
this.isEnable = true; // 启动心跳
if (this.config.heartInterval > 0) { if (this.config.heartInterval > 0) {
this.registerHeartBeat(); this.registerHeartBeat();
}
} catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 启动错误: ${e}`);
} }
} }
async close() { async close() {
this.isEnable = false; this.isEnable = false;
this.wsServer?.close((err) => { this.clients.clear();
if (err) { this.eventClients.clear();
this.logger.logError('[OneBot] [WebSocket Server] Error closing server:', err.message);
} else {
this.logger.log('[OneBot] [WebSocket Server] Server Closed');
}
}); // 清除心跳定时器
if (this.heartbeatIntervalId) { if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId); clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null; this.heartbeatIntervalId = null;
} }
await this.wsClientsMutex.runExclusive(async () => {
this.wsClients.forEach((wsClient) => { this.server?.close();
wsClient.close(); this.app = undefined;
});
this.wsClients = [];
this.wsClientWithEvent = [];
});
} }
private registerHeartBeat() { private registerHeartBeat() {
this.heartbeatIntervalId = setInterval(() => { this.heartbeatIntervalId = setInterval(() => {
this.wsClientsMutex.runExclusive(async () => { if (!this.isEnable || this.eventClients.size === 0) return;
this.wsClientWithEvent.forEach((wsClient) => {
if (wsClient.readyState === WebSocket.OPEN) { try {
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true))); const heartbeatEvent = new OB11HeartbeatEvent(
this.core,
this.config.heartInterval,
this.core.selfInfo.online ?? true,
true
);
const eventData = JSON.stringify(heartbeatEvent);
this.eventClients.forEach(client => {
try {
client.send(eventData);
} catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 发送心跳失败: ${e}`);
} }
}); });
}); } catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 心跳事件生成失败: ${e}`);
}
}, this.config.heartInterval); }, this.config.heartInterval);
} }
private authorize(token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) { private initializeServer() {
if (!token || token.length == 0) return;//客户端未设置密钥 this.app = new Hono();
const QueryClientToken = urlParse.parse(wsReq?.url || '', true).query['access_token']; const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app: this.app });
const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || '';
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken; // 处理所有WebSocket请求
if (ClientToken === token) { this.app.all('/*', upgradeWebSocket((c) => {
// 鉴权处理
if (this.config.token && this.config.token.length > 0) {
const url = new URL(c.req.url, `http://${c.req.header('host') || 'localhost'}`);
const queryToken = url.searchParams.get('access_token');
const authHeader = c.req.header('authorization');
const headerToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : '';
const clientToken = queryToken || headerToken;
if (clientToken !== this.config.token) {
return {
onOpen: (_evt, ws) => {
ws.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
ws.close();
}
};
}
}
// 判断连接类型
const url = new URL(c.req.url, `http://${c.req.header('host') || 'localhost'}`);
const path = url.pathname;
const isApiConnect = path === '/api' || path === '/api/';
return {
onOpen: (_evt, ws) => {
this.clients.add(ws);
// 仅对非API连接添加到事件客户端列表
if (!isApiConnect) {
this.eventClients.add(ws);
// 发送连接生命周期事件
try {
ws.send(JSON.stringify(new OB11LifeCycleEvent(this.core, LifeCycleSubType.CONNECT)));
} catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 发送生命周期事件失败: ${e}`);
}
}
this.core.context.logger.log(`[OneBot] [Websocket Server Adapter] 客户端已连接,类型: ${isApiConnect ? 'API' : '事件'},当前连接数: ${this.clients.size}`);
},
onMessage: (evt, ws) => {
this.actionHandler(c, evt, ws);
},
onClose: (_evt, ws) => {
this.clients.delete(ws);
this.eventClients.delete(ws);
this.core.context.logger.log(`[OneBot] [Websocket Server Adapter] 客户端已断开,当前连接数: ${this.clients.size}`);
},
onError: (error) => {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] WebSocket错误: ${error}`);
}
};
}));
// 启动服务器
this.server = serve({
fetch: this.app.fetch.bind(this.app),
port: this.config.port,
hostname: this.config.host === '0.0.0.0' ? undefined : this.config.host,
});
injectWebSocket(this.server);
this.core.context.logger.log(`[OneBot] [Websocket Server Adapter] 服务器已启动于 ${this.config.host}:${this.config.port}`);
}
async actionHandler<T>(_c: Context, evt: MessageEvent<WSMessageReceive>, ws: WSContext<T>) {
const { data } = evt;
if (typeof data !== 'string') {
this.core.context.logger.logError('[OneBot] [Websocket Server Adapter] 收到非字符串消息');
return; return;
} }
wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
wsClient.close();
}
private checkStateAndReply<T>(data: T, wsClient: WebSocket) {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(data));
}
}
private async handleMessage(wsClient: WebSocket, message: RawData) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any } = { action: ActionName.Unknown, params: {} }; let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any } = { action: ActionName.Unknown, params: {} };
let echo = undefined; let echo = undefined;
try { try {
receiveData = json5.parse(message.toString()); receiveData = JSON.parse(data);
echo = receiveData.echo; echo = receiveData.echo;
//this.logger.logDebug('收到正向Websocket消息', receiveData);
} catch { } catch {
this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient); return ws.send(JSON.stringify(OB11Response.error('json解析失败,请检查数据格式', 1400, echo)));
return;
} }
receiveData.params = (receiveData?.params) ? receiveData.params : {};//兼容类型验证 不然类型校验爆炸 receiveData.params = (receiveData?.params) ? receiveData.params : {}; // 兼容类型验证
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = this.actions.get(receiveData.action as any); const action = this.actions.get(receiveData.action as any);
if (!action) { if (!action) {
this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的API ' + receiveData.action); this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的API ' + receiveData.action);
this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient); return ws.send(JSON.stringify(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo)));
return;
} }
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config); const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config);
this.checkStateAndReply<unknown>({ ...retdata }, wsClient); ws.send(JSON.stringify({ ...retdata }));
} }
async reload(newConfig: WebsocketServerConfig) { async reload(newConfig: WebsocketServerConfig) {
const wasEnabled = this.isEnable; const wasEnabled = this.isEnable;
const oldPort = this.config.port; const oldPort = this.config.port;
const oldHost = this.config.host; const oldHost = this.config.host;
const oldHeartbeatInterval = this.config.heartInterval; const oldHeartInterval = this.config.heartInterval;
this.config = newConfig; this.config = newConfig;
if (newConfig.enable && !wasEnabled) { if (newConfig.enable && !wasEnabled) {
@@ -201,21 +215,17 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
return OB11NetworkReloadType.NetWorkClose; return OB11NetworkReloadType.NetWorkClose;
} }
// 端口或主机变更需要重启服务器
if (oldPort !== newConfig.port || oldHost !== newConfig.host) { if (oldPort !== newConfig.port || oldHost !== newConfig.host) {
this.close(); this.close();
this.wsServer = new WebSocketServer({
port: newConfig.port,
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
maxPayload: 1024 * 1024 * 1024,
});
this.createServer(this.wsServer);
if (newConfig.enable) { if (newConfig.enable) {
this.open(); this.open();
} }
return OB11NetworkReloadType.NetWorkReload; return OB11NetworkReloadType.NetWorkReload;
} }
if (oldHeartbeatInterval !== newConfig.heartInterval) { // 心跳间隔变更需要重新设置心跳
if (oldHeartInterval !== newConfig.heartInterval) {
if (this.heartbeatIntervalId) { if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId); clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null; this.heartbeatIntervalId = null;
@@ -228,5 +238,4 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
return OB11NetworkReloadType.Normal; return OB11NetworkReloadType.Normal;
} }
} }

View File

@@ -1,10 +1,4 @@
export interface OB11User { export interface OB11User {
birthday_year?: number; // 生日
birthday_month?: number; // 生日
birthday_day?: number; // 生日
phone_num?: string; // 手机号
email?: string; // 邮箱
category_id?: number; // 分组ID
user_id: number; // 用户ID user_id: number; // 用户ID
nickname: string; // 昵称 nickname: string; // 昵称
remark?: string; // 备注 remark?: string; // 备注

View File

@@ -26,8 +26,6 @@ import { LoginListItem, NodeIKernelLoginService } from '@/core/services';
import { program } from 'commander'; import { program } from 'commander';
import qrcode from '@/qrcode/lib/main'; import qrcode from '@/qrcode/lib/main';
import { NapCatOneBot11Adapter } from '@/onebot'; import { NapCatOneBot11Adapter } from '@/onebot';
import { InitWebUi } from '@/webui';
import { WebUiDataRuntime } from '@/webui/src/helper/Data';
import { napCatVersion } from '@/common/version'; import { napCatVersion } from '@/common/version';
import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener'; import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener';
import { sleep } from '@/common/helper'; import { sleep } from '@/common/helper';
@@ -139,11 +137,9 @@ async function handleLogin(
loginListener.onLoginConnected = () => { loginListener.onLoginConnected = () => {
waitForNetworkConnection(loginService, logger).then(() => { waitForNetworkConnection(loginService, logger).then(() => {
handleLoginInner(context, logger, loginService, quickLoginUin, historyLoginList).then().catch(e => logger.logError(e)); handleLoginInner(context, logger, loginService, quickLoginUin, historyLoginList).then().catch(e => logger.logError(e));
loginListener.onLoginConnected = () => { };
}); });
} }
loginListener.onQRCodeGetPicture = ({ pngBase64QrcodeData, qrcodeUrl }) => { loginListener.onQRCodeGetPicture = ({ pngBase64QrcodeData, qrcodeUrl }) => {
WebUiDataRuntime.setQQLoginQrcodeURL(qrcodeUrl);
const realBase64 = pngBase64QrcodeData.replace(/^data:image\/\w+;base64,/, ''); const realBase64 = pngBase64QrcodeData.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(realBase64, 'base64'); const buffer = Buffer.from(realBase64, 'base64');
@@ -181,24 +177,6 @@ async function handleLogin(
return await selfInfo; return await selfInfo;
} }
async function handleLoginInner(context: { isLogined: boolean }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) { async function handleLoginInner(context: { isLogined: boolean }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
WebUiDataRuntime.setQuickLoginCall(async (uin: string) => {
return await new Promise((resolve) => {
if (uin) {
logger.log('正在快速登录 ', uin);
loginService.quickLoginWithUin(uin).then(res => {
if (res.loginErrorInfo.errMsg) {
resolve({ result: false, message: res.loginErrorInfo.errMsg });
}
resolve({ result: true, message: '' });
}).catch((e) => {
logger.logError(e);
resolve({ result: false, message: '快速登录发生错误' });
});
} else {
resolve({ result: false, message: '快速登录失败' });
}
});
});
if (quickLoginUin) { if (quickLoginUin) {
if (historyLoginList.some(u => u.uin === quickLoginUin)) { if (historyLoginList.some(u => u.uin === quickLoginUin)) {
logger.log('正在快速登录 ', quickLoginUin); logger.log('正在快速登录 ', quickLoginUin);
@@ -223,19 +201,8 @@ async function handleLoginInner(context: { isLogined: boolean }, logger: LogWrap
}`); }`);
} }
loginService.getQRCodePicture(); loginService.getQRCodePicture();
try {
await WebUiDataRuntime.runWebUiConfigQuickFunction();
} catch (error) {
logger.logError('WebUi 快速登录失败 执行失败', error);
}
} }
loginService.getLoginList().then((res) => {
// 遍历 res.LocalLoginInfoList[x].isQuickLogin是否可以 res.LocalLoginInfoList[x].uin 转为string 加入string[] 最后遍历完成调用WebUiDataRuntime.setQQQuickLoginList
const list = res.LocalLoginInfoList.filter((item) => item.isQuickLogin);
WebUiDataRuntime.setQQQuickLoginList(list.map((item) => item.uin.toString()));
WebUiDataRuntime.setQQNewLoginList(list);
});
} }
async function initializeSession( async function initializeSession(
@@ -318,8 +285,6 @@ export async function NCoreInitShell() {
o3Service.addO3MiscListener(new NodeIO3MiscListener()); o3Service.addO3MiscListener(new NodeIO3MiscListener());
logger.log('[NapCat] [Core] NapCat.Core Version: ' + napCatVersion); logger.log('[NapCat] [Core] NapCat.Core Version: ' + napCatVersion);
InitWebUi(logger, pathWrapper).then().catch(e => logger.logError(e));
const engine = wrapper.NodeIQQNTWrapperEngine.get(); const engine = wrapper.NodeIQQNTWrapperEngine.get();
const loginService = wrapper.NodeIKernelLoginService.get(); const loginService = wrapper.NodeIKernelLoginService.get();
const session = wrapper.NodeIQQNTWrapperSession.create(); const session = wrapper.NodeIQQNTWrapperSession.create();

View File

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

View File

@@ -1,3 +0,0 @@
# The Path of NapCatQQ
Tiny WebUi Backend for NapCatQQ

View File

@@ -1,211 +0,0 @@
/**
* @file WebUI服务入口文件
*/
import express from 'express';
import { createServer } from 'http';
import { LogWrapper } from '@/common/log';
import { NapCatPathWrapper } from '@/common/path';
import { WebUiConfigWrapper } from '@webapi/helper/config';
import { ALLRouter } from '@webapi/router';
import { cors } from '@webapi/middleware/cors';
import { createUrl } 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用于错误捕获
// 实例化Express
const app = express();
const server = createServer(app);
/**
* 初始化并启动WebUI服务。
* 该函数配置了Express服务器以支持JSON解析和静态文件服务并监听6099端口。
* 无需参数。
* @returns {Promise<void>} 无返回值。
*/
export let WebUiConfig: WebUiConfigWrapper;
export let webUiPathWrapper: NapCatPathWrapper;
const MAX_PORT_TRY = 100;
import * as net from 'node:net';
import { WebUiDataRuntime } from './src/helper/Data';
export let webUiRuntimePort = 6099;
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
try {
await tryUseHost(parsedConfig.host);
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
return [parsedConfig.host, port, parsedConfig.token];
} catch (error) {
console.log('host或port不可用', error);
return ['', 0, ''];
}
}
export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
webUiPathWrapper = pathWrapper;
WebUiConfig = new WebUiConfigWrapper();
const [host, port, token] = await InitPort(await WebUiConfig.GetWebUIConfig());
webUiRuntimePort = port;
if (port == 0) {
logger.log('[NapCat] [WebUi] Current WebUi is not run.');
return;
}
WebUiDataRuntime.setWebUiConfigQuickFunction(
async () => {
let autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount();
if (autoLoginAccount) {
try {
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
if (!result) {
throw new Error(message);
}
console.log(`[NapCat] [WebUi] Auto login account: ${autoLoginAccount}`);
} catch (error) {
console.log(`[NapCat] [WebUi] Auto login account failed.` + error);
}
}
});
// ------------注册中间件------------
// 使用express的json中间件
app.use(express.json());
// CORS中间件
// TODO:
app.use(cors);
// 如果是webui字体文件挂载字体文件
app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
const isFontExist = await WebUiConfig.CheckWebUIFontExist();
if (isFontExist) {
res.sendFile(WebUiConfig.GetWebUIFontPath());
} else {
next();
}
});
// 如果是自定义色彩构建一个css文件
app.use('/files/theme.css', async (_req, res) => {
const colors = await WebUiConfig.GetTheme();
let css = ':root, .light, [data-theme="light"] {';
for (const key in colors.light) {
css += `${key}: ${colors.light[key]};`;
}
css += '}';
css += '.dark, [data-theme="dark"] {';
for (const key in colors.dark) {
css += `${key}: ${colors.dark[key]};`;
}
css += '}';
res.send(css);
});
// ------------中间件结束------------
// ------------挂载路由------------
// 挂载静态路由(前端),路径为 /webui
app.use('/webui', express.static(pathWrapper.staticPath));
// 初始化WebSocket服务器
server.on('upgrade', (request, socket, head) => {
terminalManager.initialize(request, socket, head, logger);
});
// 挂载API接口
app.use('/api', ALLRouter);
// 所有剩下的请求都转到静态页面
const indexFile = join(pathWrapper.staticPath, 'index.html');
app.all(/\/webui\/(.*)/, (_req, res) => {
res.sendFile(indexFile);
});
// 初始服务(先放个首页)
app.all('/', (_req, res) => {
res.status(301).header('Location', '/webui').send();
});
// 错误处理中间件捕获multer的错误
app.use((err: Error, _: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof multer.MulterError) {
return sendError(res, err.message, true);
}
next(err);
});
// 全局错误处理中间件非multer错误
app.use((_: Error, __: express.Request, res: express.Response, ___: express.NextFunction) => {
sendError(res, 'An unknown error occurred.', true);
});
// ------------启动服务------------
server.listen(port, host, async () => {
// 启动后打印出相关地址
let searchParams = { token: token };
if (host !== '' && host !== '0.0.0.0') {
logger.log(
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(host, port.toString(), '/webui', searchParams)}`
);
}
logger.log(
`[NapCat] [WebUi] WebUi Local Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
);
});
// ------------Over------------
}
async function tryUseHost(host: string): Promise<string> {
return new Promise((resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(host);
});
server.on('error', (err: any) => {
if (err.code === 'EADDRNOTAVAIL') {
reject(new Error('主机地址验证失败,可能为非本机地址'));
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
// 尝试监听 让系统随机分配一个端口
server.listen(0, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}
async function tryUsePort(port: number, host: string, tryCount: number = 0): Promise<number> {
return new Promise((resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(port);
});
server.on('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
if (tryCount < MAX_PORT_TRY) {
// 使用循环代替递归
resolve(tryUsePort(port + 1, host, tryCount + 1));
} else {
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
}
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
// 尝试监听端口
server.listen(port, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}

View File

@@ -1,115 +0,0 @@
import { RequestHandler } from 'express';
import { WebUiConfig } from '@/webui';
import { AuthHelper } from '@webapi/helper/SignToken';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendSuccess, sendError } from '@webapi/utils/response';
import { isEmpty } from '@webapi/utils/check';
// 检查是否使用默认Token
export const CheckDefaultTokenHandler: RequestHandler = async (_, res) => {
const webuiToken = await WebUiConfig.GetWebUIConfig();
if (webuiToken.token === 'napcat') {
return sendSuccess(res, true);
}
return sendSuccess(res, false);
};
// 登录
export const LoginHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
// 获取请求体中的token
const { token } = req.body;
// 获取客户端IP
const clientIP = req.ip || req.socket.remoteAddress || '';
// 如果token为空返回错误信息
if (isEmpty(token)) {
return sendError(res, 'token is empty');
}
// 检查登录频率
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
return sendError(res, 'login rate limit');
}
//验证config.token是否等于token
if (WebUiConfigData.token !== token) {
return sendError(res, 'token is invalid');
}
// 签发凭证
const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(WebUiConfigData.token))).toString(
'base64'
);
// 返回成功信息
return sendSuccess(res, {
Credential: signCredential,
});
};
// 退出登录
export const LogoutHandler: RequestHandler = async (req, res) => {
const authorization = req.headers.authorization;
try {
const CredentialBase64: string = authorization?.split(' ')[1] as string;
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
AuthHelper.revokeCredential(Credential);
return sendSuccess(res, 'Logged out successfully');
} catch (e) {
return sendError(res, 'Logout failed');
}
};
// 检查登录状态
export const checkHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
// 获取请求头中的Authorization
const authorization = req.headers.authorization;
// 检查凭证
try {
// 从Authorization中获取凭证
const CredentialBase64: string = authorization?.split(' ')[1] as string;
// 解析凭证
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
// 检查凭证是否已被注销
if (AuthHelper.isCredentialRevoked(Credential)) {
return sendError(res, 'Token has been revoked');
}
// 验证凭证是否在一小时内有效
const valid = AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential);
// 返回成功信息
if (valid) return sendSuccess(res, null);
// 返回错误信息
return sendError(res, 'Authorization Failed');
} catch (e) {
// 返回错误信息
return sendError(res, 'Authorization Failed');
}
};
// 修改密码token
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
const { oldToken, newToken } = req.body;
const authorization = req.headers.authorization;
if (isEmpty(oldToken) || isEmpty(newToken)) {
return sendError(res, 'oldToken or newToken is empty');
}
try {
// 注销当前的Token
if (authorization) {
const CredentialBase64: string = authorization.split(' ')[1] as string;
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
AuthHelper.revokeCredential(Credential);
}
await WebUiConfig.UpdateToken(oldToken, newToken);
return sendSuccess(res, 'Token updated successfully');
} catch (e: any) {
return sendError(res, `Failed to update token: ${e.message}`);
}
};

View File

@@ -1,26 +0,0 @@
import { RequestHandler } from 'express';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendSuccess } from '@webapi/utils/response';
import { WebUiConfig } from '@/webui';
export const PackageInfoHandler: RequestHandler = (_, res) => {
const data = WebUiDataRuntime.getPackageJson();
sendSuccess(res, data);
};
export const QQVersionHandler: RequestHandler = (_, res) => {
const data = WebUiDataRuntime.getQQVersion();
sendSuccess(res, data);
};
export const GetThemeConfigHandler: RequestHandler = async (_, res) => {
const data = await WebUiConfig.GetTheme();
sendSuccess(res, data);
};
export const SetThemeConfigHandler: RequestHandler = async (req, res) => {
const { theme } = req.body;
await WebUiConfig.UpdateTheme(theme);
sendSuccess(res, { message: '更新成功' });
};

View File

@@ -1,399 +0,0 @@
import type { RequestHandler } from 'express';
import { sendError, sendSuccess } from '../utils/response';
import fsProm from 'fs/promises';
import fs from 'fs';
import path from 'path';
import os from 'os';
import compressing from 'compressing';
import { PassThrough } from 'stream';
import multer from 'multer';
import webUIFontUploader from '../uploader/webui_font';
import diskUploader from '../uploader/disk';
import { WebUiConfig } from '@/webui';
const isWindows = os.platform() === 'win32';
// 获取系统根目录列表Windows返回盘符列表其他系统返回['/']
const getRootDirs = async (): Promise<string[]> => {
if (!isWindows) return ['/'];
// Windows 驱动器字母 (A-Z)
const drives: string[] = [];
for (let i = 65; i <= 90; i++) {
const driveLetter = String.fromCharCode(i);
try {
await fsProm.access(`${driveLetter}:\\`);
drives.push(`${driveLetter}:`);
} catch {
// 如果驱动器不存在或无法访问,跳过
continue;
}
}
return drives.length > 0 ? drives : ['C:'];
};
// 规范化路径
const normalizePath = (inputPath: string): string => {
if (!inputPath) return isWindows ? 'C:\\' : '/';
// 如果是Windows且输入为纯盘符可能带或不带斜杠统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) {
return inputPath.slice(0, 2) + '\\';
}
return path.normalize(inputPath);
};
interface FileInfo {
name: string;
isDirectory: boolean;
size: number;
mtime: Date;
}
// 添加系统文件黑名单
const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']);
// 检查同类型的文件或目录是否存在
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
try {
const stat = await fsProm.stat(pathToCheck);
// 只有当类型相同时才认为是冲突
return stat.isDirectory() === isDirectory;
} catch {
return false;
}
};
// 获取目录内容
export const ListFilesHandler: RequestHandler = async (req, res) => {
try {
const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath);
const onlyDirectory = req.query['onlyDirectory'] === 'true';
// 如果是根路径且在Windows系统上返回盘符列表
if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) {
const drives = await getRootDirs();
const driveInfos: FileInfo[] = await Promise.all(
drives.map(async (drive) => {
try {
const stat = await fsProm.stat(`${drive}\\`);
return {
name: drive,
isDirectory: true,
size: 0,
mtime: stat.mtime,
};
} catch {
return {
name: drive,
isDirectory: true,
size: 0,
mtime: new Date(),
};
}
})
);
return sendSuccess(res, driveInfos);
}
const files = await fsProm.readdir(normalizedPath);
let fileInfos: FileInfo[] = [];
for (const file of files) {
// 跳过系统文件
if (SYSTEM_FILES.has(file)) continue;
try {
const fullPath = path.join(normalizedPath, file);
const stat = await fsProm.stat(fullPath);
fileInfos.push({
name: file,
isDirectory: stat.isDirectory(),
size: stat.size,
mtime: stat.mtime,
});
} catch (error) {
// 忽略无法访问的文件
// console.warn(`无法访问文件 ${file}:`, error);
continue;
}
}
// 如果请求参数 onlyDirectory 为 true则只返回目录信息
if (onlyDirectory) {
fileInfos = fileInfos.filter((info) => info.isDirectory);
}
return sendSuccess(res, fileInfos);
} catch (error) {
console.error('读取目录失败:', error);
return sendError(res, '读取目录失败');
}
};
// 创建目录
export const CreateDirHandler: RequestHandler = async (req, res) => {
try {
const { path: dirPath } = req.body;
const normalizedPath = normalizePath(dirPath);
// 检查是否已存在同类型(目录)
if (await checkSameTypeExists(normalizedPath, true)) {
return sendError(res, '同名目录已存在');
}
await fsProm.mkdir(normalizedPath, { recursive: true });
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建目录失败');
}
};
// 删除文件/目录
export const DeleteHandler: RequestHandler = async (req, res) => {
try {
const { path: targetPath } = req.body;
const normalizedPath = normalizePath(targetPath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fsProm.rm(normalizedPath, { recursive: true });
} else {
await fsProm.unlink(normalizedPath);
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '删除失败');
}
};
// 批量删除文件/目录
export const BatchDeleteHandler: RequestHandler = async (req, res) => {
try {
const { paths } = req.body;
for (const targetPath of paths) {
const normalizedPath = normalizePath(targetPath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fsProm.rm(normalizedPath, { recursive: true });
} else {
await fsProm.unlink(normalizedPath);
}
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '批量删除失败');
}
};
// 读取文件内容
export const ReadFileHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
const content = await fsProm.readFile(filePath, 'utf-8');
return sendSuccess(res, content);
} catch (error) {
return sendError(res, '读取文件失败');
}
};
// 写入文件内容
export const WriteFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath, content } = req.body;
const normalizedPath = normalizePath(filePath);
await fsProm.writeFile(normalizedPath, content, 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '写入文件失败');
}
};
// 创建新文件
export const CreateFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath } = req.body;
const normalizedPath = normalizePath(filePath);
// 检查是否已存在同类型(文件)
if (await checkSameTypeExists(normalizedPath, false)) {
return sendError(res, '同名文件已存在');
}
await fsProm.writeFile(normalizedPath, '', 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建文件失败');
}
};
// 重命名文件/目录
export const RenameHandler: RequestHandler = async (req, res) => {
try {
const { oldPath, newPath } = req.body;
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
await fsProm.rename(normalizedOldPath, normalizedNewPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '重命名失败');
}
};
// 移动文件/目录
export const MoveHandler: RequestHandler = async (req, res) => {
try {
const { sourcePath, targetPath } = req.body;
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '移动失败');
}
};
// 批量移动
export const BatchMoveHandler: RequestHandler = async (req, res) => {
try {
const { items } = req.body;
for (const { sourcePath, targetPath } of items) {
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '批量移动失败');
}
};
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
export const DownloadHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
if (!filePath) {
return sendError(res, '参数错误');
}
const stat = await fsProm.stat(filePath);
res.setHeader('Content-Type', 'application/octet-stream');
let filename = path.basename(filePath);
if (stat.isDirectory()) {
filename = path.basename(filePath) + '.zip';
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
const zipStream = new PassThrough();
compressing.zip.compressDir(filePath, zipStream as unknown as fs.WriteStream).catch((err) => {
console.error('压缩目录失败:', err);
res.end();
});
zipStream.pipe(res);
return;
}
res.setHeader('Content-Length', stat.size);
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
const stream = fs.createReadStream(filePath);
stream.pipe(res);
} catch (error) {
return sendError(res, '下载失败');
}
};
// 批量下载:将多个文件/目录打包为 zip 文件下载
export const BatchDownloadHandler: RequestHandler = async (req, res) => {
try {
const { paths } = req.body as { paths: string[] };
if (!paths || !Array.isArray(paths) || paths.length === 0) {
return sendError(res, '参数错误');
}
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename=files.zip');
const zipStream = new compressing.zip.Stream();
// 修改:根据文件类型设置 relativePath
for (const filePath of paths) {
const normalizedPath = normalizePath(filePath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
zipStream.addEntry(normalizedPath, { relativePath: '' });
} else {
zipStream.addEntry(normalizedPath, { relativePath: path.basename(normalizedPath) });
}
}
zipStream.pipe(res);
res.on('finish', () => {
zipStream.destroy();
});
} catch (error) {
return sendError(res, '下载失败');
}
};
// 修改上传处理方法
export const UploadHandler: RequestHandler = async (req, res) => {
try {
await diskUploader(req, res);
return sendSuccess(res, true, '文件上传成功', true);
} catch (error) {
let errorMessage = '文件上传失败';
if (error instanceof multer.MulterError) {
switch (error.code) {
case 'LIMIT_FILE_SIZE':
errorMessage = '文件大小超过限制40MB';
break;
case 'LIMIT_UNEXPECTED_FILE':
errorMessage = '无效的文件上传字段';
break;
default:
errorMessage = `上传错误: ${error.message}`;
}
} else if (error instanceof Error) {
errorMessage = error.message;
}
return sendError(res, errorMessage, true);
}
};
// 上传WebUI字体文件处理方法
export const UploadWebUIFontHandler: RequestHandler = async (req, res) => {
try {
await webUIFontUploader(req, res);
return sendSuccess(res, true, '字体文件上传成功', true);
} catch (error) {
let errorMessage = '字体文件上传失败';
if (error instanceof multer.MulterError) {
switch (error.code) {
case 'LIMIT_FILE_SIZE':
errorMessage = '字体文件大小超过限制40MB';
break;
case 'LIMIT_UNEXPECTED_FILE':
errorMessage = '无效的文件上传字段';
break;
default:
errorMessage = `上传错误: ${error.message}`;
}
} else if (error instanceof Error) {
errorMessage = error.message;
}
return sendError(res, errorMessage, true);
}
};
// 删除WebUI字体文件处理方法
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
try {
const fontPath = WebUiConfig.GetWebUIFontPath();
const exists = await WebUiConfig.CheckWebUIFontExist();
if (!exists) {
return sendSuccess(res, true);
}
await fsProm.unlink(fontPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '删除字体文件失败');
}
};

View File

@@ -1,72 +0,0 @@
import type { RequestHandler } from 'express';
import { sendError, sendSuccess } from '../utils/response';
import { logSubscription } from '@/common/log';
import { terminalManager } from '../terminal/terminal_manager';
import { WebUiConfig } from '@/webui';
// 判断是否是 macos
const isMacOS = process.platform === 'darwin';
// 日志记录
export const LogHandler: RequestHandler = async (req, res) => {
const filename = req.query['id'];
if (!filename || typeof filename !== 'string') {
return sendError(res, 'ID不能为空');
}
if (filename.includes('..')) {
return sendError(res, 'ID不合法');
}
const logContent = await WebUiConfig.GetLogContent(filename);
return sendSuccess(res, logContent);
};
// 日志列表
export const LogListHandler: RequestHandler = async (_, res) => {
const logList = await WebUiConfig.GetLogsList();
return sendSuccess(res, logList);
};
// 实时日志SSE
export const LogRealTimeHandler: RequestHandler = async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Connection', 'keep-alive');
const listener = (log: string) => {
try {
res.write(`data: ${log}\n\n`);
} catch (error) {
console.error('向客户端写入日志数据时出错:', error);
}
};
logSubscription.subscribe(listener);
req.on('close', () => {
logSubscription.unsubscribe(listener);
});
};
// 终端相关处理器
export const CreateTerminalHandler: RequestHandler = async (req, res) => {
if (isMacOS) {
return sendError(res, 'MacOS不支持终端');
}
try {
const { cols, rows } = req.body;
const { id } = terminalManager.createTerminal(cols, rows);
return sendSuccess(res, { id });
} catch (error) {
console.error('Failed to create terminal:', error);
return sendError(res, '创建终端失败');
}
};
export const GetTerminalListHandler: RequestHandler = (_, res) => {
const list = terminalManager.getTerminalList();
return sendSuccess(res, list);
};
export const CloseTerminalHandler: RequestHandler = (req, res) => {
const id = req.params['id'];
if (!id) {
return sendError(res, 'ID不能为空');
}
terminalManager.closeTerminal(id);
return sendSuccess(res, {});
};

View File

@@ -1,60 +0,0 @@
import { RequestHandler } from 'express';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { loadConfig, OneBotConfig } from '@/onebot/config/config';
import { webUiPathWrapper } from '@/webui';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendError, sendSuccess } from '@webapi/utils/response';
import { isEmpty } from '@webapi/utils/check';
import json5 from 'json5';
// 获取OneBot11配置
export const OB11GetConfigHandler: RequestHandler = (_, res) => {
// 获取QQ登录状态
const isLogin = WebUiDataRuntime.getQQLoginStatus();
// 如果未登录,返回错误
if (!isLogin) {
return sendError(res, 'Not Login');
}
// 获取登录的QQ号
const uin = WebUiDataRuntime.getQQLoginUin();
// 读取配置文件路径
const configFilePath = resolve(webUiPathWrapper.configPath, `./onebot11_${uin}.json`);
// 尝试解析配置文件
try {
// 读取配置文件内容
const configFileContent = existsSync(configFilePath)
? readFileSync(configFilePath).toString()
: readFileSync(resolve(webUiPathWrapper.configPath, './onebot11.json')).toString();
// 解析配置文件并加载配置
const data = loadConfig(json5.parse(configFileContent)) as OneBotConfig;
// 返回配置文件
return sendSuccess(res, data);
} catch (e) {
return sendError(res, 'Config Get Error');
}
};
// 写入OneBot11配置
export const OB11SetConfigHandler: RequestHandler = async (req, res) => {
// 获取QQ登录状态
const isLogin = WebUiDataRuntime.getQQLoginStatus();
// 如果未登录,返回错误
if (!isLogin) {
return sendError(res, 'Not Login');
}
// 如果配置为空,返回错误
if (isEmpty(req.body.config)) {
return sendError(res, 'config is empty');
}
// 写入配置
try {
// 解析并加载配置
const config = loadConfig(json5.parse(req.body.config)) as OneBotConfig;
// 写入配置
await WebUiDataRuntime.setOB11Config(config);
return sendSuccess(res, null);
} catch (e) {
return sendError(res, 'Error: ' + e);
}
};

View File

@@ -1,14 +0,0 @@
import { RequestHandler } from 'express';
import { RequestUtil } from '@/common/request';
import { sendError, sendSuccess } from '../utils/response';
export const GetProxyHandler: RequestHandler = async (req, res) => {
let { url } = req.query;
if (url && typeof url === 'string') {
url = decodeURIComponent(url);
const responseText = await RequestUtil.HttpGetText(url);
return sendSuccess(res, responseText);
} else {
return sendError(res, 'url参数不合法');
}
};

View File

@@ -1,90 +0,0 @@
import { RequestHandler } from 'express';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { isEmpty } from '@webapi/utils/check';
import { sendError, sendSuccess } from '@webapi/utils/response';
import { WebUiConfig } from '@/webui';
// 获取QQ登录二维码
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
// 判断是否已经登录
if (WebUiDataRuntime.getQQLoginStatus()) {
// 已经登录
return sendError(res, 'QQ Is Logined');
}
// 获取二维码
const qrcodeUrl = WebUiDataRuntime.getQQLoginQrcodeURL();
// 判断二维码是否为空
if (isEmpty(qrcodeUrl)) {
return sendError(res, 'QRCode Get Error');
}
// 返回二维码URL
const data = {
qrcode: qrcodeUrl,
};
return sendSuccess(res, data);
};
// 获取QQ登录状态
export const QQCheckLoginStatusHandler: RequestHandler = async (_, res) => {
const data = {
isLogin: WebUiDataRuntime.getQQLoginStatus(),
qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(),
};
return sendSuccess(res, data);
};
// 快速登录
export const QQSetQuickLoginHandler: RequestHandler = async (req, res) => {
// 获取QQ号
const { uin } = req.body;
// 判断是否已经登录
const isLogin = WebUiDataRuntime.getQQLoginStatus();
if (isLogin) {
return sendError(res, 'QQ Is Logined');
}
// 判断QQ号是否为空
if (isEmpty(uin)) {
return sendError(res, 'uin is empty');
}
// 获取快速登录状态
const { result, message } = await WebUiDataRuntime.requestQuickLogin(uin);
if (!result) {
return sendError(res, message);
}
//本来应该验证 但是http不宜这么搞 建议前端验证
//isLogin = WebUiDataRuntime.getQQLoginStatus();
return sendSuccess(res, null);
};
// 获取快速登录列表
export const QQGetQuickLoginListHandler: RequestHandler = async (_, res) => {
const quickLoginList = WebUiDataRuntime.getQQQuickLoginList();
return sendSuccess(res, quickLoginList);
};
// 获取快速登录列表(新)
export const QQGetLoginListNewHandler: RequestHandler = async (_, res) => {
const newLoginList = WebUiDataRuntime.getQQNewLoginList();
return sendSuccess(res, newLoginList);
};
// 获取登录的QQ的信息
export const getQQLoginInfoHandler: RequestHandler = async (_, res) => {
const data = WebUiDataRuntime.getQQLoginInfo();
return sendSuccess(res, data);
};
// 获取自动登录QQ账号
export const getAutoLoginAccountHandler: RequestHandler = async (_, res) => {
const data = WebUiConfig.getAutoLoginAccount();
return sendSuccess(res, data);
};
// 设置自动登录QQ账号
export const setAutoLoginAccountHandler: RequestHandler = async (req, res) => {
const { uin } = req.body;
await WebUiConfig.UpdateAutoLoginAccount(uin);
return sendSuccess(res, null);
};

View File

@@ -1,19 +0,0 @@
import { RequestHandler } from 'express';
import { SystemStatus, statusHelperSubscription } from '@/core/helper/status';
export const StatusRealTimeHandler: RequestHandler = async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Connection', 'keep-alive');
const sendStatus = (status: SystemStatus) => {
try{
res.write(`data: ${JSON.stringify(status)}\n\n`);
} catch (e) {
console.error(`An error occurred when writing sendStatus data to client: ${e}`);
}
};
statusHelperSubscription.on('statusUpdate', sendStatus);
req.on('close', () => {
statusHelperSubscription.off('statusUpdate', sendStatus);
res.end();
});
};

View File

@@ -1,13 +0,0 @@
export enum HttpStatusCode {
OK = 200,
BadRequest = 400,
Unauthorized = 401,
Forbidden = 403,
NotFound = 404,
InternalServerError = 500,
}
export enum ResponseCode {
Success = 0,
Error = -1,
}

View File

@@ -1,131 +0,0 @@
import type { LoginRuntimeType } from '../types/data';
import packageJson from '../../../../package.json';
import store from '@/common/store';
const LoginRuntime: LoginRuntimeType = {
LoginCurrentTime: Date.now(),
LoginCurrentRate: 0,
QQLoginStatus: false, //已实现 但太傻了 得去那边注册个回调刷新
QQQRCodeURL: '',
QQLoginUin: '',
QQLoginInfo: {
uid: '',
uin: '',
nick: '',
},
QQVersion: 'unknown',
NapCatHelper: {
onOB11ConfigChanged: async () => {
return;
},
onQuickLoginRequested: async () => {
return { result: false, message: '' };
},
QQLoginList: [],
NewQQLoginList: [],
},
packageJson: packageJson,
WebUiConfigQuickFunction: async () => {
return;
}
};
export const WebUiDataRuntime = {
checkLoginRate(ip: string, RateLimit: number): boolean {
const key = `login_rate:${ip}`;
const count = store.get<number>(key) || 0;
if (count === 0) {
// 第一次访问设置计数器为1并设置60秒过期
store.set(key, 1, 60);
return true;
}
if (count >= RateLimit) {
return false;
}
store.incr(key);
return true;
},
getQQLoginStatus(): LoginRuntimeType['QQLoginStatus'] {
return LoginRuntime.QQLoginStatus;
},
setQQLoginStatus(status: LoginRuntimeType['QQLoginStatus']): void {
LoginRuntime.QQLoginStatus = status;
},
setQQLoginQrcodeURL(url: LoginRuntimeType['QQQRCodeURL']): void {
LoginRuntime.QQQRCodeURL = url;
},
getQQLoginQrcodeURL(): LoginRuntimeType['QQQRCodeURL'] {
return LoginRuntime.QQQRCodeURL;
},
setQQLoginInfo(info: LoginRuntimeType['QQLoginInfo']): void {
LoginRuntime.QQLoginInfo = info;
LoginRuntime.QQLoginUin = info.uin.toString();
},
getQQLoginInfo(): LoginRuntimeType['QQLoginInfo'] {
return LoginRuntime.QQLoginInfo;
},
getQQLoginUin(): LoginRuntimeType['QQLoginUin'] {
return LoginRuntime.QQLoginUin;
},
getQQQuickLoginList(): LoginRuntimeType['NapCatHelper']['QQLoginList'] {
return LoginRuntime.NapCatHelper.QQLoginList;
},
setQQQuickLoginList(list: LoginRuntimeType['NapCatHelper']['QQLoginList']): void {
LoginRuntime.NapCatHelper.QQLoginList = list;
},
getQQNewLoginList(): LoginRuntimeType['NapCatHelper']['NewQQLoginList'] {
return LoginRuntime.NapCatHelper.NewQQLoginList;
},
setQQNewLoginList(list: LoginRuntimeType['NapCatHelper']['NewQQLoginList']): void {
LoginRuntime.NapCatHelper.NewQQLoginList = list;
},
setQuickLoginCall(func: LoginRuntimeType['NapCatHelper']['onQuickLoginRequested']): void {
LoginRuntime.NapCatHelper.onQuickLoginRequested = func;
},
requestQuickLogin: function (uin) {
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
setOnOB11ConfigChanged(func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
},
setOB11Config: function (ob11) {
return LoginRuntime.NapCatHelper.onOB11ConfigChanged(ob11);
} as LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged'],
getPackageJson() {
return LoginRuntime.packageJson;
},
setQQVersion(version: string) {
LoginRuntime.QQVersion = version;
},
getQQVersion() {
return LoginRuntime.QQVersion;
},
setWebUiConfigQuickFunction(func: LoginRuntimeType['WebUiConfigQuickFunction']): void {
LoginRuntime.WebUiConfigQuickFunction = func;
},
runWebUiConfigQuickFunction: async function () {
await LoginRuntime.WebUiConfigQuickFunction();
}
};

View File

@@ -1,88 +0,0 @@
import crypto from 'crypto';
import store from '@/common/store';
export class AuthHelper {
private static readonly secretKey = Math.random().toString(36).slice(2);
/**
* 签名凭证方法。
* @param token 待签名的凭证字符串。
* @returns 签名后的凭证对象。
*/
public static signCredential(token: string): WebUiCredentialJson {
const innerJson: WebUiCredentialInnerJson = {
CreatedTime: Date.now(),
TokenEncoded: token,
};
const jsonString = JSON.stringify(innerJson);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
return { Data: innerJson, Hmac: hmac };
}
/**
* 检查凭证是否被篡改的方法。
* @param credentialJson 凭证的JSON对象。
* @returns 布尔值,表示凭证是否有效。
*/
public static checkCredential(credentialJson: WebUiCredentialJson): boolean {
try {
const jsonString = JSON.stringify(credentialJson.Data);
const calculatedHmac = crypto
.createHmac('sha256', AuthHelper.secretKey)
.update(jsonString, 'utf8')
.digest('hex');
return calculatedHmac === credentialJson.Hmac;
} catch (error) {
return false;
}
}
/**
* 验证凭证在1小时内有效且token与原始token相同。
* @param token 待验证的原始token。
* @param credentialJson 已签名的凭证JSON对象。
* @returns 布尔值表示凭证是否有效且token匹配。
*/
public static validateCredentialWithinOneHour(token: string, credentialJson: WebUiCredentialJson): boolean {
// 首先检查凭证是否被篡改
const isValid = AuthHelper.checkCredential(credentialJson);
if (!isValid) {
return false;
}
// 检查凭证是否在黑名单中
if (AuthHelper.isCredentialRevoked(credentialJson)) {
return false;
}
const currentTime = Date.now() / 1000;
const createdTime = credentialJson.Data.CreatedTime;
const timeDifference = currentTime - createdTime;
return timeDifference <= 3600 && credentialJson.Data.TokenEncoded === token;
}
/**
* 注销指定的Token凭证
* @param credentialJson 凭证JSON对象
* @returns void
*/
public static revokeCredential(credentialJson: WebUiCredentialJson): void {
const jsonString = JSON.stringify(credentialJson.Data);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
// 将已注销的凭证添加到黑名单中有效期1小时
store.set(`revoked:${hmac}`, true, 3600);
}
/**
* 检查凭证是否已被注销
* @param credentialJson 凭证JSON对象
* @returns 布尔值,表示凭证是否已被注销
*/
public static isCredentialRevoked(credentialJson: WebUiCredentialJson): boolean {
const jsonString = JSON.stringify(credentialJson.Data);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
return store.exists(`revoked:${hmac}`) > 0;
}
}

View File

@@ -1,179 +0,0 @@
import { webUiPathWrapper } from '@/webui';
import { Type, Static } from '@sinclair/typebox';
import Ajv from 'ajv';
import fs, { constants } from 'node:fs/promises';
import { resolve } from 'node:path';
import { deepMerge } from '../utils/object';
import { themeType } from '../types/theme';
// 限制尝试端口的次数,避免死循环
// 定义配置的类型
const WebUiConfigSchema = Type.Object({
host: Type.String({ default: '0.0.0.0' }),
port: Type.Number({ default: 6099 }),
token: Type.String({ default: 'napcat' }),
loginRate: Type.Number({ default: 10 }),
autoLoginAccount: Type.String({ default: '' }),
theme: themeType,
});
export type WebUiConfigType = Static<typeof WebUiConfigSchema>;
// 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件
export class WebUiConfigWrapper {
WebUiConfigData: WebUiConfigType | undefined = undefined;
private validateAndApplyDefaults(config: Partial<WebUiConfigType>): WebUiConfigType {
new Ajv({ coerceTypes: true, useDefaults: true }).compile(WebUiConfigSchema)(config);
return config as WebUiConfigType;
}
private async ensureConfigFileExists(configPath: string): Promise<void> {
const configExists = await fs
.access(configPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (!configExists) {
await fs.writeFile(configPath, JSON.stringify(this.validateAndApplyDefaults({}), null, 4));
}
}
private async readAndValidateConfig(configPath: string): Promise<WebUiConfigType> {
const fileContent = await fs.readFile(configPath, 'utf-8');
return this.validateAndApplyDefaults(JSON.parse(fileContent));
}
private async writeConfig(configPath: string, config: WebUiConfigType): Promise<void> {
const hasWritePermission = await fs
.access(configPath, constants.W_OK)
.then(() => true)
.catch(() => false);
if (hasWritePermission) {
await fs.writeFile(configPath, JSON.stringify(config, null, 4));
} else {
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
}
}
async GetWebUIConfig(): Promise<WebUiConfigType> {
if (this.WebUiConfigData) {
return this.WebUiConfigData;
}
try {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
await this.ensureConfigFileExists(configPath);
const parsedConfig = await this.readAndValidateConfig(configPath);
this.WebUiConfigData = parsedConfig;
return this.WebUiConfigData;
} catch (e) {
console.log('读取配置文件失败', e);
return this.validateAndApplyDefaults({});
}
}
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
const currentConfig = await this.GetWebUIConfig();
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
await this.writeConfig(configPath, updatedConfig);
this.WebUiConfigData = updatedConfig;
}
async UpdateToken(oldToken: string, newToken: string): Promise<void> {
const currentConfig = await this.GetWebUIConfig();
if (currentConfig.token !== oldToken) {
throw new Error('旧 token 不匹配');
}
await this.UpdateWebUIConfig({ token: newToken });
}
// 获取日志文件夹路径
async GetLogsPath(): Promise<string> {
return resolve(webUiPathWrapper.logsPath);
}
// 获取日志列表
async GetLogsList(): Promise<string[]> {
const logsPath = resolve(webUiPathWrapper.logsPath);
const logsExist = await fs
.access(logsPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (logsExist) {
return (await fs.readdir(logsPath))
.filter((file) => file.endsWith('.log'))
.map((file) => file.replace('.log', ''));
}
return [];
}
// 获取指定日志文件内容
async GetLogContent(filename: string): Promise<string> {
const logPath = resolve(webUiPathWrapper.logsPath, `${filename}.log`);
const logExists = await fs
.access(logPath, constants.R_OK)
.then(() => true)
.catch(() => false);
if (logExists) {
return await fs.readFile(logPath, 'utf-8');
}
return '';
}
// 获取字体文件夹内的字体列表
async GetFontList(): Promise<string[]> {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
const fontsExist = await fs
.access(fontsPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (fontsExist) {
return (await fs.readdir(fontsPath)).filter((file) => file.endsWith('.ttf'));
}
return [];
}
// 判断字体是否存在webui.woff
async CheckWebUIFontExist(): Promise<boolean> {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
return await fs
.access(resolve(fontsPath, './webui.woff'), constants.F_OK)
.then(() => true)
.catch(() => false);
}
// 获取webui字体文件路径
GetWebUIFontPath(): string {
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
}
getAutoLoginAccount(): string | undefined {
return this.WebUiConfigData?.autoLoginAccount;
}
// 获取自动登录账号
async GetAutoLoginAccount(): Promise<string> {
return (await this.GetWebUIConfig()).autoLoginAccount;
}
// 更新自动登录账号
async UpdateAutoLoginAccount(uin: string): Promise<void> {
await this.UpdateWebUIConfig({ autoLoginAccount: uin });
}
// 获取主题内容
async GetTheme(): Promise<WebUiConfigType['theme']> {
const config = await this.GetWebUIConfig();
return config.theme;
}
// 更新主题内容
async UpdateTheme(theme: WebUiConfigType['theme']): Promise<void> {
await this.UpdateWebUIConfig({ theme: theme });
}
}

View File

@@ -1,46 +0,0 @@
import { NextFunction, Request, Response } from 'express';
import { WebUiConfig } from '@/webui';
import { AuthHelper } from '@webapi/helper/SignToken';
import { sendError } from '@webapi/utils/response';
// 鉴权中间件
export async function auth(req: Request, res: Response, next: NextFunction) {
// 判断当前url是否为/login 如果是跳过鉴权
if (req.url == '/auth/login') {
return next();
}
// 判断是否有Authorization头
if (req.headers?.authorization) {
// 切割参数以获取token
const authorization = req.headers.authorization.split(' ');
// 当Bearer后面没有参数时
if (authorization.length < 2) {
return sendError(res, 'Unauthorized');
}
// 获取token
const token = authorization[1];
// 解析token
let Credential: WebUiCredentialJson;
try {
Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
} catch (e) {
return sendError(res, 'Unauthorized');
}
// 获取配置
const config = await WebUiConfig.GetWebUIConfig();
// 验证凭证在1小时内有效且token与原始token相同
const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
if (credentialJson) {
// 通过验证
return next();
}
// 验证失败
return sendError(res, 'Unauthorized');
}
// 没有Authorization头
return sendError(res, 'Unauthorized');
}

View File

@@ -1,16 +0,0 @@
import type { RequestHandler } from 'express';
// CORS 中间件,跨域用
export const cors: RequestHandler = (req, res, next) => {
const origin = req.headers.origin || '*';
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
res.header('Access-Control-Allow-Credentials', 'true');
if (req.method === 'OPTIONS') {
res.sendStatus(204);
return;
}
next();
};

View File

@@ -1,15 +0,0 @@
import { Router } from 'express';
import { GetThemeConfigHandler, PackageInfoHandler, QQVersionHandler, SetThemeConfigHandler } from '../api/BaseInfo';
import { StatusRealTimeHandler } from '@webapi/api/Status';
import { GetProxyHandler } from '../api/Proxy';
const router = Router();
// router: 获取nc的package.json信息
router.get('/QQVersion', QQVersionHandler);
router.get('/PackageInfo', PackageInfoHandler);
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
router.get('/proxy', GetProxyHandler);
router.get('/Theme', GetThemeConfigHandler);
router.post('/SetTheme', SetThemeConfigHandler);
export { router as BaseRouter };

View File

@@ -1,49 +0,0 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import {
ListFilesHandler,
CreateDirHandler,
DeleteHandler,
ReadFileHandler,
WriteFileHandler,
CreateFileHandler,
BatchDeleteHandler, // 添加这一行
RenameHandler,
MoveHandler,
BatchMoveHandler,
DownloadHandler,
BatchDownloadHandler, // 新增下载处理方法
UploadHandler,
UploadWebUIFontHandler,
DeleteWebUIFontHandler, // 添加上传处理器
} from '../api/File';
const router = Router();
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟内
max: 60, // 最大60个请求
validate: {
xForwardedForHeader: false,
},
});
router.use(apiLimiter);
router.get('/list', ListFilesHandler);
router.post('/mkdir', CreateDirHandler);
router.post('/delete', DeleteHandler);
router.get('/read', ReadFileHandler);
router.post('/write', WriteFileHandler);
router.post('/create', CreateFileHandler);
router.post('/batchDelete', BatchDeleteHandler);
router.post('/rename', RenameHandler);
router.post('/move', MoveHandler);
router.post('/batchMove', BatchMoveHandler);
router.post('/download', DownloadHandler);
router.post('/batchDownload', BatchDownloadHandler);
router.post('/upload', UploadHandler);
router.post('/font/upload/webui', UploadWebUIFontHandler);
router.post('/font/delete/webui', DeleteWebUIFontHandler);
export { router as FileRouter };

View File

@@ -1,23 +0,0 @@
import { Router } from 'express';
import {
LogHandler,
LogListHandler,
LogRealTimeHandler,
CreateTerminalHandler,
GetTerminalListHandler,
CloseTerminalHandler,
} from '../api/Log';
const router = Router();
// 日志相关路由
router.get('/GetLog', LogHandler);
router.get('/GetLogList', LogListHandler);
router.get('/GetLogRealTime', LogRealTimeHandler);
// 终端相关路由
router.get('/terminal/list', GetTerminalListHandler);
router.post('/terminal/create', CreateTerminalHandler);
router.post('/terminal/:id/close', CloseTerminalHandler);
export { router as LogRouter };

View File

@@ -1,11 +0,0 @@
import { Router } from 'express';
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@webapi/api/OB11Config';
const router = Router();
// router:读取配置
router.post('/GetConfig', OB11GetConfigHandler);
// router:写入配置
router.post('/SetConfig', OB11SetConfigHandler);
export { router as OB11ConfigRouter };

View File

@@ -1,32 +0,0 @@
import { Router } from 'express';
import {
QQCheckLoginStatusHandler,
QQGetQRcodeHandler,
QQGetQuickLoginListHandler,
QQSetQuickLoginHandler,
QQGetLoginListNewHandler,
getQQLoginInfoHandler,
getAutoLoginAccountHandler,
setAutoLoginAccountHandler,
} from '@webapi/api/QQLogin';
const router = Router();
// router:获取快速登录列表
router.all('/GetQuickLoginList', QQGetQuickLoginListHandler);
// router:获取快速登录列表(新)
router.all('/GetQuickLoginListNew', QQGetLoginListNewHandler);
// router:检查QQ登录状态
router.post('/CheckLoginStatus', QQCheckLoginStatusHandler);
// router:获取QQ登录二维码
router.post('/GetQQLoginQrcode', QQGetQRcodeHandler);
// router:设置QQ快速登录
router.post('/SetQuickLogin', QQSetQuickLoginHandler);
// router:获取QQ登录信息
router.post('/GetQQLoginInfo', getQQLoginInfoHandler);
// router:获取快速登录QQ账号
router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
// router:设置自动登录QQ账号
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
export { router as QQLoginRouter };

View File

@@ -1,23 +0,0 @@
import { Router } from 'express';
import {
CheckDefaultTokenHandler,
checkHandler,
LoginHandler,
LogoutHandler,
UpdateTokenHandler,
} from '@webapi/api/Auth';
const router = Router();
// router:登录
router.post('/login', LoginHandler);
// router:检查登录状态
router.post('/check', checkHandler);
// router:注销
router.post('/logout', LogoutHandler);
// router:更新token
router.post('/update_token', UpdateTokenHandler);
// router:检查默认token
router.get('/check_using_default_token', CheckDefaultTokenHandler);
export { router as AuthRouter };

View File

@@ -1,39 +0,0 @@
/**
* @file 所有路由的入口文件
*/
import { Router } from 'express';
import { OB11ConfigRouter } from '@webapi/router/OB11Config';
import { auth } from '@webapi/middleware/auth';
import { sendSuccess } from '@webapi/utils/response';
import { QQLoginRouter } from '@webapi/router/QQLogin';
import { AuthRouter } from '@webapi/router/auth';
import { LogRouter } from '@webapi/router/Log';
import { BaseRouter } from '@webapi/router/Base';
import { FileRouter } from './File';
const router = Router();
// 鉴权中间件
router.use(auth);
// router:测试用
router.all('/test', (_, res) => {
return sendSuccess(res);
});
// router:基础信息相关路由
router.use('/base', BaseRouter);
// router:WebUI登录相关路由
router.use('/auth', AuthRouter);
// router:QQ登录相关路由
router.use('/QQLogin', QQLoginRouter);
// router:OB11配置相关路由
router.use('/OB11Config', OB11ConfigRouter);
// router:日志相关路由
router.use('/Log', LogRouter);
// file:文件相关路由
router.use('/File', FileRouter);
export { router as ALLRouter };

View File

@@ -1,21 +0,0 @@
import path from 'path';
Object.defineProperty(global, '__dirname', {
get() {
const err = new Error();
const stack = err.stack?.split('\n') || [];
let callerFile = '';
// 遍历错误堆栈,跳过当前文件所在行
// 注意:堆栈格式可能不同,请根据实际环境调整索引及正则表达式
for (const line of stack) {
const match = line.match(/\((.*):\d+:\d+\)/);
if (match) {
callerFile = match[1];
if (!callerFile.includes('init-dynamic-dirname.ts')) {
break;
}
}
}
return callerFile ? path.dirname(callerFile) : '';
},
});

View File

@@ -1,183 +0,0 @@
import './init-dynamic-dirname';
import { WebUiConfig } from '@/webui';
import { AuthHelper } from '../helper/SignToken';
import { LogWrapper } from '@/common/log';
import { WebSocket, WebSocketServer } from 'ws';
import os from 'os';
import { IPty, spawn as ptySpawn } from '@/pty';
import { randomUUID } from 'crypto';
interface TerminalInstance {
pty: IPty; // 改用 PTY 实例
lastAccess: number;
sockets: Set<WebSocket>;
// 新增标识,用于防止重复关闭
isClosing: boolean;
// 新增:存储终端历史输出
buffer: string;
}
class TerminalManager {
private terminals: Map<string, TerminalInstance> = new Map();
private wss: WebSocketServer | null = null;
initialize(req: any, socket: any, head: any, logger?: LogWrapper) {
logger?.log('[NapCat] [WebUi] terminal websocket initialized');
this.wss = new WebSocketServer({
noServer: true,
verifyClient: async (info, cb) => {
// 验证 token
const url = new URL(info.req.url || '', 'ws://localhost');
const token = url.searchParams.get('token');
const terminalId = url.searchParams.get('id');
if (!token || !terminalId) {
cb(false, 401, 'Unauthorized');
return;
}
// 解析token
let Credential: WebUiCredentialJson;
try {
Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
} catch (e) {
cb(false, 401, 'Unauthorized');
return;
}
const config = await WebUiConfig.GetWebUIConfig();
const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
if (!validate) {
cb(false, 401, 'Unauthorized');
return;
}
cb(true);
},
});
this.wss.handleUpgrade(req, socket, head, (ws) => {
this.wss?.emit('connection', ws, req);
});
this.wss.on('connection', async (ws, req) => {
logger?.log('建立终端连接');
try {
const url = new URL(req.url || '', 'ws://localhost');
const terminalId = url.searchParams.get('id')!;
const instance = this.terminals.get(terminalId);
if (!instance) {
ws.close();
return;
}
instance.sockets.add(ws);
instance.lastAccess = Date.now();
// 新增:发送当前终端内容给新连接
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data: instance.buffer }));
}
ws.on('message', (data) => {
if (instance) {
const result = JSON.parse(data.toString());
if (result.type === 'input') {
instance.pty.write(result.data);
}
// 新增:处理 resize 消息
if (result.type === 'resize') {
instance.pty.resize(result.cols, result.rows);
}
}
});
ws.on('close', () => {
instance.sockets.delete(ws);
if (instance.sockets.size === 0 && !instance.isClosing) {
instance.isClosing = true;
if (os.platform() === 'win32') {
process.kill(instance.pty.pid);
} else {
instance.pty.kill();
}
}
});
} catch (err) {
console.error('WebSocket authentication failed:', err);
ws.close();
}
});
}
// 修改:新增 cols 和 rows 参数,同步 xterm 尺寸,防止错位
createTerminal(cols: number, rows: number) {
const id = randomUUID();
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const pty = ptySpawn(shell, [], {
name: 'xterm-256color',
cols, // 使用客户端传入的 cols
rows, // 使用客户端传入的 rows
cwd: process.cwd(),
env: {
...process.env,
LANG: os.platform() === 'win32' ? 'chcp 65001' : 'zh_CN.UTF-8',
TERM: 'xterm-256color',
},
});
const instance: TerminalInstance = {
pty,
lastAccess: Date.now(),
sockets: new Set(),
isClosing: false,
buffer: '', // 初始化终端内容缓存
};
pty.onData((data: any) => {
// 追加数据到 buffer
instance.buffer += data;
// 发送数据给已连接的 websocket
instance.sockets.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data }));
}
});
});
pty.onExit(() => {
this.closeTerminal(id);
});
this.terminals.set(id, instance);
// 返回生成的 id 及对应实例,方便后续通知客户端使用该 id
return { id, instance };
}
closeTerminal(id: string) {
const instance = this.terminals.get(id);
if (instance) {
if (!instance.isClosing) {
instance.isClosing = true;
if (os.platform() === 'win32') {
process.kill(instance.pty.pid);
} else {
instance.pty.kill();
}
}
instance.sockets.forEach((ws) => ws.close());
this.terminals.delete(id);
}
}
getTerminal(id: string) {
return this.terminals.get(id);
}
getTerminalList() {
return Array.from(this.terminals.keys()).map((id) => ({
id,
lastAccess: this.terminals.get(id)!.lastAccess,
}));
}
}
export const terminalManager = new TerminalManager();

View File

@@ -1,6 +0,0 @@
interface WebUiConfigType {
host: string;
port: number;
token: string;
loginRate: number;
}

View File

@@ -1,20 +0,0 @@
import type { LoginListItem, SelfInfo } from '@/core';
import type { OneBotConfig } from '@/onebot/config/config';
interface LoginRuntimeType {
LoginCurrentTime: number;
LoginCurrentRate: number;
QQLoginStatus: boolean;
QQQRCodeURL: string;
QQLoginUin: string;
QQLoginInfo: SelfInfo;
QQVersion: string;
WebUiConfigQuickFunction: () => Promise<void>;
NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
QQLoginList: string[];
NewQQLoginList: LoginListItem[];
};
packageJson: object;
}

View File

@@ -1,7 +0,0 @@
interface APIResponse<T> {
code: number;
message: string;
data: T;
}
type Protocol = 'http' | 'https' | 'ws' | 'wss';

View File

@@ -1,9 +0,0 @@
interface WebUiCredentialInnerJson {
CreatedTime: number;
TokenEncoded: string;
}
interface WebUiCredentialJson {
Data: WebUiCredentialInnerJson;
Hmac: string;
}

View File

@@ -1,260 +0,0 @@
import { Type } from '@sinclair/typebox';
export const themeType = Type.Object(
{
dark: Type.Record(Type.String(), Type.String()),
light: Type.Record(Type.String(), Type.String()),
},
{
default: {
dark: {
'--heroui-background': '0 0% 0%',
'--heroui-foreground-50': '240 5.88% 10%',
'--heroui-foreground-100': '240 3.7% 15.88%',
'--heroui-foreground-200': '240 5.26% 26.08%',
'--heroui-foreground-300': '240 5.2% 33.92%',
'--heroui-foreground-400': '240 3.83% 46.08%',
'--heroui-foreground-500': '240 5.03% 64.9%',
'--heroui-foreground-600': '240 4.88% 83.92%',
'--heroui-foreground-700': '240 5.88% 90%',
'--heroui-foreground-800': '240 4.76% 95.88%',
'--heroui-foreground-900': '0 0% 98.04%',
'--heroui-foreground': '210 5.56% 92.94%',
'--heroui-focus': '212.01999999999998 100% 46.67%',
'--heroui-overlay': '0 0% 0%',
'--heroui-divider': '0 0% 100%',
'--heroui-divider-opacity': '0.15',
'--heroui-content1': '240 5.88% 10%',
'--heroui-content1-foreground': '0 0% 98.04%',
'--heroui-content2': '240 3.7% 15.88%',
'--heroui-content2-foreground': '240 4.76% 95.88%',
'--heroui-content3': '240 5.26% 26.08%',
'--heroui-content3-foreground': '240 5.88% 90%',
'--heroui-content4': '240 5.2% 33.92%',
'--heroui-content4-foreground': '240 4.88% 83.92%',
'--heroui-default-50': '240 5.88% 10%',
'--heroui-default-100': '240 3.7% 15.88%',
'--heroui-default-200': '240 5.26% 26.08%',
'--heroui-default-300': '240 5.2% 33.92%',
'--heroui-default-400': '240 3.83% 46.08%',
'--heroui-default-500': '240 5.03% 64.9%',
'--heroui-default-600': '240 4.88% 83.92%',
'--heroui-default-700': '240 5.88% 90%',
'--heroui-default-800': '240 4.76% 95.88%',
'--heroui-default-900': '0 0% 98.04%',
'--heroui-default-foreground': '0 0% 100%',
'--heroui-default': '240 5.26% 26.08%',
'--heroui-danger-50': '301.89 82.61% 22.55%',
'--heroui-danger-100': '308.18 76.39% 28.24%',
'--heroui-danger-200': '313.85 70.65% 36.08%',
'--heroui-danger-300': '319.73 65.64% 44.51%',
'--heroui-danger-400': '325.82 69.62% 53.53%',
'--heroui-danger-500': '331.82 75% 65.49%',
'--heroui-danger-600': '337.84 83.46% 73.92%',
'--heroui-danger-700': '343.42 90.48% 83.53%',
'--heroui-danger-800': '350.53 90.48% 91.76%',
'--heroui-danger-900': '324 90.91% 95.69%',
'--heroui-danger-foreground': '0 0% 100%',
'--heroui-danger': '325.82 69.62% 53.53%',
'--heroui-primary-50': '340 84.91% 10.39%',
'--heroui-primary-100': '339.33 86.54% 20.39%',
'--heroui-primary-200': '339.11 85.99% 30.78%',
'--heroui-primary-300': '339 86.54% 40.78%',
'--heroui-primary-400': '339.2 90.36% 51.18%',
'--heroui-primary-500': '339 90% 60.78%',
'--heroui-primary-600': '339.11 90.6% 70.78%',
'--heroui-primary-700': '339.33 90% 80.39%',
'--heroui-primary-800': '340 91.84% 90.39%',
'--heroui-primary-900': '339.13 92% 95.1%',
'--heroui-primary-foreground': '0 0% 100%',
'--heroui-primary': '339.2 90.36% 51.18%',
'--heroui-secondary-50': '270 66.67% 9.41%',
'--heroui-secondary-100': '270 66.67% 18.82%',
'--heroui-secondary-200': '270 66.67% 28.24%',
'--heroui-secondary-300': '270 66.67% 37.65%',
'--heroui-secondary-400': '270 66.67% 47.06%',
'--heroui-secondary-500': '270 59.26% 57.65%',
'--heroui-secondary-600': '270 59.26% 68.24%',
'--heroui-secondary-700': '270 59.26% 78.82%',
'--heroui-secondary-800': '270 59.26% 89.41%',
'--heroui-secondary-900': '270 61.54% 94.9%',
'--heroui-secondary-foreground': '0 0% 100%',
'--heroui-secondary': '270 59.26% 57.65%',
'--heroui-success-50': '145.71 77.78% 8.82%',
'--heroui-success-100': '146.2 79.78% 17.45%',
'--heroui-success-200': '145.79 79.26% 26.47%',
'--heroui-success-300': '146.01 79.89% 35.1%',
'--heroui-success-400': '145.96 79.46% 43.92%',
'--heroui-success-500': '146.01 62.45% 55.1%',
'--heroui-success-600': '145.79 62.57% 66.47%',
'--heroui-success-700': '146.2 61.74% 77.45%',
'--heroui-success-800': '145.71 61.4% 88.82%',
'--heroui-success-900': '146.67 64.29% 94.51%',
'--heroui-success-foreground': '0 0% 0%',
'--heroui-success': '145.96 79.46% 43.92%',
'--heroui-warning-50': '37.14 75% 10.98%',
'--heroui-warning-100': '37.14 75% 21.96%',
'--heroui-warning-200': '36.96 73.96% 33.14%',
'--heroui-warning-300': '37.01 74.22% 44.12%',
'--heroui-warning-400': '37.03 91.27% 55.1%',
'--heroui-warning-500': '37.01 91.26% 64.12%',
'--heroui-warning-600': '36.96 91.24% 73.14%',
'--heroui-warning-700': '37.14 91.3% 81.96%',
'--heroui-warning-800': '37.14 91.3% 90.98%',
'--heroui-warning-900': '54.55 91.67% 95.29%',
'--heroui-warning-foreground': '0 0% 0%',
'--heroui-warning': '37.03 91.27% 55.1%',
'--heroui-code-background': '240 5.56% 7.06%',
'--heroui-strong': '190.14 94.67% 44.12%',
'--heroui-code-mdx': '190.14 94.67% 44.12%',
'--heroui-divider-weight': '1px',
'--heroui-disabled-opacity': '.5',
'--heroui-font-size-tiny': '0.75rem',
'--heroui-font-size-small': '0.875rem',
'--heroui-font-size-medium': '1rem',
'--heroui-font-size-large': '1.125rem',
'--heroui-line-height-tiny': '1rem',
'--heroui-line-height-small': '1.25rem',
'--heroui-line-height-medium': '1.5rem',
'--heroui-line-height-large': '1.75rem',
'--heroui-radius-small': '8px',
'--heroui-radius-medium': '12px',
'--heroui-radius-large': '14px',
'--heroui-border-width-small': '1px',
'--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px',
'--heroui-box-shadow-small':
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-box-shadow-medium':
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-box-shadow-large':
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-hover-opacity': '.9',
},
light: {
'--heroui-background': '0 0% 100%',
'--heroui-foreground-50': '240 5.88% 95%',
'--heroui-foreground-100': '240 3.7% 90%',
'--heroui-foreground-200': '240 5.26% 80%',
'--heroui-foreground-300': '240 5.2% 70%',
'--heroui-foreground-400': '240 3.83% 60%',
'--heroui-foreground-500': '240 5.03% 50%',
'--heroui-foreground-600': '240 4.88% 40%',
'--heroui-foreground-700': '240 5.88% 30%',
'--heroui-foreground-800': '240 4.76% 20%',
'--heroui-foreground-900': '0 0% 10%',
'--heroui-foreground': '210 5.56% 7.06%',
'--heroui-focus': '212.01999999999998 100% 53.33%',
'--heroui-overlay': '0 0% 100%',
'--heroui-divider': '0 0% 0%',
'--heroui-divider-opacity': '0.85',
'--heroui-content1': '240 5.88% 95%',
'--heroui-content1-foreground': '0 0% 10%',
'--heroui-content2': '240 3.7% 90%',
'--heroui-content2-foreground': '240 4.76% 20%',
'--heroui-content3': '240 5.26% 80%',
'--heroui-content3-foreground': '240 5.88% 30%',
'--heroui-content4': '240 5.2% 70%',
'--heroui-content4-foreground': '240 4.88% 40%',
'--heroui-default-50': '240 5.88% 95%',
'--heroui-default-100': '240 3.7% 90%',
'--heroui-default-200': '240 5.26% 80%',
'--heroui-default-300': '240 5.2% 70%',
'--heroui-default-400': '240 3.83% 60%',
'--heroui-default-500': '240 5.03% 50%',
'--heroui-default-600': '240 4.88% 40%',
'--heroui-default-700': '240 5.88% 30%',
'--heroui-default-800': '240 4.76% 20%',
'--heroui-default-900': '0 0% 10%',
'--heroui-default-foreground': '0 0% 0%',
'--heroui-default': '240 5.26% 80%',
'--heroui-danger-50': '324 90.91% 95.69%',
'--heroui-danger-100': '350.53 90.48% 91.76%',
'--heroui-danger-200': '343.42 90.48% 83.53%',
'--heroui-danger-300': '337.84 83.46% 73.92%',
'--heroui-danger-400': '331.82 75% 65.49%',
'--heroui-danger-500': '325.82 69.62% 53.53%',
'--heroui-danger-600': '319.73 65.64% 44.51%',
'--heroui-danger-700': '313.85 70.65% 36.08%',
'--heroui-danger-800': '308.18 76.39% 28.24%',
'--heroui-danger-900': '301.89 82.61% 22.55%',
'--heroui-danger-foreground': '0 0% 100%',
'--heroui-danger': '325.82 69.62% 53.53%',
'--heroui-primary-50': '339.13 92% 95.1%',
'--heroui-primary-100': '340 91.84% 90.39%',
'--heroui-primary-200': '339.33 90% 80.39%',
'--heroui-primary-300': '339.11 90.6% 70.78%',
'--heroui-primary-400': '339 90% 60.78%',
'--heroui-primary-500': '339.2 90.36% 51.18%',
'--heroui-primary-600': '339 86.54% 40.78%',
'--heroui-primary-700': '339.11 85.99% 30.78%',
'--heroui-primary-800': '339.33 86.54% 20.39%',
'--heroui-primary-900': '340 84.91% 10.39%',
'--heroui-primary-foreground': '0 0% 100%',
'--heroui-primary': '339.2 90.36% 51.18%',
'--heroui-secondary-50': '270 61.54% 94.9%',
'--heroui-secondary-100': '270 59.26% 89.41%',
'--heroui-secondary-200': '270 59.26% 78.82%',
'--heroui-secondary-300': '270 59.26% 68.24%',
'--heroui-secondary-400': '270 59.26% 57.65%',
'--heroui-secondary-500': '270 66.67% 47.06%',
'--heroui-secondary-600': '270 66.67% 37.65%',
'--heroui-secondary-700': '270 66.67% 28.24%',
'--heroui-secondary-800': '270 66.67% 18.82%',
'--heroui-secondary-900': '270 66.67% 9.41%',
'--heroui-secondary-foreground': '0 0% 100%',
'--heroui-secondary': '270 66.67% 47.06%',
'--heroui-success-50': '146.67 64.29% 94.51%',
'--heroui-success-100': '145.71 61.4% 88.82%',
'--heroui-success-200': '146.2 61.74% 77.45%',
'--heroui-success-300': '145.79 62.57% 66.47%',
'--heroui-success-400': '146.01 62.45% 55.1%',
'--heroui-success-500': '145.96 79.46% 43.92%',
'--heroui-success-600': '146.01 79.89% 35.1%',
'--heroui-success-700': '145.79 79.26% 26.47%',
'--heroui-success-800': '146.2 79.78% 17.45%',
'--heroui-success-900': '145.71 77.78% 8.82%',
'--heroui-success-foreground': '0 0% 0%',
'--heroui-success': '145.96 79.46% 43.92%',
'--heroui-warning-50': '54.55 91.67% 95.29%',
'--heroui-warning-100': '37.14 91.3% 90.98%',
'--heroui-warning-200': '37.14 91.3% 81.96%',
'--heroui-warning-300': '36.96 91.24% 73.14%',
'--heroui-warning-400': '37.01 91.26% 64.12%',
'--heroui-warning-500': '37.03 91.27% 55.1%',
'--heroui-warning-600': '37.01 74.22% 44.12%',
'--heroui-warning-700': '36.96 73.96% 33.14%',
'--heroui-warning-800': '37.14 75% 21.96%',
'--heroui-warning-900': '37.14 75% 10.98%',
'--heroui-warning-foreground': '0 0% 0%',
'--heroui-warning': '37.03 91.27% 55.1%',
'--heroui-code-background': '221.25 17.39% 18.04%',
'--heroui-strong': '316.95 100% 65.29%',
'--heroui-code-mdx': '316.95 100% 65.29%',
'--heroui-divider-weight': '1px',
'--heroui-disabled-opacity': '.5',
'--heroui-font-size-tiny': '0.75rem',
'--heroui-font-size-small': '0.875rem',
'--heroui-font-size-medium': '1rem',
'--heroui-font-size-large': '1.125rem',
'--heroui-line-height-tiny': '1rem',
'--heroui-line-height-small': '1.25rem',
'--heroui-line-height-medium': '1.5rem',
'--heroui-line-height-large': '1.75rem',
'--heroui-radius-small': '8px',
'--heroui-radius-medium': '12px',
'--heroui-radius-large': '14px',
'--heroui-border-width-small': '1px',
'--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px',
'--heroui-box-shadow-small':
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-box-shadow-medium':
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-box-shadow-large':
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-hover-opacity': '.8',
},
},
}
);

View File

@@ -1,85 +0,0 @@
import multer from 'multer';
import { Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
const isWindows = process.platform === 'win32';
// 修改:使用 Buffer 转码文件名,解决文件上传时乱码问题
const decodeFileName = (fileName: string): string => {
try {
return Buffer.from(fileName, 'binary').toString('utf8');
} catch {
return fileName;
}
};
export const createDiskStorage = (uploadPath: string) => {
return multer.diskStorage({
destination: (
_: Request,
file: Express.Multer.File,
cb: (error: Error | null, destination: string) => void
) => {
try {
const decodedName = decodeFileName(file.originalname);
if (!uploadPath) {
return cb(new Error('上传路径不能为空'), '');
}
if (isWindows && uploadPath === '\\') {
return cb(new Error('根目录不允许上传文件'), '');
}
// 处理文件夹上传的情况
if (decodedName.includes('/') || decodedName.includes('\\')) {
const fullPath = path.join(uploadPath, path.dirname(decodedName));
fs.mkdirSync(fullPath, { recursive: true });
cb(null, fullPath);
} else {
cb(null, uploadPath);
}
} catch (error) {
cb(error as Error, '');
}
},
filename: (_: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
try {
const decodedName = decodeFileName(file.originalname);
const fileName = path.basename(decodedName);
// 检查文件是否存在
const fullPath = path.join(uploadPath, decodedName);
if (fs.existsSync(fullPath)) {
const ext = path.extname(fileName);
const name = path.basename(fileName, ext);
cb(null, `${name}-${randomUUID()}${ext}`);
} else {
cb(null, fileName);
}
} catch (error) {
cb(error as Error, '');
}
},
});
};
export const createDiskUpload = (uploadPath: string) => {
const upload = multer({ storage: createDiskStorage(uploadPath) }).array('files');
return upload;
};
const diskUploader = (req: Request, res: Response) => {
const uploadPath = (req.query['path'] || '') as string;
return new Promise((resolve, reject) => {
createDiskUpload(uploadPath)(req, res, (error) => {
if (error) {
// 错误处理
return reject(error);
}
return resolve(true);
});
});
};
export default diskUploader;

View File

@@ -1,52 +0,0 @@
import multer from 'multer';
import { WebUiConfigWrapper } from '../helper/config';
import path from 'path';
import fs from 'fs';
import type { Request, Response } from 'express';
export const webUIFontStorage = multer.diskStorage({
destination: (_, __, cb) => {
try {
const fontsPath = path.dirname(WebUiConfigWrapper.GetWebUIFontPath());
// 确保字体目录存在
fs.mkdirSync(fontsPath, { recursive: true });
cb(null, fontsPath);
} catch (error) {
// 确保错误信息被正确传递
cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
}
},
filename: (_, __, cb) => {
// 统一保存为webui.woff
cb(null, 'webui.woff');
},
});
export const webUIFontUpload = multer({
storage: webUIFontStorage,
fileFilter: (_, file, cb) => {
// 再次验证文件类型
if (!file.originalname.toLowerCase().endsWith('.woff')) {
cb(new Error('只支持WOFF格式的字体文件'));
return;
}
cb(null, true);
},
limits: {
fileSize: 40 * 1024 * 1024, // 限制40MB
},
}).single('file');
const webUIFontUploader = (req: Request, res: Response) => {
return new Promise((resolve, reject) => {
webUIFontUpload(req, res, (error) => {
if (error) {
// 错误处理
// sendError(res, error.message, true);
return reject(error);
}
return resolve(true);
});
});
};
export default webUIFontUploader;

View File

@@ -1 +0,0 @@
export const isEmpty = <T>(data: T) => data === undefined || data === null || data === '';

View File

@@ -1,22 +0,0 @@
export function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>): T {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
// 如果 source[key] 为 undefined则跳过保留 target[key]
if (source[key] === undefined) {
continue;
}
if (
target[key] !== undefined &&
typeof target[key] === 'object' &&
!Array.isArray(target[key]) &&
typeof source[key] === 'object' &&
!Array.isArray(source[key])
) {
target[key] = deepMerge({ ...target[key] }, source[key]!) as T[Extract<keyof T, string>];
} else {
target[key] = source[key]! as T[Extract<keyof T, string>];
}
}
}
return target;
}

View File

@@ -1,47 +0,0 @@
import type { Response } from 'express';
import { ResponseCode, HttpStatusCode } from '@webapi/const/status';
export const sendResponse = <T>(
res: Response,
data?: T,
code: ResponseCode = 0,
message = 'success',
useSend: boolean = false
) => {
const result = {
code,
message,
data,
};
if (useSend) {
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
return;
}
res.status(HttpStatusCode.OK).json(result);
};
export const sendError = (res: Response, message = 'error', useSend: boolean = false) => {
const result = {
code: ResponseCode.Error,
message,
};
if (useSend) {
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
return;
}
res.status(HttpStatusCode.OK).json(result);
};
export const sendSuccess = <T>(res: Response, data?: T, message = 'success', useSend: boolean = false) => {
const result = {
code: ResponseCode.Success,
data,
message,
};
if (useSend) {
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
return;
}
res.status(HttpStatusCode.OK).json(result);
};

View File

@@ -1,47 +0,0 @@
/**
* @file URL工具
*/
import { isIP } from 'node:net';
/**
* 将 host主机地址 转换为标准格式
* @param host 主机地址
* @returns 标准格式的IP地址
* @example normalizeHost('10.0.3.2') => '10.0.3.2'
* @example normalizeHost('0.0.0.0') => '127.0.0.1'
* @example normalizeHost('2001:4860:4801:51::27') => '[2001:4860:4801:51::27]'
*/
export const normalizeHost = (host: string) => {
if (host === '0.0.0.0') return '127.0.0.1';
if (isIP(host) === 6) return `[${host}]`;
return host;
};
/**
* 创建URL
* @param host 主机地址
* @param port 端口
* @param path URL路径
* @param search URL参数
* @returns 完整URL
* @example createUrl('127.0.0.1', '8080', '/api', { token: '123456' }) => 'http://127.0.0.1:8080/api?token=123456'
* @example createUrl('baidu.com', '80', void 0, void 0, 'https') => 'https://baidu.com:80/'
*/
export const createUrl = (
host: string,
port: string,
path = '/',
search?: Record<string, any>,
protocol: Protocol = 'http'
) => {
const url = new URL(`${protocol}://${normalizeHost(host)}`);
url.port = port;
url.pathname = path;
if (search) {
for (const key in search) {
url.searchParams.set(key, search[key]);
}
}
return url.toString();
};

View File

@@ -1,7 +0,0 @@
{
"host": "0.0.0.0",
"port": 6099,
"prefix": "",
"token": "random",
"loginRate": 3
}

View File

@@ -3,15 +3,9 @@ import { defineConfig, PluginOption, UserConfig } from 'vite';
import { resolve } from 'path'; import { resolve } from 'path';
import nodeResolve from '@rollup/plugin-node-resolve'; import nodeResolve from '@rollup/plugin-node-resolve';
import { builtinModules } from 'module'; import { builtinModules } from 'module';
import wasm from 'vite-plugin-wasm';
//依赖排除 //依赖排除
const external = [ const external = [
'silk-wasm',
'ws',
'express',
'@ffmpeg.wasm/core-mt',
'@breezystack/lamejs',
'audio-decode',
'wavefile'
]; ];
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat(); const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
@@ -45,6 +39,7 @@ const UniversalBaseConfigPlugin: PluginOption[] = [
], ],
}), }),
nodeResolve(), nodeResolve(),
wasm()
]; ];
const FrameworkBaseConfigPlugin: PluginOption[] = [ const FrameworkBaseConfigPlugin: PluginOption[] = [
@@ -64,6 +59,7 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [
], ],
}), }),
nodeResolve(), nodeResolve(),
wasm()
]; ];
const ShellBaseConfigPlugin: PluginOption[] = [ const ShellBaseConfigPlugin: PluginOption[] = [
@@ -71,7 +67,6 @@ const ShellBaseConfigPlugin: PluginOption[] = [
targets: [ targets: [
{ src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false },
{ src: './src/native/pty', dest: 'dist/pty', flatten: false }, { src: './src/native/pty', dest: 'dist/pty', flatten: false },
{ src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false },
{ src: './src/core/external/napcat.json', dest: 'dist/config/' }, { src: './src/core/external/napcat.json', dest: 'dist/config/' },
{ src: './package.json', dest: 'dist' }, { src: './package.json', dest: 'dist' },
{ src: './launcher/', dest: 'dist', flatten: true }, { src: './launcher/', dest: 'dist', flatten: true },
@@ -81,6 +76,7 @@ const ShellBaseConfigPlugin: PluginOption[] = [
], ],
}), }),
nodeResolve(), nodeResolve(),
wasm()
]; ];
const UniversalBaseConfig = () => const UniversalBaseConfig = () =>
defineConfig({ defineConfig({