import { NapCatOneBot11Adapter } from '@/napcat-onebot/index'; import { encodeSilk } from '@/napcat-core/helper/audio'; import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg'; import { calculateFileMD5 } from 'napcat-common/src/file'; import { ElementType, NapCatCore, NTVideoType, PicElement, PicSubType, SendFileElement, SendPicElement, SendPttElement, SendVideoElement } from 'napcat-core'; import { getFileTypeForSendType } from 'napcat-core/helper/msg'; import { imageSizeFallBack } from 'napcat-image-size'; import { SendMessageContext } from './msg'; import { fileTypeFromFile } from 'file-type'; import pathLib from 'node:path'; import fsPromises from 'fs/promises'; import fs from 'fs'; import { randomUUID } from 'crypto'; import { defaultVideoThumbB64 } from '@/napcat-core/helper/ffmpeg/video'; const videoFormatByExtension: Partial> = { afs: NTVideoType.VIDEO_FORMAT_AFS, avi: NTVideoType.VIDEO_FORMAT_AVI, mkv: NTVideoType.VIDEO_FORMAT_MKV, mod: NTVideoType.VIDEO_FORMAT_MOD, mov: NTVideoType.VIDEO_FORMAT_MOV, mp4: NTVideoType.VIDEO_FORMAT_MP4, mts: NTVideoType.VIDEO_FORMAT_MTS, rm: NTVideoType.VIDEO_FORMAT_RM, rmvb: NTVideoType.VIDEO_FORMAT_RMVB, ts: NTVideoType.VIDEO_FORMAT_TS, wmv: NTVideoType.VIDEO_FORMAT_WMV, }; export class OneBotFileApi { obContext: NapCatOneBot11Adapter; core: NapCatCore; constructor (obContext: NapCatOneBot11Adapter, core: NapCatCore) { this.obContext = obContext; this.core = core; } async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = '', uploadGroupFile: boolean = false, _isLocal: boolean = false): Promise { const { fileName: _fileName, path, fileSize, } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE, 0, uploadGroupFile); if (fileSize === 0) { throw new Error('文件异常,大小为0'); } if (uploadGroupFile) { context.deleteAfterSentFiles.push(path); } return { elementType: ElementType.FILE, elementId: '', fileElement: { fileName: fileName || _fileName, folderId, filePath: path, fileSize: fileSize.toString(), }, }; } async createValidSendPicElement (context: SendMessageContext, picPath: string, summary: string = '', subType: PicSubType = 0, _isLocal: boolean = false): Promise { const { md5, fileName, path, fileSize } = await this.core.apis.FileApi.uploadFile(picPath, ElementType.PIC, subType); if (fileSize === 0) { throw new Error('文件异常,大小为0'); } const imageSize = await imageSizeFallBack(picPath); context.deleteAfterSentFiles.push(path); return { elementType: ElementType.PIC, elementId: '', picElement: { md5HexStr: md5, fileSize: fileSize.toString(), picWidth: imageSize.width, picHeight: imageSize.height, fileName, sourcePath: path, original: true, picType: await getFileTypeForSendType(picPath), picSubType: subType, fileUuid: '', fileSubId: '', thumbFileSize: 0, summary, } as PicElement, }; } async createValidSendVideoElement (context: SendMessageContext, filePath: string, fileName: string = '', _diyThumbPath: string = '', isLocal: boolean = false): Promise { let videoInfo = { width: 1920, height: 1080, time: 15, format: 'mp4', size: 0, filePath, }; let fileExt = 'mp4'; try { const tempExt = (await fileTypeFromFile(filePath))?.ext; if (tempExt) fileExt = tempExt; } catch (e) { this.core.context.logger.logError('获取文件类型失败', e); } const currentExt = pathLib.extname(filePath).slice(1).toLowerCase(); const needsExtCopy = currentExt !== fileExt.toLowerCase(); if (needsExtCopy) { if (isLocal) { // 本地文件缺少正确扩展名:拷贝到临时目录,避免污染用户目录 const tempFilePath = pathLib.join(this.core.NapCatTempPath, `${randomUUID()}.${fileExt}`); fs.copyFileSync(filePath, tempFilePath); context.deleteAfterSentFiles.push(tempFilePath); filePath = tempFilePath; } else { // 临时文件缺少正确扩展名:在原位置加扩展名 const newFilePath = `${filePath}.${fileExt}`; fs.copyFileSync(filePath, newFilePath); context.deleteAfterSentFiles.push(newFilePath); filePath = newFilePath; } } // 扩展名已匹配时:本地文件直接用原始路径(零拷贝),临时文件也无需额外操作 const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO); context.deleteAfterSentFiles.push(path); if (fileSize === 0) { throw new Error('文件异常,大小为0'); } const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`); fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true }); const usePacketVideoMetadata = context.usePacketVideoMetadata === true; const thumbExtension = usePacketVideoMetadata ? 'jpg' : 'png'; const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.${thumbExtension}`); try { if (usePacketVideoMetadata) { videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath); } else { videoInfo = await FFmpegService.getVideoInfo(filePath, ''); await FFmpegService.extractLegacyVideoThumbnail(filePath, thumbPath); } if (!fs.existsSync(thumbPath)) { this.core.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在')); throw new Error('获取视频缩略图失败'); } } catch (e) { this.core.context.logger.logError('获取视频信息失败', e); fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64')); } if (_diyThumbPath) { try { await this.core.apis.FileApi.copyFile(_diyThumbPath, thumbPath); } catch (e) { this.core.context.logger.logError('复制自定义缩略图失败', e); } } context.deleteAfterSentFiles.push(thumbPath); const thumbSize = (await fsPromises.stat(thumbPath)).size; const thumbMd5 = await calculateFileMD5(thumbPath); const uploadName = (fileName || _fileName).toLocaleLowerCase().endsWith(`.${fileExt.toLocaleLowerCase()}`) ? (fileName || _fileName) : `${fileName || _fileName}.${fileExt}`; const videoMetadata = usePacketVideoMetadata ? { fileTime: Math.max(0, Math.round(videoInfo.time)), fileFormat: videoFormatByExtension[fileExt.toLowerCase()] ?? NTVideoType.VIDEO_FORMAT_MP4, fileWidth: videoInfo.width, fileHeight: videoInfo.height, } : { fileTime: videoInfo.time, }; return { elementType: ElementType.VIDEO, elementId: '', videoElement: { fileName: uploadName, filePath: path, videoMd5: md5, thumbMd5, ...videoMetadata, thumbPath: new Map([[0, thumbPath]]), thumbSize, thumbWidth: videoInfo.width, thumbHeight: videoInfo.height, fileSize: fileSize.toString(), }, }; } async createValidSendPttElement (_context: SendMessageContext, pttPath: string): Promise { const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.NapCatTempPath, this.core.context.logger); if (!silkPath) { throw new Error('语音转换失败, 请检查语音文件是否正常'); } const { md5, fileName, path, fileSize } = await this.core.apis.FileApi.uploadFile(silkPath, ElementType.PTT); if (fileSize === 0) { throw new Error('文件异常,大小为0'); } if (converted) { fsPromises.unlink(silkPath).then().catch((e) => this.core.context.logger.logError('删除临时文件失败', e)); } return { elementType: ElementType.PTT, elementId: '', pttElement: { fileName, filePath: path, md5HexStr: md5, fileSize: fileSize.toString(), duration: duration ?? 1, formatType: 1, voiceType: 1, voiceChangeType: 0, canConvert2Text: true, waveAmplitudes: [ 0, 18, 9, 23, 16, 17, 16, 15, 44, 17, 24, 20, 14, 15, 17, ], fileSubId: '', playState: 1, autoConvertText: 0, storeID: 0, otherBusinessInfo: { aiVoiceType: 0, }, }, }; } }