diff --git a/src/common/utils.ts b/src/common/utils.ts index c6a1c75..9268cf2 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -4,6 +4,7 @@ import {ConfigUtil} from "./config"; import util from "util"; import {encode, getDuration, isWav} from "silk-wasm"; import fs from 'fs'; +import * as crypto from 'crypto'; import {v4 as uuidv4} from "uuid"; import ffmpeg from "fluent-ffmpeg" @@ -234,9 +235,9 @@ export async function encodeSilk(filePath: string) { } else { const pcm = fs.readFileSync(filePath); let duration = 0; - try{ + try { duration = getDuration(pcm); - }catch (e) { + } catch (e) { log("获取语音文件时长失败", filePath, e.stack) duration = fs.statSync(filePath).size / 1024 / 3 // 每3kb大约1s duration = Math.floor(duration) @@ -256,6 +257,80 @@ export async function encodeSilk(filePath: string) { } } +export async function getVideoInfo(filePath: string) { + const size = fs.statSync(filePath).size; + return new Promise<{ width: number, height: number, time: number, format: string, size: number, filePath: string }>((resolve, reject) => { + ffmpeg.ffprobe(filePath, (err, metadata) => { + if (err) { + reject(err); + } else { + const videoStream = metadata.streams.find(s => s.codec_type === 'video'); + if (videoStream) { + console.log(`视频尺寸: ${videoStream.width}x${videoStream.height}`); + } else { + console.log('未找到视频流信息。'); + } + resolve({ + width: videoStream.width, height: videoStream.height, + time: parseInt(videoStream.duration), + format: metadata.format.format_name, + size, + filePath + }); + } + }); + }) +} + +export async function encodeMp4(filePath: string) { + let videoInfo = await getVideoInfo(filePath); + log("视频信息", videoInfo) + if (videoInfo.format.indexOf("mp4") === -1) { + log("视频需要转换为MP4格式", filePath) + // 转成mp4 + const newPath: string = await new Promise((resolve, reject) => { + const newPath = filePath + ".mp4" + ffmpeg(filePath) + .toFormat('mp4') + .on('error', (err) => { + reject(`转换视频格式失败: ${err.message}`); + }) + .on('end', () => { + log('视频转换为MP4格式完成'); + resolve(newPath); // 返回转换后的文件路径 + }) + .save(newPath); + }); + return await getVideoInfo(newPath) + } + return videoInfo +} + export function isNull(value: any) { return value === undefined || value === null; -} \ No newline at end of file +} + + +export function calculateFileMD5(filePath: string): Promise { + return new Promise((resolve, reject) => { + // 创建一个流式读取器 + const stream = fs.createReadStream(filePath); + const hash = crypto.createHash('md5'); + + stream.on('data', (data: Buffer) => { + // 当读取到数据时,更新哈希对象的状态 + hash.update(data); + }); + + stream.on('end', () => { + // 文件读取完成,计算哈希 + const md5 = hash.digest('hex'); + resolve(md5); + }); + + stream.on('error', (err: Error) => { + // 处理可能的读取错误 + reject(err); + }); + }); +} diff --git a/src/ntqqapi/constructor.ts b/src/ntqqapi/constructor.ts index 94b75a4..28ce031 100644 --- a/src/ntqqapi/constructor.ts +++ b/src/ntqqapi/constructor.ts @@ -1,16 +1,20 @@ import { AtType, - ElementType, PicType, SendArkElement, + ElementType, + PicType, + SendArkElement, SendFaceElement, SendFileElement, SendPicElement, SendPttElement, SendReplyElement, - SendTextElement + SendTextElement, + SendVideoElement } from "./types"; import {NTQQApi} from "./ntcall"; -import {encodeSilk, isGIF} from "../common/utils"; -import * as fs from "node:fs"; +import {calculateFileMD5, encodeSilk, getVideoInfo, isGIF, log, sleep} from "../common/utils"; +import {promises as fs} from "node:fs"; +import ffmpeg from "fluent-ffmpeg" export class SendMsgElementConstructor { @@ -57,7 +61,7 @@ export class SendMsgElementConstructor { static async pic(picPath: string): Promise { const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(picPath, ElementType.PIC); - if (fileSize === 0){ + if (fileSize === 0) { throw "文件异常,大小为0"; } const imageSize = await NTQQApi.getImageSize(picPath); @@ -84,15 +88,9 @@ export class SendMsgElementConstructor { }; } - static async file(filePath: string, showPreview: boolean = false, fileName: string = ""): Promise { - let picHeight = 0; - let picWidth = 0; - if (showPreview) { - picHeight = 1024; - picWidth = 768; - } + static async file(filePath: string, fileName: string = ""): Promise { const {md5, fileName: _fileName, path, fileSize} = await NTQQApi.uploadFile(filePath, ElementType.FILE); - if (fileSize === 0){ + if (fileSize === 0) { throw "文件异常,大小为0"; } let element: SendFileElement = { @@ -102,28 +100,89 @@ export class SendMsgElementConstructor { fileName: fileName || _fileName, "filePath": path, "fileSize": (fileSize).toString(), - picHeight, - picWidth } } return element; } - static video(filePath: string, fileName: string=""): Promise { - return SendMsgElementConstructor.file(filePath, true, fileName); + static async video(filePath: string, fileName: string = ""): Promise { + let {fileName: _fileName, path, fileSize, md5} = await NTQQApi.uploadFile(filePath, ElementType.VIDEO); + if (fileSize === 0) { + throw "文件异常,大小为0"; + } + // const videoInfo = await encodeMp4(path); + // path = videoInfo.filePath + // md5 = videoInfo.md5; + // fileSize = videoInfo.size; + // log("上传视频", md5, path, fileSize, fileName || _fileName) + const pathLib = require("path"); + let thumb = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`) + thumb = pathLib.dirname(thumb) + // log("thumb 目录", thumb) + const videoInfo = await getVideoInfo(path); + // log("视频信息", videoInfo) + const createThumb = new Promise((resolve, reject) => { + const thumbFileName = `${md5}_0.png` + ffmpeg(filePath) + .on("end", () => { + }) + .on("error", (err) => { + reject(err); + }) + .screenshots({ + timestamps: [0], + filename: thumbFileName, + folder: thumb, + size: videoInfo.width + "x" + videoInfo.height + }).on("end", () => { + resolve(pathLib.join(thumb, thumbFileName)); + }); + }) + let thumbPath = new Map() + const _thumbPath = await createThumb; + const thumbSize = (await fs.stat(_thumbPath)).size; + // log("生成缩略图", _thumbPath) + thumbPath.set(0, _thumbPath) + const thumbMd5 = await calculateFileMD5(_thumbPath); + let element: SendVideoElement = { + elementType: ElementType.VIDEO, + elementId: "", + videoElement: { + fileName: fileName || _fileName, + filePath: path, + videoMd5: md5, + thumbMd5, + fileTime: videoInfo.time, + thumbPath: thumbPath, + thumbSize, + thumbWidth: videoInfo.width, + thumbHeight: videoInfo.height, + fileSize: "" + fileSize, + // fileUuid: "", + // transferStatus: 0, + // progress: 0, + // invalidState: 0, + // fileSubId: "", + // fileBizId: null, + // originVideoMd5: "", + // fileFormat: 2, + // import_rich_media_context: null, + // sourceVideoCodecFormat: 2 + } + } + return element; } static async ptt(pttPath: string): Promise { const {converted, path: silkPath, duration} = await encodeSilk(pttPath); // log("生成语音", silkPath, duration); const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(silkPath, ElementType.PTT); - if (fileSize === 0){ + if (fileSize === 0) { throw "文件异常,大小为0"; } if (converted) { - fs.unlink(silkPath, () => { - }); + fs.unlink(silkPath).then(); } return { elementType: ElementType.PTT, diff --git a/src/ntqqapi/hook.ts b/src/ntqqapi/hook.ts index 860924f..ec5738f 100644 --- a/src/ntqqapi/hook.ts +++ b/src/ntqqapi/hook.ts @@ -28,6 +28,7 @@ export enum ReceiveCmd { FRIEND_REQUEST = "nodeIKernelBuddyListener/onBuddyReqChange", SELF_STATUS = 'nodeIKernelProfileListener/onSelfStatusChanged', CACHE_SCAN_FINISH = "nodeIKernelStorageCleanListener/onFinishScan", + MEDIA_UPLOAD_COMPLETE = "nodeIKernelMsgListener/onRichMediaUploadComplete", } interface NTQQApiReturnData extends Array { @@ -224,6 +225,10 @@ registerReceiveHook<{ msgList: Array }>(ReceiveCmd.NEW_MSG, (payload continue } for (const msgElement of message.elements) { + if (msgElement.videoElement) { + log("收到视频消息", msgElement.videoElement) + log("視頻缩略图", msgElement.videoElement.thumbPath.get(0)); + } setTimeout(() => { const picPath = msgElement.picElement?.sourcePath const pttPath = msgElement.pttElement?.filePath @@ -235,6 +240,7 @@ registerReceiveHook<{ msgList: Array }>(ReceiveCmd.NEW_MSG, (payload if (aioOpGrayTipElement){ tempGroupCodeMap[aioOpGrayTipElement.peerUid] = aioOpGrayTipElement.fromGrpCodeOfTmpChat; } + // log("需要清理的文件", pathList); for (const path of pathList) { if (path) { diff --git a/src/ntqqapi/types.ts b/src/ntqqapi/types.ts index 8d0b1ed..6b5e46f 100644 --- a/src/ntqqapi/types.ts +++ b/src/ntqqapi/types.ts @@ -71,6 +71,7 @@ export enum ElementType { PIC = 2, FILE = 3, PTT = 4, + VIDEO = 5, FACE = 6, REPLY = 7, ARK = 10, @@ -167,11 +168,16 @@ export interface FileElement { } export interface SendFileElement { - elementType: ElementType.FILE, + elementType: ElementType.FILE elementId: "", fileElement: FileElement } +export interface SendVideoElement { + elementType: ElementType.VIDEO + elementId: "", + videoElement: VideoElement +} export interface SendArkElement { elementType: ElementType.ARK, elementId: "", @@ -180,7 +186,7 @@ export interface SendArkElement { } export type SendMessageElement = SendTextElement | SendPttElement | - SendPicElement | SendReplyElement | SendFaceElement | SendFileElement | SendArkElement + SendPicElement | SendReplyElement | SendFaceElement | SendFileElement | SendVideoElement | SendArkElement export enum AtType { notAt = 0, @@ -258,26 +264,26 @@ export interface FaceElement { export interface VideoElement { "filePath": string, "fileName": string, - "videoMd5": string, - "thumbMd5": string - "fileTime": 87, // second - "thumbSize": 314235, // byte - "fileFormat": 2, // 2表示mp4? - "fileSize": string, // byte - "thumbWidth": number, - "thumbHeight": number, - "busiType": 0, // 未知 - "subBusiType": 0, // 未知 - "thumbPath": Map, - "transferStatus": 0, // 未知 - "progress": 0, // 下载进度? - "invalidState": 0, // 未知 - "fileUuid": string, // 可以用于下载链接? - "fileSubId": "", - "fileBizId": null, - "originVideoMd5": "", - "import_rich_media_context": null, - "sourceVideoCodecFormat": 0 + "videoMd5"?: string, + "thumbMd5"?: string + "fileTime"?: number, // second + "thumbSize"?: number, // byte + "fileFormat"?: number, // 2表示mp4? + "fileSize"?: string, // byte + "thumbWidth"?: number, + "thumbHeight"?: number, + "busiType"?: 0, // 未知 + "subBusiType"?: 0, // 未知 + "thumbPath"?: Map, + "transferStatus"?: 0, // 未知 + "progress"?: 0, // 下载进度? + "invalidState"?: 0, // 未知 + "fileUuid"?: string, // 可以用于下载链接? + "fileSubId"?: "", + "fileBizId"?: null, + "originVideoMd5"?: "", + "import_rich_media_context"?: null, + "sourceVideoCodecFormat"?: number } export interface TipAioOpGrayTipElement { // 这是什么提示来着? diff --git a/src/onebot11/action/BaseAction.ts b/src/onebot11/action/BaseAction.ts index 09b3752..05ab409 100644 --- a/src/onebot11/action/BaseAction.ts +++ b/src/onebot11/action/BaseAction.ts @@ -22,7 +22,7 @@ class BaseAction { return OB11Response.ok(resData); } catch (e) { log("发生错误", e) - return OB11Response.error(e.toString(), 200); + return OB11Response.error(e?.toString() || e?.stack?.toString() || "未知错误,可能操作超时", 200); } } @@ -36,7 +36,7 @@ class BaseAction { return OB11Response.ok(resData, echo); } catch (e) { log("发生错误", e) - return OB11Response.error(e.toString(), 1200, echo) + return OB11Response.error(e.stack?.toString() || e.toString(), 1200, echo) } } diff --git a/src/onebot11/action/SendMsg.ts b/src/onebot11/action/SendMsg.ts index 4828746..d58f0ac 100644 --- a/src/onebot11/action/SendMsg.ts +++ b/src/onebot11/action/SendMsg.ts @@ -157,14 +157,9 @@ export class SendMsg extends BaseAction { } // log("send msg:", peer, sendElements) const {sendElements, deleteAfterSentFiles} = await this.createSendElements(messages, group) - try { const returnMsg = await this.send(peer, sendElements, deleteAfterSentFiles) deleteAfterSentFiles.map(f => fs.unlink(f, () => {})); return {message_id: returnMsg.msgShortId} - } catch (e) { - log("发送消息失败", e.stack.toString()) - throw (e.toString()) - } } protected convertMessage2List(message: OB11MessageMixType) { @@ -372,7 +367,7 @@ export class SendMsg extends BaseAction { } if (sendMsg.type === OB11MessageDataType.file) { log("发送文件", path, payloadFileName || fileName) - sendElements.push(await SendMsgElementConstructor.file(path, false, payloadFileName || fileName)); + sendElements.push(await SendMsgElementConstructor.file(path, payloadFileName || fileName)); } else if (sendMsg.type === OB11MessageDataType.video) { log("发送视频", path, payloadFileName || fileName) sendElements.push(await SendMsgElementConstructor.video(path, payloadFileName || fileName));