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

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

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

No files matched your search

@@ -0,0 +1,102 @@
import { OneBotAction, OneBotRequestToolkit } from '@/snowcat-onebot/action/OneBotAction';
import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs';
import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid';
import { calculateTimeout } from '@/snowcat-onebot/config/config';
export interface ResolvedFileInfo {
downloadPath: string;
fileName: string;
fileSize: number;
}
export interface DownloadResult {
// 文件信息
file_name?: string;
file_size?: number;
chunk_size?: number;
// 分片数据
index?: number;
data?: string;
size?: number;
progress?: number;
base64_size?: number;
// 完成信息
total_chunks?: number;
total_bytes?: number;
message?: string;
data_type?: 'file_info' | 'file_chunk' | 'file_complete';
// 可选扩展字段
width?: number;
height?: number;
out_format?: string;
}
export abstract class BaseDownloadStream<PayloadType, ResultType> extends OneBotAction<PayloadType, StreamPacket<ResultType>> {
protected async resolveDownload (file?: string): Promise<ResolvedFileInfo> {
const target = file || '';
let downloadPath = '';
let fileName = '';
let fileSize = 0;
const contextMsgFile = FileNapCatOneBotUUID.decode(target);
if (contextMsgFile && contextMsgFile.msgId && contextMsgFile.elementId) {
const { peer, msgId, elementId } = contextMsgFile;
downloadPath = await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', '');
const rawMessage = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId]))?.msgList
.find(msg => msg.msgId === msgId);
const mixElement = rawMessage?.elements.find(e => e.elementId === elementId);
const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement;
if (!mixElementInner) throw new Error('element not found');
fileSize = parseInt(mixElementInner.fileSize?.toString() ?? '0');
fileName = mixElementInner.fileName ?? '';
return { downloadPath, fileName, fileSize };
}
const contextModelIdFile = FileNapCatOneBotUUID.decodeModelId(target);
if (contextModelIdFile && contextModelIdFile.modelId) {
const { peer, modelId } = contextModelIdFile;
downloadPath = await this.core.apis.FileApi.downloadFileForModelId(peer, modelId, '');
return { downloadPath, fileName, fileSize };
}
const searchResult = (await this.core.apis.FileApi.searchForFile([target]));
if (searchResult) {
const fileSizeNum = parseInt(searchResult.fileSize);
const timeoutConfig = this.obContext.configLoader.configData.timeout;
const estimatedTime = calculateTimeout(timeoutConfig, fileSizeNum, timeoutConfig.downloadSpeedKBps);
downloadPath = await this.core.apis.FileApi.downloadFileById(searchResult.id, fileSizeNum, estimatedTime);
fileSize = fileSizeNum;
fileName = searchResult.fileName;
return { downloadPath, fileName, fileSize };
}
throw new Error('file not found');
}
protected async streamFileChunks (req: OneBotRequestToolkit, streamPath: string, chunkSize: number, chunkDataType: string): Promise<{ totalChunks: number; totalBytes: number }> {
const stats = await fs.promises.stat(streamPath);
const totalSize = stats.size;
const readStream = fs.createReadStream(streamPath, { highWaterMark: chunkSize });
let chunkIndex = 0;
let bytesRead = 0;
for await (const chunk of readStream) {
const base64Chunk = (chunk as Buffer).toString('base64');
bytesRead += (chunk as Buffer).length;
await req.send({
type: StreamStatus.Stream,
data_type: chunkDataType,
index: chunkIndex,
data: base64Chunk,
size: (chunk as Buffer).length,
progress: Math.round((bytesRead / totalSize) * 100),
base64_size: base64Chunk.length,
} as unknown as StreamPacket<any>);
chunkIndex++;
}
return { totalChunks: chunkIndex, totalBytes: bytesRead };
}
}
@@ -0,0 +1,42 @@
import { ActionName } from '@/snowcat-onebot/action/router';
import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction';
import { join } from 'node:path';
import { readdir, unlink } from 'node:fs/promises';
import { Type } from '@sinclair/typebox';
export class CleanStreamTempFile extends OneBotAction<void, void> {
override actionName = ActionName.CleanStreamTempFile;
override actionSummary = '清理流式传输临时文件';
override actionTags = ['流式传输扩展'];
override payloadExample = {};
override returnExample = {
message: 'success',
};
override payloadSchema = Type.Object({});
override returnSchema = Type.Null();
async _handle (_payload: void): Promise<void> {
try {
// 获取临时文件夹路径
const tempPath = this.core.SnowCatTempPath;
// 读取文件夹中的所有文件
const files = await readdir(tempPath);
// 删除每个文件
const deletePromises = files.map(async (file) => {
const filePath = join(tempPath, file);
try {
await unlink(filePath);
this.core.context.logger.log(`已删除文件: ${filePath}`);
} catch (err: unknown) {
this.core.context.logger.log(`删除文件 ${filePath} 失败: ${(err as Error).message}`);
}
});
await Promise.all(deletePromises);
} catch (err: unknown) {
this.core.context.logger.log(`清理流临时文件失败: ${(err as Error).message}`);
}
}
}
@@ -0,0 +1,70 @@
import { ActionName } from '@/snowcat-onebot/action/router';
import { OneBotRequestToolkit } from '@/snowcat-onebot/action/OneBotAction';
import { Static, Type } from '@sinclair/typebox';
import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config';
import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs';
import { imageSizeFallBack } from 'snowcat-image-size';
import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream';
export const DownloadFileImageStreamPayloadSchema = Type.Object({
file: Type.Optional(Type.String({ description: '文件路径或 URL' })),
file_id: Type.Optional(Type.String({ description: '文件 ID' })),
chunk_size: Type.Optional(Type.Number({ default: 64 * 1024, description: '分块大小 (字节)' })), // 默认64KB分块
});
export type DownloadFileImageStreamPayload = Static<typeof DownloadFileImageStreamPayloadSchema>;
export class DownloadFileImageStream extends BaseDownloadStream<DownloadFileImageStreamPayload, DownloadResult> {
override actionName = ActionName.DownloadFileImageStream;
override actionSummary = '下载图片文件流';
override actionTags = ['流式传输扩展'];
override payloadExample = {
file: 'image_file_id',
};
override returnExample = {
file: 'temp_image_path',
};
override payloadSchema = DownloadFileImageStreamPayloadSchema;
override returnSchema = Type.Any({ description: '下载结果 (流式)' });
override useStream = true;
async _handle (payload: DownloadFileImageStreamPayload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<StreamPacket<DownloadResult>> {
try {
payload.file ||= payload.file_id || '';
const chunkSize = payload.chunk_size || 64 * 1024;
const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file);
const stats = await fs.promises.stat(downloadPath);
const totalSize = fileSize || stats.size;
const { width, height } = await imageSizeFallBack(downloadPath);
// 发送文件信息(与 DownloadFileStream 对齐,但包含宽高)
await req.send({
type: StreamStatus.Stream,
data_type: 'file_info',
file_name: fileName,
file_size: totalSize,
chunk_size: chunkSize,
width,
height,
});
const { totalChunks, totalBytes } = await this.streamFileChunks(req, downloadPath, chunkSize, 'file_chunk');
// 返回完成状态(与 DownloadFileStream 对齐)
return {
type: StreamStatus.Response,
data_type: 'file_complete',
total_chunks: totalChunks,
total_bytes: totalBytes,
message: 'Download completed',
};
} catch (error) {
throw new Error(`Download failed: ${(error as Error).message}`);
}
}
}
@@ -0,0 +1,89 @@
import { ActionName } from '@/snowcat-onebot/action/router';
import { OneBotRequestToolkit } from '@/snowcat-onebot/action/OneBotAction';
import { Static, Type } from '@sinclair/typebox';
import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config';
import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs';
import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg';
import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream';
const out_format = ['mp3', 'amr', 'wma', 'm4a', 'spx', 'ogg', 'wav', 'flac'];
export const DownloadFileRecordStreamPayloadSchema = Type.Object({
file: Type.Optional(Type.String({ description: '文件路径或 URL' })),
file_id: Type.Optional(Type.String({ description: '文件 ID' })),
chunk_size: Type.Optional(Type.Number({ default: 64 * 1024, description: '分块大小 (字节)' })), // 默认64KB分块
out_format: Type.Optional(Type.String({ description: '输出格式' })),
});
export type DownloadFileRecordStreamPayload = Static<typeof DownloadFileRecordStreamPayloadSchema>;
export class DownloadFileRecordStream extends BaseDownloadStream<DownloadFileRecordStreamPayload, DownloadResult> {
override actionName = ActionName.DownloadFileRecordStream;
override actionSummary = '下载语音文件流';
override actionTags = ['流式传输扩展'];
override payloadExample = {
file: 'record_file_id',
};
override returnExample = {
file: 'temp_record_path',
};
override payloadSchema = DownloadFileRecordStreamPayloadSchema;
override returnSchema = Type.Any({ description: '下载结果 (流式)' });
override useStream = true;
async _handle (payload: DownloadFileRecordStreamPayload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<StreamPacket<DownloadResult>> {
try {
payload.file ||= payload.file_id || '';
const chunkSize = payload.chunk_size || 64 * 1024;
const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file);
// 处理输出格式转换
let streamPath = downloadPath;
if (payload.out_format && typeof payload.out_format === 'string') {
if (!out_format.includes(payload.out_format)) {
throw new Error('转换失败 out_format 字段可能格式不正确');
}
const outputFile = `${downloadPath}.${payload.out_format}`;
try {
// 如果已存在目标文件则跳过转换
await fs.promises.access(outputFile);
streamPath = outputFile;
} catch {
// 尝试解码 amr 到 out format直接 ffmpeg 转换
await FFmpegService.convertAudioFmt(downloadPath, outputFile, payload.out_format);
streamPath = outputFile;
}
}
const stats = await fs.promises.stat(streamPath);
const totalSize = fileSize || stats.size;
await req.send({
type: StreamStatus.Stream,
data_type: 'file_info',
file_name: fileName,
file_size: totalSize,
chunk_size: chunkSize,
out_format: payload.out_format,
});
const { totalChunks, totalBytes } = await this.streamFileChunks(req, streamPath, chunkSize, 'file_chunk');
return {
type: StreamStatus.Response,
data_type: 'file_complete',
total_chunks: totalChunks,
total_bytes: totalBytes,
message: 'Download completed',
};
} catch (error) {
throw new Error(`Download failed: ${(error as Error).message}`);
}
}
}
@@ -0,0 +1,67 @@
import { ActionName } from '@/snowcat-onebot/action/router';
import { OneBotRequestToolkit } from '@/snowcat-onebot/action/OneBotAction';
import { Static, Type } from '@sinclair/typebox';
import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config';
import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs';
import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream';
const DownloadFileStreamPayloadSchema = Type.Object({
file: Type.Optional(Type.String({ description: '文件路径或 URL' })),
file_id: Type.Optional(Type.String({ description: '文件 ID' })),
chunk_size: Type.Optional(Type.Number({ default: 64 * 1024, description: '分块大小 (字节)' })), // 默认64KB分块
});
export type DownloadFileStreamPayload = Static<typeof DownloadFileStreamPayloadSchema>;
export class DownloadFileStream extends BaseDownloadStream<DownloadFileStreamPayload, DownloadResult> {
override actionName = ActionName.DownloadFileStream;
override payloadSchema = DownloadFileStreamPayloadSchema;
override returnSchema = Type.Any({ description: '下载结果 (流式)' });
override actionSummary = '下载文件流';
override actionDescription = '以流式方式从网络或本地下载文件';
override actionTags = ['流式接口'];
override payloadExample = {
file: 'http://example.com/file.png',
};
override returnExample = {
type: 'stream',
data_type: 'file_info',
file_name: 'file.png',
file_size: 1024,
};
override useStream = true;
async _handle (payload: DownloadFileStreamPayload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<StreamPacket<DownloadResult>> {
try {
payload.file ||= payload.file_id || '';
const chunkSize = payload.chunk_size || 64 * 1024;
const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file);
const stats = await fs.promises.stat(downloadPath);
const totalSize = fileSize || stats.size;
await req.send({
type: StreamStatus.Stream,
data_type: 'file_info',
file_name: fileName,
file_size: totalSize,
chunk_size: chunkSize,
});
const { totalChunks, totalBytes } = await this.streamFileChunks(req, downloadPath, chunkSize, 'file_chunk');
return {
type: StreamStatus.Response,
data_type: 'file_complete',
total_chunks: totalChunks,
total_bytes: totalBytes,
message: 'Download completed',
};
} catch (error) {
throw new Error(`Download failed: ${(error as Error).message}`);
}
}
}
@@ -0,0 +1,3 @@
# Stream-Api
## 流式接口
@@ -0,0 +1,16 @@
import { OneBotAction, OneBotRequestToolkit } from '../OneBotAction';
import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config';
export type StreamPacketBasic = {
type: StreamStatus;
data_type?: string;
};
export type StreamPacket<T> = T & StreamPacketBasic;
export enum StreamStatus {
Stream = 'stream', // 分片流数据包
Response = 'response', // 流最终响应
Reset = 'reset', // 重置流
Error = 'error', // 流错误
}
export abstract class BasicStream<T, R> extends OneBotAction<T, StreamPacket<R>> {
abstract override _handle (_payload: T, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<StreamPacket<R>>;
}
@@ -0,0 +1,43 @@
import { ActionName } from '@/snowcat-onebot/action/router';
import { OneBotAction, OneBotRequestToolkit } from '@/snowcat-onebot/action/OneBotAction';
import { Static, Type } from '@sinclair/typebox';
import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config';
import { StreamPacket, StreamStatus } from './StreamBasic';
export const TestDownloadStreamPayloadSchema = Type.Object({
error: Type.Optional(Type.Boolean({ default: false, description: '是否触发测试错误' })),
});
export type TestDownloadStreamPayload = Static<typeof TestDownloadStreamPayloadSchema>;
export class TestDownloadStream extends OneBotAction<TestDownloadStreamPayload, StreamPacket<{ data: string; }>> {
override actionName = ActionName.TestDownloadStream;
override actionSummary = '测试下载流';
override actionTags = ['流式传输扩展'];
override payloadExample = {
url: 'http://example.com/file',
};
override returnExample = {
success: true,
};
override payloadSchema = TestDownloadStreamPayloadSchema;
override returnSchema = Type.Any({ description: '测试流数据' });
override useStream = true;
async _handle (_payload: TestDownloadStreamPayload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit) {
for (let i = 0; i < 10; i++) {
await req.send({ type: StreamStatus.Stream, data: `Index-> ${i + 1}`, data_type: 'data_chunk' });
await new Promise(resolve => setTimeout(resolve, 100));
}
if (_payload.error) {
throw new Error('This is a test error');
}
return {
type: StreamStatus.Response,
data_type: 'data_complete',
data: 'Stream transmission complete',
};
}
}
@@ -0,0 +1,363 @@
import { ActionName } from '@/snowcat-onebot/action/router';
import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction';
import { Static, Type } from '@sinclair/typebox';
import { NetworkAdapterConfig } from '@/snowcat-onebot/config/config';
import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs';
import { join as joinPath } from 'node:path';
import { randomUUID, createHash } from 'crypto';
import { unlink } from 'node:fs';
// 简化配置
const CONFIG = {
TIMEOUT: 10 * 60 * 1000, // 10分钟超时
MEMORY_THRESHOLD: 10 * 1024 * 1024, // 10MB,超过使用磁盘
MEMORY_LIMIT: 100 * 1024 * 1024, // 100MB内存总限制
} as const;
export const UploadFileStreamPayloadSchema = Type.Object({
stream_id: Type.String({ description: '流 ID' }),
chunk_data: Type.Optional(Type.String({ description: '分块数据 (Base64)' })),
chunk_index: Type.Optional(Type.Number({ description: '分块索引' })),
total_chunks: Type.Optional(Type.Number({ description: '总分块数' })),
file_size: Type.Optional(Type.Number({ description: '文件总大小' })),
expected_sha256: Type.Optional(Type.String({ description: '期望的 SHA256' })),
is_complete: Type.Optional(Type.Boolean({ description: '是否完成' })),
filename: Type.Optional(Type.String({ description: '文件名' })),
reset: Type.Optional(Type.Boolean({ description: '是否重置' })),
verify_only: Type.Optional(Type.Boolean({ description: '是否仅验证' })),
file_retention: Type.Number({ default: 5 * 60 * 1000, description: '文件保留时间 (毫秒)' }), // 默认5分钟 回收 不设置或0为不回收
});
export type UploadFileStreamPayload = Static<typeof UploadFileStreamPayloadSchema>;
// 简化流状态接口
interface StreamState {
id: string;
filename: string;
totalChunks: number;
receivedChunks: number;
missingChunks: Set<number>;
// 可选属性
fileSize?: number;
expectedSha256?: string;
// 存储策略
useMemory: boolean;
memoryChunks?: Map<number, Buffer>;
tempDir?: string;
finalPath?: string;
fileRetention?: number;
// 管理
createdAt: number;
timeoutId: NodeJS.Timeout;
}
interface StreamResult {
stream_id: string;
status: 'file_created' | 'chunk_received' | 'file_complete';
received_chunks: number;
total_chunks: number;
file_path?: string;
file_size?: number;
sha256?: string;
}
export class UploadFileStream extends OneBotAction<UploadFileStreamPayload, StreamPacket<StreamResult>> {
override actionName = ActionName.UploadFileStream;
override payloadSchema = UploadFileStreamPayloadSchema;
override returnSchema = Type.Any({ description: '上传结果 (流式)' });
override actionSummary = '上传文件流';
override actionDescription = '以流式方式上传文件数据到机器人';
override actionTags = ['流式接口'];
override payloadExample = {
stream_id: 'uuid-1234-5678',
chunk_data: 'SGVsbG8gV29ybGQ=',
chunk_index: 0,
total_chunks: 1,
file_size: 11,
};
override returnExample = {
type: 'stream',
stream_id: 'uuid-1234-5678',
status: 'chunk_received',
received_chunks: 1,
total_chunks: 1,
};
override useStream = true;
private static streams = new Map<string, StreamState>();
private static memoryUsage = 0;
async _handle (payload: UploadFileStreamPayload, _adaptername: string, _config: NetworkAdapterConfig): Promise<StreamPacket<StreamResult>> {
const { stream_id, reset, verify_only } = payload;
if (reset) {
this.cleanupStream(stream_id);
throw new Error('Stream reset completed');
}
if (verify_only) {
const stream = UploadFileStream.streams.get(stream_id);
if (!stream) throw new Error('Stream not found');
return this.getStreamStatus(stream);
}
const stream = this.getOrCreateStream(payload);
if (payload.chunk_data && payload.chunk_index !== undefined) {
return await this.processChunk(stream, payload.chunk_data, payload.chunk_index);
}
if (payload.is_complete || stream.receivedChunks === stream.totalChunks) {
return await this.completeStream(stream);
}
return this.getStreamStatus(stream);
}
private getOrCreateStream (payload: UploadFileStreamPayload): StreamState {
let stream = UploadFileStream.streams.get(payload.stream_id);
if (!stream) {
if (!payload.total_chunks) {
throw new Error('total_chunks required for new stream');
}
stream = this.createStream(payload);
}
return stream;
}
private createStream (payload: UploadFileStreamPayload): StreamState {
const { stream_id, total_chunks, file_size, filename, expected_sha256 } = payload;
const useMemory = this.shouldUseMemory(file_size);
if (useMemory && file_size && (UploadFileStream.memoryUsage + file_size) > CONFIG.MEMORY_LIMIT) {
throw new Error('Memory limit exceeded');
}
const stream: StreamState = {
id: stream_id,
filename: filename || `upload_${randomUUID()}`,
totalChunks: total_chunks!,
receivedChunks: 0,
missingChunks: new Set(Array.from({ length: total_chunks! }, (_, i) => i)),
fileSize: file_size,
expectedSha256: expected_sha256,
useMemory,
createdAt: Date.now(),
timeoutId: this.setupTimeout(stream_id),
fileRetention: payload.file_retention,
};
try {
if (useMemory) {
stream.memoryChunks = new Map();
if (file_size) UploadFileStream.memoryUsage += file_size;
} else {
this.setupDiskStorage(stream);
}
UploadFileStream.streams.set(stream_id, stream);
return stream;
} catch (error) {
// 如果设置存储失败,清理已创建的资源
clearTimeout(stream.timeoutId);
if (stream.tempDir && fs.existsSync(stream.tempDir)) {
try {
fs.rmSync(stream.tempDir, { recursive: true, force: true });
} catch (cleanupError) {
console.error('Failed to cleanup temp dir during creation error:', cleanupError);
}
}
throw error;
}
}
private shouldUseMemory (fileSize?: number): boolean {
return fileSize !== undefined && fileSize <= CONFIG.MEMORY_THRESHOLD;
}
private setupDiskStorage (stream: StreamState): void {
const tempDir = joinPath(this.core.SnowCatTempPath, `upload_${stream.id}`);
const finalPath = joinPath(this.core.SnowCatTempPath, stream.filename);
fs.mkdirSync(tempDir, { recursive: true });
stream.tempDir = tempDir;
stream.finalPath = finalPath;
}
private setupTimeout (streamId: string): NodeJS.Timeout {
return setTimeout(() => {
console.log(`Stream ${streamId} timeout`);
this.cleanupStream(streamId);
}, CONFIG.TIMEOUT);
}
private async processChunk (stream: StreamState, chunkData: string, chunkIndex: number): Promise<StreamPacket<StreamResult>> {
// 验证索引
if (chunkIndex < 0 || chunkIndex >= stream.totalChunks) {
throw new Error(`Invalid chunk index: ${chunkIndex}`);
}
// 检查重复
if (!stream.missingChunks.has(chunkIndex)) {
return this.getStreamStatus(stream);
}
const buffer = Buffer.from(chunkData, 'base64');
// 存储分片
if (stream.useMemory) {
stream.memoryChunks!.set(chunkIndex, buffer);
} else {
const chunkPath = joinPath(stream.tempDir!, `${chunkIndex}.chunk`);
await fs.promises.writeFile(chunkPath, buffer);
}
// 更新状态
stream.missingChunks.delete(chunkIndex);
stream.receivedChunks++;
this.refreshTimeout(stream);
return {
type: StreamStatus.Stream,
stream_id: stream.id,
status: 'chunk_received',
received_chunks: stream.receivedChunks,
total_chunks: stream.totalChunks,
};
}
private refreshTimeout (stream: StreamState): void {
clearTimeout(stream.timeoutId);
stream.timeoutId = this.setupTimeout(stream.id);
}
private getStreamStatus (stream: StreamState): StreamPacket<StreamResult> {
return {
type: StreamStatus.Stream,
stream_id: stream.id,
status: 'file_created',
received_chunks: stream.receivedChunks,
total_chunks: stream.totalChunks,
};
}
private async completeStream (stream: StreamState): Promise<StreamPacket<StreamResult>> {
// 合并分片
const finalBuffer = stream.useMemory
? await this.mergeMemoryChunks(stream)
: await this.mergeDiskChunks(stream);
// 验证SHA256
const sha256 = this.validateSha256(stream, finalBuffer);
// 保存文件
const finalPath = stream.finalPath || joinPath(this.core.SnowCatTempPath, stream.filename);
await fs.promises.writeFile(finalPath, finalBuffer);
// 清理资源但保留文件
this.cleanupStream(stream.id, false);
if (stream.fileRetention && stream.fileRetention > 0) {
setTimeout(() => {
unlink(finalPath, err => {
if (err) this.core.context.logger.logError(`Failed to delete retained file ${finalPath}:`, err);
});
}, stream.fileRetention);
}
return {
type: StreamStatus.Response,
stream_id: stream.id,
status: 'file_complete',
received_chunks: stream.receivedChunks,
total_chunks: stream.totalChunks,
file_path: finalPath,
file_size: finalBuffer.length,
sha256,
};
}
private async mergeMemoryChunks (stream: StreamState): Promise<Buffer> {
const chunks: Buffer[] = [];
for (let i = 0; i < stream.totalChunks; i++) {
const chunk = stream.memoryChunks!.get(i);
if (!chunk) throw new Error(`Missing memory chunk ${i}`);
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
private async mergeDiskChunks (stream: StreamState): Promise<Buffer> {
const chunks: Buffer[] = [];
for (let i = 0; i < stream.totalChunks; i++) {
const chunkPath = joinPath(stream.tempDir!, `${i}.chunk`);
if (!fs.existsSync(chunkPath)) throw new Error(`Missing chunk file ${i}`);
chunks.push(await fs.promises.readFile(chunkPath));
}
return Buffer.concat(chunks);
}
private validateSha256 (stream: StreamState, buffer: Buffer): string | undefined {
if (!stream.expectedSha256) return undefined;
const actualSha256 = createHash('sha256').update(buffer).digest('hex');
if (actualSha256 !== stream.expectedSha256) {
throw new Error(`SHA256 mismatch. Expected: ${stream.expectedSha256}, Got: ${actualSha256}`);
}
return actualSha256;
}
private cleanupStream (streamId: string, deleteFinalFile = true): void {
const stream = UploadFileStream.streams.get(streamId);
if (!stream) return;
try {
// 清理超时
clearTimeout(stream.timeoutId);
// 清理内存
if (stream.useMemory) {
if (stream.fileSize) {
UploadFileStream.memoryUsage = Math.max(0, UploadFileStream.memoryUsage - stream.fileSize);
}
stream.memoryChunks?.clear();
}
// 清理临时文件夹及其所有内容
if (stream.tempDir) {
try {
if (fs.existsSync(stream.tempDir)) {
fs.rmSync(stream.tempDir, { recursive: true, force: true });
console.log(`Cleaned up temp directory: ${stream.tempDir}`);
}
} catch (error) {
console.error(`Failed to cleanup temp directory ${stream.tempDir}:`, error);
}
}
// 删除最终文件(如果需要)
if (deleteFinalFile && stream.finalPath) {
try {
if (fs.existsSync(stream.finalPath)) {
fs.unlinkSync(stream.finalPath);
console.log(`Deleted final file: ${stream.finalPath}`);
}
} catch (error) {
console.error(`Failed to delete final file ${stream.finalPath}:`, error);
}
}
} catch (error) {
console.error(`Cleanup error for stream ${streamId}:`, error);
} finally {
UploadFileStream.streams.delete(streamId);
console.log(`Stream ${streamId} cleaned up`);
}
}
}
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NapCat OneBot WebSocket 文件流上传测试脚本
用于测试 UploadFileStream 接口的一次性分片上传功能
"""
import asyncio
import json
import base64
import hashlib
import os
import uuid
from typing import List, Optional
import websockets
import argparse
from pathlib import Path
class OneBotUploadTester:
def __init__(self, ws_url: str = "ws://localhost:3001", access_token: Optional[str] = None):
self.ws_url = ws_url
self.access_token = access_token
self.websocket = None
async def connect(self):
"""连接到 OneBot WebSocket"""
headers = {}
if self.access_token:
headers["Authorization"] = f"Bearer {self.access_token}"
print(f"连接到 {self.ws_url}")
self.websocket = await websockets.connect(self.ws_url, additional_headers=headers)
print("WebSocket 连接成功")
async def disconnect(self):
"""断开 WebSocket 连接"""
if self.websocket:
await self.websocket.close()
print("WebSocket 连接已断开")
def calculate_file_chunks(self, file_path: str, chunk_size: int = 64) -> tuple[List[bytes], str, int]:
"""
计算文件分片和 SHA256
Args:
file_path: 文件路径
chunk_size: 分片大小(默认64KB)
Returns:
(chunks, sha256_hash, total_size)
"""
chunks = []
hasher = hashlib.sha256()
total_size = 0
with open(file_path, 'rb') as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
chunks.append(chunk)
hasher.update(chunk)
total_size += len(chunk)
sha256_hash = hasher.hexdigest()
print(f"文件分析完成:")
print(f" - 文件大小: {total_size} 字节")
print(f" - 分片数量: {len(chunks)}")
print(f" - SHA256: {sha256_hash}")
return chunks, sha256_hash, total_size
async def send_action(self, action: str, params: dict, echo: str = None) -> dict:
"""发送 OneBot 动作请求"""
if not echo:
echo = str(uuid.uuid4())
message = {
"action": action,
"params": params,
"echo": echo
}
print(f"发送请求: {action}")
await self.websocket.send(json.dumps(message))
# 等待响应
while True:
response = await self.websocket.recv()
data = json.loads(response)
# 检查是否是我们的响应
if data.get("echo") == echo:
return data
else:
# 可能是其他消息,继续等待
print(f"收到其他消息: {data}")
continue
async def upload_file_stream_batch(self, file_path: str, chunk_size: int = 64 ) -> str:
"""
一次性批量上传文件流
Args:
file_path: 要上传的文件路径
chunk_size: 分片大小
Returns:
上传完成后的文件路径
"""
file_path = Path(file_path)
if not file_path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}")
# 分析文件
chunks, sha256_hash, total_size = self.calculate_file_chunks(str(file_path), chunk_size)
stream_id = str(uuid.uuid4())
print(f"\n开始上传文件: {file_path.name}")
print(f"流ID: {stream_id}")
# 一次性发送所有分片
total_chunks = len(chunks)
for chunk_index, chunk_data in enumerate(chunks):
# 将分片数据编码为 base64
chunk_base64 = base64.b64encode(chunk_data).decode('utf-8')
# 构建参数
params = {
"stream_id": stream_id,
"chunk_data": chunk_base64,
"chunk_index": chunk_index,
"total_chunks": total_chunks,
"file_size": total_size,
"expected_sha256": sha256_hash,
"filename": file_path.name,
"file_retention": 30 * 1000
}
# 发送分片
response = await self.send_action("upload_file_stream", params)
if response.get("status") != "ok":
raise Exception(f"上传分片 {chunk_index} 失败: {response}")
# 解析流响应
stream_data = response.get("data", {})
print(f"分片 {chunk_index + 1}/{total_chunks} 上传成功 "
f"(接收: {stream_data.get('received_chunks', 0)}/{stream_data.get('total_chunks', 0)})")
# 发送完成信号
print(f"\n所有分片发送完成,请求文件合并...")
complete_params = {
"stream_id": stream_id,
"is_complete": True
}
response = await self.send_action("upload_file_stream", complete_params)
if response.get("status") != "ok":
raise Exception(f"文件合并失败: {response}")
result = response.get("data", {})
if result.get("status") == "file_complete":
print(f"✅ 文件上传成功!")
print(f" - 文件路径: {result.get('file_path')}")
print(f" - 文件大小: {result.get('file_size')} 字节")
print(f" - SHA256: {result.get('sha256')}")
return result.get('file_path')
else:
raise Exception(f"文件状态异常: {result}")
async def test_upload(self, file_path: str, chunk_size: int = 64 ):
"""测试文件上传"""
try:
await self.connect()
# 执行上传
uploaded_path = await self.upload_file_stream_batch(file_path, chunk_size)
print(f"\n🎉 测试完成! 上传后的文件路径: {uploaded_path}")
except Exception as e:
print(f"❌ 测试失败: {e}")
raise
finally:
await self.disconnect()
def create_test_file(file_path: str, size_mb: float = 1):
"""创建测试文件"""
size_bytes = int(size_mb * 1024 * 1024)
with open(file_path, 'wb') as f:
# 写入一些有意义的测试数据
test_data = b"NapCat Upload Test Data - " * 100
written = 0
while written < size_bytes:
write_size = min(len(test_data), size_bytes - written)
f.write(test_data[:write_size])
written += write_size
print(f"创建测试文件: {file_path} ({size_mb}MB)")
async def main():
parser = argparse.ArgumentParser(description="NapCat OneBot 文件流上传测试")
parser.add_argument("--url", default="ws://localhost:3001", help="WebSocket URL")
parser.add_argument("--token", help="访问令牌")
parser.add_argument("--file", help="要上传的文件路径")
parser.add_argument("--chunk-size", type=int, default=64*1024, help="分片大小(字节)")
parser.add_argument("--create-test", type=float, help="创建测试文件(MB)")
args = parser.parse_args()
# 创建测试文件
if args.create_test:
test_file = "test_upload_file.bin"
create_test_file(test_file, args.create_test)
if not args.file:
args.file = test_file
if not args.file:
print("请指定要上传的文件路径,或使用 --create-test 创建测试文件")
return
# 创建测试器并运行
tester = OneBotUploadTester(args.url, args.token)
await tester.test_upload(args.file, args.chunk_size)
if __name__ == "__main__":
# 安装依赖提示
try:
import websockets
except ImportError:
print("请先安装依赖: pip install websockets")
exit(1)
asyncio.run(main())