mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2024-11-21 09:36:35 +00:00
477 lines
19 KiB
TypeScript
477 lines
19 KiB
TypeScript
import {
|
||
ChatType,
|
||
ElementType,
|
||
IMAGE_HTTP_HOST,
|
||
IMAGE_HTTP_HOST_NT,
|
||
Peer,
|
||
PicElement,
|
||
PicType,
|
||
RawMessage,
|
||
SendFileElement,
|
||
SendPicElement,
|
||
SendPttElement,
|
||
SendVideoElement,
|
||
} from '@/core/entities';
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
import fsPromises from 'fs/promises';
|
||
import { InstanceContext, NapCatCore, SearchResultItem } from '@/core';
|
||
import * as fileType from 'file-type';
|
||
import imageSize from 'image-size';
|
||
import { ISizeCalculationResult } from 'image-size/dist/types/interface';
|
||
import { RkeyManager } from '../helper/rkey';
|
||
import { calculateFileMD5, isGIF } from '@/common/file';
|
||
import pathLib from 'node:path';
|
||
import { defaultVideoThumbB64, getVideoInfo } from '@/common/video';
|
||
import ffmpeg from 'fluent-ffmpeg';
|
||
import { encodeSilk } from '@/common/audio';
|
||
import { MessageContext } from '@/onebot/api';
|
||
|
||
export class NTQQFileApi {
|
||
context: InstanceContext;
|
||
core: NapCatCore;
|
||
rkeyManager: RkeyManager;
|
||
packetRkey: Array<{ rkey: string; time: number; type: number; ttl: bigint }> | undefined;
|
||
|
||
constructor(context: InstanceContext, core: NapCatCore) {
|
||
this.context = context;
|
||
this.core = core;
|
||
this.rkeyManager = new RkeyManager(['https://llob.linyuchen.net/rkey', 'http://napcat-sign.wumiao.wang:2082/rkey'], this.context.logger);
|
||
}
|
||
|
||
async copyFile(filePath: string, destPath: string) {
|
||
await this.core.util.copyFile(filePath, destPath);
|
||
}
|
||
|
||
async getFileSize(filePath: string): Promise<number> {
|
||
return await this.core.util.getFileSize(filePath);
|
||
}
|
||
|
||
async getVideoUrl(peer: Peer, msgId: string, elementId: string) {
|
||
return (await this.context.session.getRichMediaService().getVideoPlayUrlV2(peer, msgId, elementId, 0, {
|
||
downSourceType: 1,
|
||
triggerType: 1,
|
||
})).urlResult.domainUrl;
|
||
}
|
||
|
||
async uploadFile(filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0) {
|
||
const fileMd5 = await calculateFileMD5(filePath);
|
||
const extOrEmpty = (await fileType.fileTypeFromFile(filePath))?.ext;
|
||
const ext = extOrEmpty ? `.${extOrEmpty}` : '';
|
||
let fileName = `${path.basename(filePath)}`;
|
||
if (fileName.indexOf('.') === -1) {
|
||
fileName += ext;
|
||
}
|
||
|
||
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
|
||
md5HexStr: fileMd5,
|
||
fileName: fileName,
|
||
elementType: elementType,
|
||
elementSubType,
|
||
thumbSize: 0,
|
||
needCreate: true,
|
||
downloadType: 1,
|
||
file_uuid: '',
|
||
});
|
||
|
||
await this.copyFile(filePath, mediaPath);
|
||
const fileSize = await this.getFileSize(filePath);
|
||
return {
|
||
md5: fileMd5,
|
||
fileName,
|
||
path: mediaPath,
|
||
fileSize,
|
||
ext,
|
||
};
|
||
}
|
||
|
||
async createValidSendFileElement(context: MessageContext, filePath: string, fileName: string = '', folderId: string = '',): Promise<SendFileElement> {
|
||
const {
|
||
fileName: _fileName,
|
||
path,
|
||
fileSize,
|
||
} = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE);
|
||
if (fileSize === 0) {
|
||
throw new Error('文件异常,大小为0');
|
||
}
|
||
context.deleteAfterSentFiles.push(path);
|
||
return {
|
||
elementType: ElementType.FILE,
|
||
elementId: '',
|
||
fileElement: {
|
||
fileName: fileName || _fileName,
|
||
folderId: folderId,
|
||
filePath: path,
|
||
fileSize: (fileSize).toString(),
|
||
},
|
||
};
|
||
}
|
||
|
||
async createValidSendPicElement(context: MessageContext, picPath: string, summary: string = '', subType: 0 | 1 = 0,): 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 this.core.apis.FileApi.getImageSize(picPath);
|
||
context.deleteAfterSentFiles.push(path);
|
||
return {
|
||
elementType: ElementType.PIC,
|
||
elementId: '',
|
||
picElement: {
|
||
md5HexStr: md5,
|
||
fileSize: fileSize.toString(),
|
||
picWidth: imageSize.width,
|
||
picHeight: imageSize.height,
|
||
fileName: fileName,
|
||
sourcePath: path,
|
||
original: true,
|
||
picType: isGIF(picPath) ? PicType.gif : PicType.jpg,
|
||
picSubType: subType,
|
||
fileUuid: '',
|
||
fileSubId: '',
|
||
thumbFileSize: 0,
|
||
summary,
|
||
} as PicElement,
|
||
};
|
||
}
|
||
|
||
async createValidSendVideoElement(context: MessageContext, filePath: string, fileName: string = '', diyThumbPath: string = ''): Promise<SendVideoElement> {
|
||
const logger = this.core.context.logger;
|
||
let videoInfo = {
|
||
width: 1920, height: 1080,
|
||
time: 15,
|
||
format: 'mp4',
|
||
size: 0,
|
||
filePath,
|
||
};
|
||
try {
|
||
videoInfo = await getVideoInfo(filePath, logger);
|
||
} catch (e) {
|
||
logger.logError.bind(logger)('获取视频信息失败,将使用默认值', e);
|
||
}
|
||
|
||
let fileExt = 'mp4';
|
||
try {
|
||
const tempExt = (await fileType.fileTypeFromFile(filePath))?.ext;
|
||
if (tempExt) fileExt = tempExt;
|
||
} catch (e) {
|
||
this.context.logger.logError.bind(logger)('获取文件类型失败', e);
|
||
}
|
||
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);
|
||
if (fileSize === 0) {
|
||
throw new Error('文件异常,大小为0');
|
||
}
|
||
videoInfo.size = fileSize;
|
||
let thumb = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
|
||
thumb = pathLib.dirname(thumb);
|
||
|
||
const thumbPath = new Map();
|
||
const _thumbPath = await new Promise<string | undefined>((resolve, reject) => {
|
||
const thumbFileName = `${md5}_0.png`;
|
||
const thumbPath = pathLib.join(thumb, thumbFileName);
|
||
ffmpeg(filePath)
|
||
.on('error', (err) => {
|
||
try {
|
||
logger.logDebug('获取视频封面失败,使用默认封面', err);
|
||
if (diyThumbPath) {
|
||
fsPromises.copyFile(diyThumbPath, thumbPath).then(() => {
|
||
resolve(thumbPath);
|
||
}).catch(reject);
|
||
} else {
|
||
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
|
||
resolve(thumbPath);
|
||
}
|
||
} catch (error) {
|
||
logger.logError.bind(logger)('获取视频封面失败,使用默认封面失败', error);
|
||
}
|
||
})
|
||
.screenshots({
|
||
timestamps: [0],
|
||
filename: thumbFileName,
|
||
folder: thumb,
|
||
size: videoInfo.width + 'x' + videoInfo.height,
|
||
})
|
||
.on('end', () => {
|
||
resolve(thumbPath);
|
||
});
|
||
});
|
||
const thumbSize = _thumbPath ? (await fsPromises.stat(_thumbPath)).size : 0;
|
||
thumbPath.set(0, _thumbPath);
|
||
const thumbMd5 = _thumbPath ? await calculateFileMD5(_thumbPath) : '';
|
||
context.deleteAfterSentFiles.push(path);
|
||
const uploadName = (fileName || _fileName).toLocaleLowerCase().endsWith('.' + fileExt.toLocaleLowerCase()) ? (fileName || _fileName) : (fileName || _fileName) + '.' + fileExt;
|
||
return {
|
||
elementType: ElementType.VIDEO,
|
||
elementId: '',
|
||
videoElement: {
|
||
fileName: uploadName,
|
||
filePath: path,
|
||
videoMd5: md5,
|
||
thumbMd5,
|
||
fileTime: videoInfo.time,
|
||
thumbPath: thumbPath,
|
||
thumbSize,
|
||
thumbWidth: videoInfo.width,
|
||
thumbHeight: videoInfo.height,
|
||
fileSize: '' + fileSize,
|
||
},
|
||
};
|
||
}
|
||
|
||
async createValidSendPttElement(pttPath: string): Promise<SendPttElement> {
|
||
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.context.logger.logError.bind(this.context.logger)('删除临时文件失败', e)
|
||
);
|
||
}
|
||
return {
|
||
elementType: ElementType.PTT,
|
||
elementId: '',
|
||
pttElement: {
|
||
fileName: 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,
|
||
},
|
||
};
|
||
}
|
||
|
||
async downloadFileForModelId(peer: Peer, modelId: string, unknown: string, timeout = 1000 * 60 * 2) {
|
||
const [, fileTransNotifyInfo] = await this.core.eventWrapper.callNormalEventV2(
|
||
'NodeIKernelRichMediaService/downloadFileForModelId',
|
||
'NodeIKernelMsgListener/onRichMediaDownloadComplete',
|
||
[peer, [modelId], unknown],
|
||
() => true,
|
||
(arg) => arg?.commonFileInfo?.fileModelId === modelId,
|
||
1,
|
||
timeout,
|
||
);
|
||
return fileTransNotifyInfo.filePath;
|
||
}
|
||
|
||
async downloadRawMsgMedia(msg: RawMessage[]) {
|
||
const res = await Promise.all(
|
||
msg.map(m =>
|
||
Promise.all(
|
||
m.elements
|
||
.filter(element =>
|
||
element.elementType === ElementType.PIC ||
|
||
element.elementType === ElementType.VIDEO ||
|
||
element.elementType === ElementType.PTT ||
|
||
element.elementType === ElementType.FILE
|
||
)
|
||
.map(element =>
|
||
this.downloadMedia(m.msgId, m.chatType, m.peerUid, element.elementId, '', '', 1000 * 60 * 2, true)
|
||
)
|
||
)
|
||
)
|
||
);
|
||
msg.forEach((m, msgIndex) => {
|
||
const elementResults = res[msgIndex];
|
||
let elementIndex = 0;
|
||
m.elements.forEach(element => {
|
||
if (
|
||
element.elementType === ElementType.PIC ||
|
||
element.elementType === ElementType.VIDEO ||
|
||
element.elementType === ElementType.PTT ||
|
||
element.elementType === ElementType.FILE
|
||
) {
|
||
switch (element.elementType) {
|
||
case ElementType.PIC:
|
||
element.picElement!.sourcePath = elementResults[elementIndex];
|
||
break;
|
||
case ElementType.VIDEO:
|
||
element.videoElement!.filePath = elementResults[elementIndex];
|
||
break;
|
||
case ElementType.PTT:
|
||
element.pttElement!.filePath = elementResults[elementIndex];
|
||
break;
|
||
case ElementType.FILE:
|
||
element.fileElement!.filePath = elementResults[elementIndex];
|
||
break;
|
||
}
|
||
elementIndex++;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async downloadMedia(msgId: string, chatType: ChatType, peerUid: string, elementId: string, thumbPath: string, sourcePath: string, timeout = 1000 * 60 * 2, force: boolean = false) {
|
||
// 用于下载收到的消息中的图片等
|
||
if (sourcePath && fs.existsSync(sourcePath)) {
|
||
if (force) {
|
||
try {
|
||
await fsPromises.unlink(sourcePath);
|
||
} catch (e) {
|
||
//
|
||
}
|
||
} else {
|
||
return sourcePath;
|
||
}
|
||
}
|
||
const [, completeRetData] = await this.core.eventWrapper.callNormalEventV2(
|
||
'NodeIKernelMsgService/downloadRichMedia',
|
||
'NodeIKernelMsgListener/onRichMediaDownloadComplete',
|
||
[{
|
||
fileModelId: '0',
|
||
downloadSourceType: 0,
|
||
triggerType: 1,
|
||
msgId: msgId,
|
||
chatType: chatType,
|
||
peerUid: peerUid,
|
||
elementId: elementId,
|
||
thumbSize: 0,
|
||
downloadType: 1,
|
||
filePath: thumbPath,
|
||
}],
|
||
() => true,
|
||
(arg) => arg.msgElementId === elementId && arg.msgId === msgId,
|
||
1,
|
||
timeout,
|
||
);
|
||
return completeRetData.filePath;
|
||
}
|
||
|
||
async getImageSize(filePath: string): Promise<ISizeCalculationResult> {
|
||
return new Promise((resolve, reject) => {
|
||
imageSize(filePath, (err: Error | null, dimensions) => {
|
||
if (err) {
|
||
reject(new Error(err.message));
|
||
} else if (!dimensions) {
|
||
reject(new Error('获取图片尺寸失败'));
|
||
} else {
|
||
resolve(dimensions);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async searchForFile(keys: string[]): Promise<SearchResultItem | undefined> {
|
||
const randomResultId = 100000 + Math.floor(Math.random() * 10000);
|
||
let searchId = 0;
|
||
const [, searchResult] = await this.core.eventWrapper.callNormalEventV2(
|
||
'NodeIKernelFileAssistantService/searchFile',
|
||
'NodeIKernelFileAssistantListener/onFileSearch',
|
||
[
|
||
keys,
|
||
{ resultType: 2, pageLimit: 1 },
|
||
randomResultId,
|
||
],
|
||
(ret) => {
|
||
searchId = ret;
|
||
return true;
|
||
},
|
||
result => result.searchId === searchId && result.resultId === randomResultId,
|
||
);
|
||
return searchResult.resultItems[0];
|
||
}
|
||
|
||
async downloadFileById(
|
||
fileId: string,
|
||
fileSize: number = 1024576,
|
||
estimatedTime: number = (fileSize * 1000 / 1024576) + 5000,
|
||
) {
|
||
const [, fileData] = await this.core.eventWrapper.callNormalEventV2(
|
||
'NodeIKernelFileAssistantService/downloadFile',
|
||
'NodeIKernelFileAssistantListener/onFileStatusChanged',
|
||
[[fileId]],
|
||
ret => ret.result === 0,
|
||
status => status.fileStatus === 2 && status.fileProgress === '0',
|
||
1,
|
||
estimatedTime, // estimate 1MB/s
|
||
);
|
||
return fileData.filePath!;
|
||
}
|
||
|
||
async getImageUrl(element: PicElement) {
|
||
if (!element) {
|
||
return '';
|
||
}
|
||
const url: string = element.originImageUrl ?? '';
|
||
const md5HexStr = element.md5HexStr;
|
||
const fileMd5 = element.md5HexStr;
|
||
|
||
if (url) {
|
||
const parsedUrl = new URL(IMAGE_HTTP_HOST + url);
|
||
const urlRkey = parsedUrl.searchParams.get('rkey');
|
||
const imageAppid = parsedUrl.searchParams.get('appid');
|
||
const isNTV2 = imageAppid && ['1406', '1407'].includes(imageAppid);
|
||
const imageFileId = parsedUrl.searchParams.get('fileid');
|
||
|
||
const rkeyData = {
|
||
private_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qEc3Rbib9LP4',
|
||
group_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qffcqm614gds',
|
||
online_rkey: false
|
||
};
|
||
try {
|
||
if (this.core.apis.PacketApi.available) {
|
||
const rkey_expired_private = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
|
||
const rkey_expired_group = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
|
||
if (rkey_expired_private || rkey_expired_group) {
|
||
this.packetRkey = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
|
||
}
|
||
if (this.packetRkey && this.packetRkey.length > 0) {
|
||
rkeyData.group_rkey = this.packetRkey[1].rkey.slice(6);
|
||
rkeyData.private_rkey = this.packetRkey[0].rkey.slice(6);
|
||
rkeyData.online_rkey = true;
|
||
}
|
||
}
|
||
} catch (error: any) {
|
||
this.context.logger.logError.bind(this.context.logger)('获取rkey失败', error.message);
|
||
}
|
||
|
||
if (!rkeyData.online_rkey) {
|
||
try {
|
||
const tempRkeyData = await this.rkeyManager.getRkey();
|
||
rkeyData.group_rkey = tempRkeyData.group_rkey;
|
||
rkeyData.private_rkey = tempRkeyData.private_rkey;
|
||
rkeyData.online_rkey = tempRkeyData.expired_time > Date.now() / 1000;
|
||
} catch (e) {
|
||
this.context.logger.logError.bind(this.context.logger)('获取rkey失败 Fallback Old Mode', e);
|
||
}
|
||
}
|
||
if (isNTV2 && urlRkey) {
|
||
return IMAGE_HTTP_HOST_NT + urlRkey;
|
||
} else if (isNTV2 && rkeyData.online_rkey) {
|
||
const rkey = imageAppid === '1406' ? rkeyData.private_rkey : rkeyData.group_rkey;
|
||
return IMAGE_HTTP_HOST_NT + url + `&rkey=${rkey}`;
|
||
} else if (isNTV2 && imageFileId) {
|
||
const rkey = imageAppid === '1406' ? rkeyData.private_rkey : rkeyData.group_rkey;
|
||
return IMAGE_HTTP_HOST + `/download?appid=${imageAppid}&fileid=${imageFileId}&rkey=${rkey}`;
|
||
}
|
||
|
||
}
|
||
//到这里说明可能是旧客户端
|
||
if (fileMd5 || md5HexStr) {
|
||
return `${IMAGE_HTTP_HOST}/gchatpic_new/0/0-0-${(fileMd5 ?? md5HexStr)!.toUpperCase()}/0`;
|
||
}
|
||
|
||
this.context.logger.logDebug('图片url获取失败', element);
|
||
return '';
|
||
}
|
||
}
|
||
|