26 包 monorepo(TS 全源码): - snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包 - snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线 - snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI) - snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports) - snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时 - 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏) - 工具件: 解包器/启动器/便携制作器/doctor GitHub 关联已全量指向 DandanLLab/SnowCatQQ
229 lines
8.6 KiB
TypeScript
229 lines
8.6 KiB
TypeScript
import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index';
|
|
import { encodeSilk } from '@/snowcat-core/helper/audio';
|
|
import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg';
|
|
import { calculateFileMD5 } from 'snowcat-common/src/file';
|
|
import { ElementType, SnowCatCore, NTVideoType, PicElement, PicSubType, SendFileElement, SendPicElement, SendPttElement, SendVideoElement } from 'snowcat-core';
|
|
import { getFileTypeForSendType } from 'snowcat-core/helper/msg';
|
|
import { imageSizeFallBack } from 'snowcat-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 '@/snowcat-core/helper/ffmpeg/video';
|
|
|
|
const videoFormatByExtension: Partial<Record<string, NTVideoType>> = {
|
|
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: SnowCatCore;
|
|
constructor (obContext: NapCatOneBot11Adapter, core: SnowCatCore) {
|
|
this.obContext = obContext;
|
|
this.core = core;
|
|
}
|
|
|
|
async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = '', uploadGroupFile: boolean = false, _isLocal: boolean = false): Promise<SendFileElement> {
|
|
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<SendPicElement> {
|
|
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<SendVideoElement> {
|
|
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.SnowCatTempPath, `${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<SendPttElement> {
|
|
const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.SnowCatTempPath, 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,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
}
|