mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-12-19 05:05:44 +08:00
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:
parent
2afdb2a0da
commit
381d320967
@ -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),
|
||||
|
||||
@ -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',
|
||||
|
||||
99
src/onebot/action/stream/BaseDownloadStream.ts
Normal file
99
src/onebot/action/stream/BaseDownloadStream.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
60
src/onebot/action/stream/DownloadFileImageStream.ts
Normal file
60
src/onebot/action/stream/DownloadFileImageStream.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
src/onebot/action/stream/DownloadFileRecordStream.ts
Normal file
96
src/onebot/action/stream/DownloadFileRecordStream.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user