// import Contactable, { core } from 'oicq' import querystring from 'querystring' import fetch, { File, fileFromSync, FormData } from 'node-fetch' import fs from 'fs' import os from 'os' import util from 'util' import stream from 'stream' import crypto from 'crypto' import child_process from 'child_process' import { Config } from './config.js' import path from 'path' import { mkdirs } from './common.js' let module try { module = await import('oicq') } catch (err) { try { module = await import('icqq') } catch (err1) { // 可能是go-cqhttp之类的 } } let pcm2slk, core, Contactable if (module) { core = module.core Contactable = module.default try { pcm2slk = (await import('node-silk')).pcm2slk } catch (e) { if (Config.cloudTranscode) { logger.warn('未安装node-silk,将尝试使用云转码服务进行合成') } else { Config.debug && logger.error(e) logger.warn('未安装node-silk,如ffmpeg不支持amr编码请安装node-silk以支持语音模式') } } } // import { pcm2slk } from 'node-silk' let errors = {} async function uploadRecord (recordUrl, ttsMode = 'vits-uma-genshin-honkai') { let recordType = 'url' let tmpFile = '' if (ttsMode === 'azure') { recordType = 'file' } else if (ttsMode === 'voicevox') { recordType = 'buffer' tmpFile = `data/chatgpt/tts/tmp/${crypto.randomUUID()}.wav` } let result if (pcm2slk) { result = await getPttBuffer(recordUrl, Bot.config.ffmpeg_path) } else if (Config.cloudTranscode) { logger.mark('使用云转码silk进行高清语音生成:"') try { if (recordType === 'buffer') { // save it as a file mkdirs('data/chatgpt/tts/tmp') fs.writeFileSync(tmpFile, recordUrl) recordType = 'file' recordUrl = tmpFile } if (recordType === 'file' || Config.cloudMode === 'file') { const formData = new FormData() let buffer if (!recordUrl.startsWith('http')) { // 本地文件 formData.append('file', fileFromSync(recordUrl)) } else { let response = await fetch(recordUrl, { method: 'GET', headers: { 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 12; MI 9 Build/SKQ1.211230.001)' } }) const blob = await response.blob() const arrayBuffer = await blob.arrayBuffer() buffer = Buffer.from(arrayBuffer) formData.append('file', new File([buffer], 'audio.wav')) } const resultres = await fetch(`${Config.cloudTranscode}/audio`, { method: 'POST', body: formData }) let t = await resultres.text() try { result = JSON.parse(t) } catch (e) { logger.error(t) throw e } } else { const resultres = await fetch(`${Config.cloudTranscode}/audio`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recordUrl }) }) let t = await resultres.text() try { result = JSON.parse(t) } catch (e) { logger.error(t) throw e } } if (result.error) { logger.error('云转码API报错:' + result.error) return false } result.buffer = Buffer.from(result.buffer.data) } catch (err) { logger.error('云转码API报错:' + err) return false } } else { return false } if (!result.buffer) { return false } let buf = Buffer.from(result.buffer) const hash = md5(buf) const codec = String(buf.slice(0, 7)).includes('SILK') ? 1 : 0 const body = core.pb.encode({ 1: 3, 2: 3, 5: { 1: Contactable.target, 2: Bot.uin, 3: 0, 4: hash, 5: buf.length, 6: hash, 7: 5, 8: 9, 9: 4, 11: 0, 10: Bot.apk.version, 12: 1, 13: 1, 14: 0, 15: 1 } }) const payload = await Bot.sendUni('PttStore.GroupPttUp', body) const rsp = core.pb.decode(payload)[5] rsp[2] && (0, errors.drop)(rsp[2], rsp[3]) const ip = rsp[5]?.[0] || rsp[5]; const port = rsp[6]?.[0] || rsp[6] const ukey = rsp[7].toHex(); const filekey = rsp[11].toHex() const params = { ver: 4679, ukey, filekey, filesize: buf.length, bmd5: hash.toString('hex'), mType: 'pttDu', voice_encodec: codec } const url = `http://${int32ip2str(ip)}:${port}/?` + querystring.stringify(params) const headers = { 'User-Agent': `QQ/${Bot.apk.version} CFNetwork/1126`, 'Net-Type': 'Wifi' } await fetch(url, { method: 'POST', // post请求 headers, body: buf }) // await axios.post(url, buf, { headers }); const fid = rsp[11].toBuffer() const b = core.pb.encode({ 1: 4, 2: Bot.uin, 3: fid, 4: hash, 5: hash.toString('hex') + '.amr', 6: buf.length, 11: 1, 18: fid, 30: Buffer.from([8, 0, 40, 0, 56, 0]) }) if (tmpFile) { try { fs.unlinkSync(tmpFile) } catch (err) { logger.warn('fail to delete temp audio file') } } return { type: 'record', file: 'protobuf://' + Buffer.from(b).toString('base64') } } export default uploadRecord async function getPttBuffer (file, ffmpeg = 'ffmpeg') { let buffer let time if (file instanceof Buffer || file.startsWith('base64://')) { // Buffer或base64 const buf = file instanceof Buffer ? file : Buffer.from(file.slice(9), 'base64') const head = buf.slice(0, 7).toString() if (head.includes('SILK') || head.includes('AMR')) { return buf } else { const tmpfile = TMP_DIR + '/' + (0, uuid)() await fs.promises.writeFile(tmpfile, buf) return audioTrans(tmpfile, ffmpeg) } } else if (file.startsWith('http://') || file.startsWith('https://')) { // 网络文件 // const readable = (await axios.get(file, { responseType: "stream" })).data; try { const headers = { 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 12; MI 9 Build/SKQ1.211230.001)' } let response = await fetch(file, { method: 'GET', // post请求 headers }) const buf = Buffer.from(await response.arrayBuffer()) const tmpfile = TMP_DIR + '/' + (0, uuid)() await fs.promises.writeFile(tmpfile, buf) // await (0, pipeline)(readable.pipe(new DownloadTransform), fs.createWriteStream(tmpfile)); const head = await read7Bytes(tmpfile) if (head.includes('SILK') || head.includes('AMR')) { fs.unlink(tmpfile, NOOP) buffer = buf } else { buffer = await audioTrans(tmpfile, ffmpeg) } } catch (err) {} } else { // 本地文件 file = String(file).replace(/^file:\/{2}/, '') IS_WIN && file.startsWith('/') && (file = file.slice(1)) const head = await read7Bytes(file) if (head.includes('SILK') || head.includes('AMR')) { buffer = await fs.promises.readFile(file) } else { buffer = await audioTrans(file, ffmpeg) } } return { buffer, time } } async function audioTrans (file, ffmpeg = 'ffmpeg') { const tmpfile = path.join(TMP_DIR, uuid()) const cmd = IS_WIN ? `${ffmpeg} -i "${file}" -f s16le -ac 1 -ar 24000 "${tmpfile}"` : `exec ${ffmpeg} -i "${file}" -f s16le -ac 1 -ar 24000 "${tmpfile}"` return new Promise((resolve, reject) => { // 隐藏windows下调用ffmpeg的cmd弹窗 const options = IS_WIN ? { windowsHide: true, stdio: 'ignore' } : {} child_process.exec(cmd, options, async (error, stdout, stderr) => { try { resolve(pcm2slk(fs.readFileSync(tmpfile))) } catch { reject(new core.ApiRejection(ErrorCode.FFmpegPttTransError, '音频转码到pcm失败,请确认你的ffmpeg可以处理此转换')) } finally { fs.unlink(tmpfile, NOOP) } }) }) } async function read7Bytes (file) { const fd = await fs.promises.open(file, 'r') const buf = (await fd.read(Buffer.alloc(7), 0, 7, 0)).buffer fd.close() return buf } function uuid () { let hex = crypto.randomBytes(16).toString('hex') return hex.substr(0, 8) + '-' + hex.substr(8, 4) + '-' + hex.substr(12, 4) + '-' + hex.substr(16, 4) + '-' + hex.substr(20) } function int32ip2str (ip) { if (typeof ip === 'string') { return ip } ip = ip & 0xffffffff return [ ip & 0xff, (ip & 0xff00) >> 8, (ip & 0xff0000) >> 16, (ip & 0xff000000) >> 24 & 0xff ].join('.') } const IS_WIN = os.platform() === 'win32' /** 系统临时目录,用于临时存放下载的图片等内容 */ const TMP_DIR = os.tmpdir() /** no operation */ const NOOP = () => { } (0, util.promisify)(stream.pipeline) /** md5 hash */ const md5 = (data) => (0, crypto.createHash)('md5').update(data).digest() errors.LoginErrorCode = errors.drop = errors.ErrorCode = void 0 let ErrorCode; (function (ErrorCode) { /** 客户端离线 */ ErrorCode[ErrorCode.ClientNotOnline = -1] = 'ClientNotOnline' /** 发包超时未收到服务器回应 */ ErrorCode[ErrorCode.PacketTimeout = -2] = 'PacketTimeout' /** 用户不存在 */ ErrorCode[ErrorCode.UserNotExists = -10] = 'UserNotExists' /** 群不存在(未加入) */ ErrorCode[ErrorCode.GroupNotJoined = -20] = 'GroupNotJoined' /** 群员不存在 */ ErrorCode[ErrorCode.MemberNotExists = -30] = 'MemberNotExists' /** 发消息时传入的参数不正确 */ ErrorCode[ErrorCode.MessageBuilderError = -60] = 'MessageBuilderError' /** 群消息被风控发送失败 */ ErrorCode[ErrorCode.RiskMessageError = -70] = 'RiskMessageError' /** 群消息有敏感词发送失败 */ ErrorCode[ErrorCode.SensitiveWordsError = -80] = 'SensitiveWordsError' /** 上传图片/文件/视频等数据超时 */ ErrorCode[ErrorCode.HighwayTimeout = -110] = 'HighwayTimeout' /** 上传图片/文件/视频等数据遇到网络错误 */ ErrorCode[ErrorCode.HighwayNetworkError = -120] = 'HighwayNetworkError' /** 没有上传通道 */ ErrorCode[ErrorCode.NoUploadChannel = -130] = 'NoUploadChannel' /** 不支持的file类型(没有流) */ ErrorCode[ErrorCode.HighwayFileTypeError = -140] = 'HighwayFileTypeError' /** 文件安全校验未通过不存在 */ ErrorCode[ErrorCode.UnsafeFile = -150] = 'UnsafeFile' /** 离线(私聊)文件不存在 */ ErrorCode[ErrorCode.OfflineFileNotExists = -160] = 'OfflineFileNotExists' /** 群文件不存在(无法转发) */ ErrorCode[ErrorCode.GroupFileNotExists = -170] = 'GroupFileNotExists' /** 获取视频中的图片失败 */ ErrorCode[ErrorCode.FFmpegVideoThumbError = -210] = 'FFmpegVideoThumbError' /** 音频转换失败 */ ErrorCode[ErrorCode.FFmpegPttTransError = -220] = 'FFmpegPttTransError' })(ErrorCode = errors.ErrorCode || (errors.ErrorCode = {})) const ErrorMessage = { [ErrorCode.UserNotExists]: '查无此人', [ErrorCode.GroupNotJoined]: '未加入的群', [ErrorCode.MemberNotExists]: '幽灵群员', [ErrorCode.RiskMessageError]: '群消息发送失败,可能被风控', [ErrorCode.SensitiveWordsError]: '群消息发送失败,请检查消息内容', 10: '消息过长', 34: '消息过长', 120: '在该群被禁言', 121: 'AT全体剩余次数不足' } function drop (code, message) { if (!message || !message.length) { message = ErrorMessage[code] } throw new core.ApiRejection(code, message) } errors.drop = drop /** 登录时可能出现的错误,不在列的都属于未知错误,暂时无法解决 */ let LoginErrorCode; (function (LoginErrorCode) { /** 密码错误 */ LoginErrorCode[LoginErrorCode.WrongPassword = 1] = 'WrongPassword' /** 账号被冻结 */ LoginErrorCode[LoginErrorCode.AccountFrozen = 40] = 'AccountFrozen' /** 发短信太频繁 */ LoginErrorCode[LoginErrorCode.TooManySms = 162] = 'TooManySms' /** 短信验证码错误 */ LoginErrorCode[LoginErrorCode.WrongSmsCode = 163] = 'WrongSmsCode' /** 滑块ticket错误 */ LoginErrorCode[LoginErrorCode.WrongTicket = 237] = 'WrongTicket' })(LoginErrorCode = errors.LoginErrorCode || (errors.LoginErrorCode = {}))