feat: Add image and record stream download actions

Introduces BaseDownloadStream as a shared base class for streaming file downloads. Adds DownloadFileImageStream and DownloadFileRecordStream for image and audio file streaming with support for format conversion. Refactors DownloadFileStream to use the new base class, and updates action registration and router to include the new actions.
This commit is contained in:
手瓜一十雪 2025-10-12 15:50:34 +08:00
parent 2afdb2a0da
commit 381d320967
6 changed files with 268 additions and 87 deletions

View File

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

View File

@ -17,6 +17,8 @@ export const ActionName = {
TestDownloadStream: 'test_download_stream',
UploadFileStream: 'upload_file_stream',
DownloadFileStream: 'download_file_stream',
DownloadFileRecordStream: 'download_file_record_stream',
DownloadFileImageStream: 'download_file_image_stream',
DelGroupAlbumMedia: 'del_group_album_media',
SetGroupAlbumMediaLike: 'set_group_album_media_like',

View File

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

View File

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

View File

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

View File

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