import fs from 'fs' import { createHash, randomUUID } from 'crypto' import { resolve, join, dirname, basename } from 'path' import fetch, { FormData, Blob } from 'node-fetch' import { fileURLToPath } from 'url' import { exec, spawn } from 'child_process' import os from 'os' import _ from 'lodash' import { Stream } from "stream" import YAML from 'yaml' import { TMP_DIR } from '../tool.js' const user = os.userInfo().username let redPath = `C:/Users/${user}/.chronocat` if (!fs.existsSync(redPath)) { redPath = `C:/Users/${user}/AppData/Roaming/BetterUniverse/QQNT` } const roleMap = { 2: 'member', 3: 'admin', 4: 'owner' } async function uploadImg(bot, msg) { const file = await upload(bot, msg, 'image/png') if (!file.imageInfo) throw "获取图片信息失败,请检查图片状态" return { elementType: 2, picElement: { md5HexStr: file.md5, fileSize: file.fileSize, picHeight: file.imageInfo.height, picWidth: file.imageInfo.width, fileName: basename(file.ntFilePath), sourcePath: file.ntFilePath, picType: file.imageInfo.type === 'gif' ? 2000 : 1000 } } } async function upload(bot, msg, contentType) { if (!msg) throw { noLog: true } let buffer if (msg instanceof Stream.Readable) { buffer = fs.readFileSync(msg.path) contentType = contentType.split('/')[0] + '/' + msg.path.substring(msg.path.lastIndexOf('.') + 1) } if (Buffer.isBuffer(msg)) { buffer = msg } else if (msg.match(/^base64:\/\//)) { buffer = Buffer.from(msg.replace(/^base64:\/\//, ""), 'base64') } else if (msg.startsWith('http')) { const img = await fetch(msg) const type = img.headers.get('content-type'); if (type) contentType = type const arrayBuffer = await img.arrayBuffer() buffer = Buffer.from(arrayBuffer) } else if (msg.startsWith('file://')) { buffer = fs.readFileSync(msg.replace(/file:\/{2,3}/, '')) contentType = contentType.split('/')[0] + '/' + msg.substring(msg.lastIndexOf('.') + 1) } else { buffer = fs.readFileSync(msg) contentType = contentType.split('/')[0] + '/' + msg.substring(msg.lastIndexOf('.') + 1) } const blob = new Blob([buffer], { type: contentType }) const formData = new FormData() formData.append('file', blob, 'ws-plugin.' + contentType.split('/')[1]) const file = await bot.sendApi('POST', 'upload', formData) if (file.error) { throw file.error } file.contentType = contentType return file } async function uploadAudio(file) { let buffer if (file.match(/^base64:\/\//)) { buffer = Buffer.from(file.replace(/^base64:\/\//, ""), 'base64') } else if (file.startsWith('http')) { const http = await fetch(file) const arrayBuffer = await http.arrayBuffer() buffer = Buffer.from(arrayBuffer) } else if (file.startsWith('file://')) { buffer = fs.readFileSync(file.replace(/file:\/{2,3}/, '')) } const head = buffer.subarray(0, 7).toString() let filePath let duration = 0 if (!head.includes('SILK')) { const tmpPath = await saveTmp(buffer) duration = await getDuration(tmpPath) const res = await audioTrans(tmpPath) filePath = res.silkFile buffer = fs.readFileSync(filePath) } else { filePath = await saveTmp(buffer) } const hash = createHash('md5') hash.update(buffer.toString('binary'), 'binary') const md5 = hash.digest('hex') return { elementType: 4, pttElement: { md5HexStr: md5, fileSize: buffer.length, fileName: md5 + '.amr', filePath: filePath, // waveAmplitudes: [36, 28, 68, 28, 84, 28], waveAmplitudes: [ 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99 ], duration: duration } } } function audioTrans(tmpPath, samplingRate = '24000') { return new Promise((resolve, reject) => { const pcmFile = join(TMP_DIR, randomUUID({ disableEntropyCache: true })) exec(`ffmpeg -y -i "${tmpPath}" -ar ${samplingRate} -ac 1 -f s16le "${pcmFile}"`, async () => { fs.unlink(tmpPath, () => { }) fs.access(pcmFile, fs.constants.F_OK, (err) => { if (err) { reject('音频转码失败, 请确保你的 ffmpeg 已正确安装') } }) const silkFile = join(TMP_DIR, randomUUID({ disableEntropyCache: true })) try { await pcmToSilk(pcmFile, silkFile, samplingRate) } catch (error) { reject('red发送语音暂不支持非win系统') } fs.unlink(pcmFile, () => { }) resolve({ silkFile }) }) }) } function pcmToSilk(input, output, samplingRate) { return new Promise((resolve, reject) => { const args = ['-i', input, '-s', samplingRate, '-o', output] const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const child = spawn(join(__dirname, './cli.exe'), args) child.on('exit', () => { fs.access(output, fs.constants.F_OK, (err) => { if (err) { reject('音频转码失败') } }) // fs.stat(output, (err, stats) => { // if (err) { // console.error(err); // return; // } // fs.truncate(output, stats.size - 1, err => { // if (err) { // console.error(err); // return; // } // }); // }); resolve() }) }) } function getDuration(file) { return new Promise((resolve, reject) => { exec(`ffmpeg -i ${file}`, function (err, stdout, stderr) { const outStr = stderr.toString() const regDuration = /Duration\: ([0-9\:\.]+),/ const rs = regDuration.exec(outStr) if (rs === null) { reject("获取音频时长失败, 请确保你的 ffmpeg 已正确安装") } else if (rs[1]) { const time = rs[1] const parts = time.split(":") const seconds = (+parts[0]) * 3600 + (+parts[1]) * 60 + (+parts[2]) const round = seconds.toString().split('.')[0] resolve(+ round) } }) }) } async function saveTmp(data, ext = null) { ext = ext ? '.' + ext : '' const filename = randomUUID({ disableEntropyCache: true }) + ext const tmpPath = resolve(TMP_DIR, filename) fs.writeFileSync(tmpPath, data) return tmpPath } async function getNtPath(bot) { let dataPath try { const buffer = fs.readFileSync('./plugins/ws-plugin/resources/common/cont/logo.png') const blob = new Blob([buffer], { type: 'image/png' }) const formData = new FormData() formData.append('file', blob, '1.png') const file = await bot.sendApi('POST', 'upload', formData) fs.unlinkSync(file.ntFilePath) const index = file.ntFilePath.indexOf('nt_data'); dataPath = file.ntFilePath.slice(0, index + 'nt_data'.length); } catch (error) { return null } return dataPath } async function uploadVideo(bot, file) { let type = 'mp4' if (file.match(/^base64:\/\//)) { const buffer = Buffer.from(file.replace(/^base64:\/\//, ""), 'base64') file = join(TMP_DIR, randomUUID({ disableEntropyCache: true }) + '.' + type) fs.writeFileSync(file, buffer) } else { file = file.replace(/file:\/{2,3}/, '') type = file.substring(file.lastIndexOf('.') + 1) const Temp = join(TMP_DIR, randomUUID({ disableEntropyCache: true }) + '.' + type) fs.copyFileSync(file, Temp) file = Temp } const ntPath = await getNtPath(bot) if (!ntPath) return const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; const date = `${year}-${month.toString().padStart(2, '0')}`; const video = await getVideoInfo(file) let oriPath = `${ntPath}/Video` if (!fs.existsSync(oriPath)) fs.mkdirSync(oriPath) oriPath = `${oriPath}/${date}` if (!fs.existsSync(oriPath)) fs.mkdirSync(oriPath) oriPath = `${oriPath}/Ori` if (!fs.existsSync(oriPath)) fs.mkdirSync(oriPath) oriPath = `${oriPath}/${video.videoMd5}.${type}` let thumbPath = `${ntPath}/Video/${date}/Thumb` if (!fs.existsSync(thumbPath)) fs.mkdirSync(thumbPath) thumbPath = `${thumbPath}/${video.videoMd5}_0.png` fs.copyFileSync(file, oriPath) fs.unlinkSync(file) const thumb = await getThumbInfo(oriPath, thumbPath) return { elementType: 5, videoElement: { filePath: oriPath, fileName: video.videoMd5 + '.' + type, videoMd5: video.videoMd5, thumbMd5: thumb.thumbMd5, fileTime: video.fileTime, thumbSize: thumb.thumbSize, fileSize: video.fileSize, thumbWidth: thumb.thumbWidth, thumbHeight: thumb.thumbHeight } } } async function getVideoInfo(file) { const fileTime = await getVideoTime(file) const videoMd5 = await getVideoMd5(file) const fileSize = fs.readFileSync(file).length return { fileTime, videoMd5, fileSize } } function getVideoMd5(file) { return new Promise((resolve, reject) => { const stream = fs.createReadStream(file); const hash = createHash('md5'); stream.on('data', chunk => { hash.update(chunk); }); stream.on('end', () => { const md5 = hash.digest('hex'); resolve(md5) }); }) } function getVideoTime(file) { return new Promise((resolve, reject) => { exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${file}"`, (error, stdout, stderr) => { if (error) { reject('获取视频长度失败, 请确保你的 ffmpeg 已正确安装') } const durationInSeconds = parseInt(stdout); resolve(durationInSeconds) }); }) } async function getThumbInfo(file, thumbPath) { const tempPath = join(TMP_DIR, randomUUID({ disableEntropyCache: true }) + '.jpg') const { thumbMd5, thumbSize } = await extractThumbnail(file, tempPath); const { thumbWidth, thumbHeight } = getImageSize(tempPath); fs.copyFileSync(tempPath, thumbPath) fs.unlinkSync(tempPath) return { thumbMd5, thumbWidth, thumbHeight, thumbSize }; } function extractThumbnail(inputFile, outputFile) { return new Promise((resolve, reject) => { exec(`ffmpeg -i "${inputFile}" -ss 00:00:00.000 -vframes 1 -vf "scale=iw/3:ih/3" "${outputFile}" `, async () => { fs.access(outputFile, fs.constants.F_OK, (err) => { if (err) { reject('获取视频封面失败, 请确保你的 ffmpeg 已正确安装') } }) const buffer = fs.readFileSync(outputFile); const hash = createHash('md5'); hash.update(buffer); resolve({ thumbMd5: hash.digest('hex'), thumbSize: buffer.length }) }) }) } function getImageSize(file) { const buffer = fs.readFileSync(file); const start = buffer.indexOf(Buffer.from([0xff, 0xc0])); const thumbHeight = buffer.readUInt16BE(start + 5); const thumbWidth = buffer.readUInt16BE(start + 7); return { thumbWidth, thumbHeight }; } async function uploadFile(file) { let buffer, name, path = process.cwd() + '/plugins/ws-plugin/Temp/' if (file.startsWith('http')) { const http = await fetch(file) const arrayBuffer = await http.arrayBuffer() buffer = Buffer.from(arrayBuffer) name = file.substring(file.lastIndexOf('/') + 1) path = path + name fs.writeFileSync(path, buffer); } else if (file.startsWith('file://')) { buffer = fs.readFileSync(file.replace(/file:\/{2,3}/, '')) name = file.substring(file.lastIndexOf('/') + 1) path = path + name fs.copyFileSync(file, path) } else if (Buffer.isBuffer(file)) { buffer = file name = 'buffer' path = path + name fs.writeFileSync(path, buffer); } else { buffer = fs.readFileSync(file) name = file.substring(file.lastIndexOf('/') + 1) path = path + name fs.copyFileSync(file, path) } const size = buffer.length const hash = createHash('md5'); hash.update(buffer); const md5 = hash.digest('hex') return { elementType: 3, fileElement: { fileMd5: md5, fileName: name, filePath: path, fileSize: size, } } } function getToken() { let tokenPath try { if (os.platform() === 'win32') { tokenPath = `${redPath}/config/chronocat.yml` if (fs.existsSync(tokenPath)) { const data = YAML.parse(fs.readFileSync(tokenPath, 'utf-8')) for (const i of data?.servers || []) { if (i.type === 'red') { return i.token } } logger.error('[ws-plugin] 请检查chronocat配置是否开启red服务') return false } else { tokenPath = `${redPath}/RED_PROTOCOL_TOKEN` return fs.readFileSync(tokenPath, 'utf-8') } } else { logger.error('[ws-plugin] 非Windows系统请自行获取Token') return false } } catch (error) { logger.error('[ws-plugin] QQNT自动获取Token失败,请检查是否已安装Chronocat并尝试手动获取') logger.error(error) return false } } export { uploadImg, uploadAudio, uploadVideo, uploadFile, getToken, getNtPath, roleMap, redPath }