From 222989f8f822217eb0cb7bf49ce4eeb41020adcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Sun, 12 Oct 2025 15:50:34 +0800 Subject: [PATCH] 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. --- src/onebot/action/index.ts | 4 + src/onebot/action/router.ts | 2 + .../action/stream/BaseDownloadStream.ts | 99 +++++++++++++++++++ .../action/stream/DownloadFileImageStream.ts | 60 +++++++++++ .../action/stream/DownloadFileRecordStream.ts | 96 ++++++++++++++++++ .../action/stream/DownloadFileStream.ts | 94 ++---------------- 6 files changed, 268 insertions(+), 87 deletions(-) create mode 100644 src/onebot/action/stream/BaseDownloadStream.ts create mode 100644 src/onebot/action/stream/DownloadFileImageStream.ts create mode 100644 src/onebot/action/stream/DownloadFileRecordStream.ts diff --git a/src/onebot/action/index.ts b/src/onebot/action/index.ts index f78ceed0..b77ada21 100644 --- a/src/onebot/action/index.ts +++ b/src/onebot/action/index.ts @@ -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), diff --git a/src/onebot/action/router.ts b/src/onebot/action/router.ts index bb6de99f..41872cf8 100644 --- a/src/onebot/action/router.ts +++ b/src/onebot/action/router.ts @@ -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', diff --git a/src/onebot/action/stream/BaseDownloadStream.ts b/src/onebot/action/stream/BaseDownloadStream.ts new file mode 100644 index 00000000..7a7379ea --- /dev/null +++ b/src/onebot/action/stream/BaseDownloadStream.ts @@ -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 extends OneBotAction> { + protected async resolveDownload(file?: string): Promise { + 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); + chunkIndex++; + } + return { totalChunks: chunkIndex, totalBytes: bytesRead }; + } +} diff --git a/src/onebot/action/stream/DownloadFileImageStream.ts b/src/onebot/action/stream/DownloadFileImageStream.ts new file mode 100644 index 00000000..a80985fd --- /dev/null +++ b/src/onebot/action/stream/DownloadFileImageStream.ts @@ -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; + +export class DownloadFileImageStream extends BaseDownloadStream { + override actionName = ActionName.DownloadFileImageStream; + override payloadSchema = SchemaData; + override useStream = true; + + async _handle(payload: Payload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise> { + 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}`); + } + } +} diff --git a/src/onebot/action/stream/DownloadFileRecordStream.ts b/src/onebot/action/stream/DownloadFileRecordStream.ts new file mode 100644 index 00000000..e0fdec1e --- /dev/null +++ b/src/onebot/action/stream/DownloadFileRecordStream.ts @@ -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; + +import { DownloadResult } from './BaseDownloadStream'; + +export class DownloadFileRecordStream extends BaseDownloadStream { + override actionName = ActionName.DownloadFileRecordStream; + override payloadSchema = SchemaData; + override useStream = true; + + async _handle(payload: Payload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise> { + 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 { + 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; + } + } +} diff --git a/src/onebot/action/stream/DownloadFileStream.ts b/src/onebot/action/stream/DownloadFileStream.ts index 2c0095ef..7062aab3 100644 --- a/src/onebot/action/stream/DownloadFileStream.ts +++ b/src/onebot/action/stream/DownloadFileStream.ts @@ -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; -// 下载结果类型 -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> { +export class DownloadFileStream extends BaseDownloadStream { override actionName = ActionName.DownloadFileStream; override payloadSchema = SchemaData; override useStream = true; @@ -43,50 +22,12 @@ export class DownloadFileStream extends OneBotAction 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