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