chatgpt-plugin/utils/uploadRecord.js
HalcyonAlcedo 709a1cebf0
优化和错误修复 (#527)
* 修复后台API反代地址未能正确显示的问题

* 更新渲染页面配置

* 添加个人聊天模式配置

* 将用户数据获取改到common中

* 修复错误的渲染页面参数

* 修复bug

* 添加Live2D

* 修复渲染页面错误

* 修复渲染传入值

* 更新渲染

* 修复图表渲染bug

* 调整live2d模型大小

* 修复live2d无法关闭问题

* 修复错误的传值

* 修复ai命名

* 更新渲染

* 添加用户独立设定

* 更新渲染配置适配个人设置

* 修复合并导致的渲染文件异常删除

* 修复用户数据缺失问题

* 修复旧版本数据缺失问题

* 修复bing参数不存在问题,兼容miao的截图

* 修复受限token重试时不被排除的问题

* 修复个人模式下结束对话的模式错误

* 更新渲染页面,将预览版转为正式版

* 修复传统渲染无法调用截图功能的问题

* 文字模式也进行一次缓存

* 更新README

* Update README.md

* 更新渲染

* 更新渲染页面

* 添加版本信息

* 遗漏参数

* 丢失引用

* 补充路由

* 添加云转码功能

* 判断node-silk是否正常合成

* 云转码提示

* 修复图片渲染出错

* 云转码支持发送Buffer

* 添加云转码模式支持

* 更新描述

* 更新后台渲染页面

* 更新配置

* 更新渲染页面

* 添加云渲染

* 修复错误的接口调用

* 修复遗漏的数据转换

* 修复获取的图片数据异常问题

* 更新后台配置

* 更新渲染页面

* 修复云渲染访问地址错误

* 更新渲染页面

* 修复遗漏的模型文件

* 修复live2d问题

* 更新live2d以及相关配置

* 修复遗漏的数据参数

* 修复新live2d情况下云渲染错误的问题

* 适配云渲染1.1.2等待参数

* 添加云服务api检查

* 更新渲染页面

* 添加live2d加载检测

* 修复错误的属性判断

* 添加云渲染DPR

* 更新sydney支持内容生成

* 修改文件模式语音转码接收模式

* 添加云转码时recordUrl检查

* 更新后台配置项

* 修复错误的文本描述

* 更新后台页面

* 添加全局对话模式设置,更新后台面板

* 添加第三方渲染服务适配

* 修复第三方服务器live2d加载导致的渲染失败问题

* 修复后台地址无法实时保存的问题

* 添加live2d模型透明度设置

* 合并更新

* 更新渲染页面

* 更新渲染页面

* 使dpr对本地渲染也生效

* 更新渲染页面

* 添加网页截图功能

* 添加后台配置项

* 添加配置导出和导入功能

* 运行通过参数传递用户token

* 登录时将token作为参数返回

* 修复错误

* 添加密码修改和用户删除接口

* 修正密码比对

* 修复user错误

* 优化数据保存时的返回值

* 添加系统额外服务检查api

* 添加AccessToken配置

* 修复错误的导入提示

* 添加ws连接

* 添加ws用户信息获取

* 修复错误的循环

* 修正ws参数

* 添加群消息获取权限

* 添加用户多端登录支持

* 修复错误的路径引用

* 修复错误的路径引用

* 修复错误

* 修复页面数据获取失败问题

* 修复异常的中断

* 添加配置视图

* 更新配置面板

* 添加用户版本信息

* 更新配置视图

* 修复错误的视图绑定

* 修改视图文件位置,添加mediaLink相关代码

* 修复错误的视图配置绑定

* 更新依赖,添加qq消息组件初始化信息获取

* 修复异常的群名称无法获取问题

* 修改注释

* 撤销对management的错误合并

* 添加Sydney图片识别功能

* 更新配置文件和后台页面

* 修改view配置

* 修复node版本过低导致的FormData无法调用,尝试添加反代

* 国外图片识别不使用反代

* fix: 修复云转码导致的语音重复发送问题

* fix: 修复qq消息可越权获取的问题

* feat: 添加代理post操作

* fix: 修复一些字符串格式的数字导致的配置加载错误

* fix: 修复错误的云服务api网址格式

* fix: 修复错误的云转码接口调用格式

* fix: 修复错误的精度

---------

Co-authored-by: ikechan8370 <geyinchibuaa@gmail.com>
2023-07-28 20:14:26 +08:00

375 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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') {
if (!recordUrl) {
logger.error('云转码错误recordUrl 异常')
return false
}
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 cloudUrl = new URL(Config.cloudTranscode)
const resultres = await fetch(`${cloudUrl}audio`, {
method: 'POST',
body: formData
})
let t = await resultres.arrayBuffer()
try {
result = {
buffer: {
data: t
}
}
} catch (e) {
logger.error(t)
throw e
}
} else {
const cloudUrl = new URL(Config.cloudTranscode)
const resultres = await fetch(`${cloudUrl}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
})
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://')) {
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 = {}))