chatgpt-plugin/utils/common.js
HalcyonAlcedo 458b04c666
新增管理面板,重写必应token管理,错误修复 (#340)
* 修复引用转发,默认bing模式并发

* 开启stream增加稳定性

* fix: remove queue element only in non-bing mode

* 使用chatgpt-api自带的超时逻辑,文字过多时启动切换到图片输出防止被吞

* Update chat.js

* 添加Bing专用的图片输出样式

* 添加chatgpt的新图片模式,临时处理切换api导致的对话异常

* 修改bing样式表

* 为图片添加外部页面缓存

* 为图片模式添加MathJax

* feat: add switch for qrcode

* 防止script攻击

* 修复网页模板错误

* 修复bing页面引用错误

* 缓存服务器异常时处理

* 添加默认配置加载

* 修复配置文件路径错误

* 删除重复的模板文件,修复二维码地址错误

* 修正图片渲染错误

* 修复引用渲染错误

* 二维码网址统一改为使用本地配置

* 添加关闭思考提示的配置项

* 修复在Windows上无法载入配置文件的问题

* 修复关闭qr的情况下渲染错误

* 改为使用base64传递返回数据

* 当异常过多时使用图片输出

* 添加锅巴面板配置支持

* 补充遗漏的默认配置

* 修复qr模式下引用未被传递的问题

* 修复未将引用数据传输给缓存服务器的问题

* 删除无用的bingTimeoutMs配置项

* 添加消息队列超时弹出

* 优化图片模式处理,解决对话队列卡住的问题

* 添加对图片ocr的支持

* 添加图片识别配置项

* 添加黑名单配置项

* 修复一些bug

* 修改锅巴配置格式和描述

* 传入数据也使用markdown

* 图片识别换行改为marked兼容

* 添加绘图CD配置项

* 独立render模块,添加图片回复引用

* 添加必应风格

* 修复上下文,修改bing样式

* 修复上下文

* 添加Sydney上下文支持

* 调整不同模式下的bing渲染颜色

* 修复样式

* 修复无法结束会话的问题

* fix: 更新版本号

* 修复无法结束对话的问题

* 向缓存服务器传送样式

* 为网址格式的配置添加验证

* 去除重复的Keyv删除,取消锅巴配置格式检查

* 闭合中断的代码块

* 试添加Sydney图片模式的情感显示

* 修复at不兼容

* 处理意外的markdown包裹和结构解析修复

* 修复markdown处理的顺序错误

* 兼容json换行

* 重写completeJSON和使用

* 修复换行格式异常

* 均衡BingToken使用

* 修复删除token的数组处理错误

* 修改token文字描述

* 创建本地缓存服务

* 修复首次使用无法添加bingtoken的问题

* 修复意外的删除格式问题,添加查看token功能

* 修复路由错误,暂时固定ip测试

* 恢复引用功能

* 更新渲染页面

* 更换缓存目录

* 清除调试用消息

* 调整屏幕分辨率

* 使用服务器生成的访问地址

* 改为使用api获取公网ip

* 修复引用显示

* 添加依赖需求

* 更新渲染页面和渲染api

* 修复渲染页面错误

* 修复建议字符串切割,添加帮助路由

* 添加内容中图片数据获取功能

* 试修复suggestbug

* 修复图片导致服务器卡死的问题

* 暂时禁用图片

* 尝试恢复图片

* 添加链接图片识别

* 替换掉request

* 修复可能的responseUrls空值

* 优化格式

* 更新渲染页面

* 尝试新的引用索引

* 取消渲染时旧的策略

* 更新帮助页面

* 修复帮助路由

* 修复渲染页面错误

* 修复错误的正则

* 修改系统api服务

* 添加配置项

* 将新渲染方式加入配置并还原原渲染方式,进行并存

* 暂时取消端口设置功能

* 重新开启端口设置

* 修复旧渲染引用

* 更新帮助样式

* 更新帮助,增强功能

* 有cacheHost的情况下不再附带端口号

* 添加渲染图片的宽度设置

* 添加渲染页面宽度调整,修bug

* 修复二维码不显示

* 添加第三方渲染支持

* 修复一些渲染页面问题

* 更新渲染页面

* 修正错误的变量调用

* 添加新渲染模式bot命名

* 修复空消息问题

* 撤销之前的修复,使用新方法修复

* 修复返回空页面问题

* 尝试不依赖网络获取外网地址

* 修bug,初步创建管理系统

* 依赖名写错了

* 修复错误的异步

* 修正错误的配置调用

* 放弃本机设置的获取方案,对服务器获取多半失效

* 添加配置页面接口

* 更新渲染页面

* 添加依赖

* 修复bug

* 移除windows性能显示,更换依赖

* 添加依赖

* 修复图片异常时不反回文字而是直接报错的问题

* 修改必应token记录和均衡方法,更新渲染页面

* 修复错误

* 修复bug,更新渲染页面

* 更新渲染

* 修复ip错误

* 完善配置页面

* 渲染页面错误修复

* 更新版本号

* 只获取一次有效ip

* 修复渲染页面bug

---------

Co-authored-by: ikechan8370 <geyinchibuaa@gmail.com>
Co-authored-by: Err0rCM <68117733+Err0rCM@users.noreply.github.com>
2023-04-12 16:35:41 +08:00

609 lines
No EOL
16 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 { remark } from 'remark'
// import stripMarkdown from 'strip-markdown'
import { exec } from 'child_process'
import lodash from 'lodash'
import fs from 'node:fs'
import path from 'node:path'
import buffer from 'buffer'
import puppeteer from '../../../lib/puppeteer/puppeteer.js'
import { Config } from './config.js'
// export function markdownToText (markdown) {
// return remark()
// .use(stripMarkdown)
// .processSync(markdown ?? '')
// .toString()
// }
let localIP = ''
export function escapeHtml (str) {
const htmlEntities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;'
}
return str.replace(/[&<>"'/]/g, (match) => htmlEntities[match])
}
export function randomString (length = 5) {
let str = ''
for (let i = 0; i < length; i++) {
str += lodash.random(36).toString(36)
}
return str.substr(0, length)
}
export async function upsertMessage (message) {
await redis.set(`CHATGPT:MESSAGE:${message.id}`, JSON.stringify(message))
}
export async function getMessageById (id) {
let messageStr = await redis.get(`CHATGPT:MESSAGE:${id}`)
return JSON.parse(messageStr)
}
export async function tryTimes (promiseFn, maxTries = 10) {
try {
return await promiseFn()
} catch (e) {
if (maxTries > 0) {
logger.warn('Failed, retry ' + maxTries)
return tryTimes(promiseFn, maxTries - 1)
}
throw e
}
}
export async function makeForwardMsg (e, msg = [], dec = '') {
let nickname = Bot.nickname
if (e.isGroup) {
let info = await Bot.getGroupMemberInfo(e.group_id, Bot.uin)
nickname = info.card || info.nickname
}
let userInfo = {
user_id: Bot.uin,
nickname
}
let forwardMsg = []
msg.forEach(v => {
forwardMsg.push({
...userInfo,
message: v
})
})
/** 制作转发内容 */
if (e.isGroup) {
forwardMsg = await e.group.makeForwardMsg(forwardMsg)
} else if (e.friend) {
forwardMsg = await e.friend.makeForwardMsg(forwardMsg)
} else {
return false
}
if (dec) {
/** 处理描述 */
forwardMsg.data = forwardMsg.data
.replace(/\n/g, '')
.replace(/<title color="#777777" size="26">(.+?)<\/title>/g, '___')
.replace(/___+/, `<title color="#777777" size="26">${dec}</title>`)
}
return forwardMsg
}
// @see https://github.com/sindresorhus/p-timeout
export async function pTimeout (
promise,
options
) {
const {
milliseconds,
fallback,
message,
customTimers = { setTimeout, clearTimeout }
} = options
let timer
const cancelablePromise = new Promise((resolve, reject) => {
if (typeof milliseconds !== 'number' || Math.sign(milliseconds) !== 1) {
throw new TypeError(
`Expected \`milliseconds\` to be a positive number, got \`${milliseconds}\``
)
}
if (milliseconds === Number.POSITIVE_INFINITY) {
resolve(promise)
return
}
if (options.signal) {
const { signal } = options
if (signal.aborted) {
reject(getAbortedReason(signal))
}
signal.addEventListener('abort', () => {
reject(getAbortedReason(signal))
})
}
timer = customTimers.setTimeout.call(
undefined,
() => {
if (fallback) {
try {
resolve(fallback())
} catch (error) {
reject(error)
}
return
}
const errorMessage =
typeof message === 'string'
? message
: `Promise timed out after ${milliseconds} milliseconds`
const timeoutError =
message instanceof Error ? message : new Error(errorMessage)
if (typeof promise.cancel === 'function') {
promise.cancel()
}
reject(timeoutError)
},
milliseconds
)
;(async () => {
try {
resolve(await promise)
} catch (error) {
reject(error)
} finally {
customTimers.clearTimeout.call(undefined, timer)
}
})()
})
cancelablePromise.clear = () => {
customTimers.clearTimeout.call(undefined, timer)
timer = undefined
}
return cancelablePromise
}
/**
TODO: Remove below function and just 'reject(signal.reason)' when targeting Node 18.
*/
function getAbortedReason (signal) {
const reason =
signal.reason === undefined
? getDOMException('This operation was aborted.')
: signal.reason
return reason instanceof Error ? reason : getDOMException(reason)
}
/**
TODO: Remove AbortError and just throw DOMException when targeting Node 18.
*/
function getDOMException (errorMessage) {
return globalThis.DOMException === undefined
? new Error(errorMessage)
: new DOMException(errorMessage)
}
export async function checkPnpm () {
let npm = 'npm'
let ret = await execSync('pnpm -v')
if (ret.stdout) npm = 'pnpm'
return npm
}
async function execSync (cmd) {
return new Promise((resolve, reject) => {
exec(cmd, { windowsHide: true }, (error, stdout, stderr) => {
resolve({ error, stdout, stderr })
})
})
}
export function mkdirs (dirname) {
if (fs.existsSync(dirname)) {
return true
} else {
if (mkdirs(path.dirname(dirname))) {
fs.mkdirSync(dirname)
return true
}
}
}
export function formatDate (date) {
const year = date.getFullYear()
const month = date.getMonth() + 1 // Note that getMonth() returns a zero-based index
const day = date.getDate()
const hour = date.getHours()
const minute = date.getMinutes()
const formattedDate = `${year}${month}${day}${hour}:${minute}`
return formattedDate
}
export async function getMasterQQ () {
return (await import('../../../lib/config/config.js')).default.masterQQ
}
/**
*
* @param pluginKey plugin key
* @param htmlPath html文件路径相对于plugin resources目录
* @param data 渲染数据
* @param renderCfg 渲染配置
* @param renderCfg.retType 返回值类型
* * default/空自动发送图片返回true
* * msgId自动发送图片返回msg id
* * base64: 不自动发送图像返回图像base64数据
* @param renderCfg.beforeRender({data}) 可改写渲染的data数据
* @returns {Promise<boolean>}
*/
export async function render (e, pluginKey, htmlPath, data = {}, renderCfg = {}) {
// 处理传入的path
htmlPath = htmlPath.replace(/.html$/, '')
let paths = lodash.filter(htmlPath.split('/'), (p) => !!p)
htmlPath = paths.join('/')
// 创建目录
const mkdir = (check) => {
let currDir = `${process.cwd()}/data`
for (let p of check.split('/')) {
currDir = `${currDir}/${p}`
if (!fs.existsSync(currDir)) {
fs.mkdirSync(currDir)
}
}
return currDir
}
mkdir(`html/${pluginKey}/${htmlPath}`)
// 自动计算pluResPath
let pluResPath = `../../../${lodash.repeat('../', paths.length)}plugins/${pluginKey}/resources/`
// 渲染data
data = {
...data,
_plugin: pluginKey,
_htmlPath: htmlPath,
pluResPath,
tplFile: `./plugins/${pluginKey}/resources/${htmlPath}.html`,
saveId: data.saveId || data.save_id || paths[paths.length - 1],
pageGotoParams: {
waitUntil: 'networkidle0'
}
}
// 处理beforeRender
if (renderCfg.beforeRender) {
data = renderCfg.beforeRender({ data }) || data
}
// 保存模板数据
if (process.argv.includes('web-debug')) {
// debug下保存当前页面的渲染数据方便模板编写与调试
// 由于只用于调试开发者只关注自己当时开发的文件即可暂不考虑app及plugin的命名冲突
let saveDir = mkdir(`ViewData/${pluginKey}`)
let file = `${saveDir}/${data._htmlPath.split('/').join('_')}.json`
fs.writeFileSync(file, JSON.stringify(data))
}
// 截图
let base64 = await puppeteer.screenshot(`${pluginKey}/${htmlPath}`, data)
if (renderCfg.retType === 'base64') {
return base64
}
let ret = true
if (base64) {
ret = await e.reply(base64)
}
return renderCfg.retType === 'msgId' ? ret : true
}
export async function renderUrl (e, url, renderCfg = {}) {
await puppeteer.browserInit()
const page = await puppeteer.browser.newPage()
let base64
try {
await page.goto(url, { timeout: 120000 })
await page.setViewport(renderCfg.Viewport || {
width: 1280,
height: 720
})
let buff = base64 = await page.screenshot({fullPage:true})
base64 = segment.image(buff)
await page.close().catch((err) => logger.error(err))
} catch (error) {
logger.error(`${url}图片生成失败:${error}`)
/** 关闭浏览器 */
if (puppeteer.browser) {
await puppeteer.browser.close().catch((err) => logger.error(err))
}
puppeteer.browser = false
}
if (renderCfg.retType === 'base64') {
return base64
}
let ret = true
if (base64) {
ret = await e.reply(base64)
}
return renderCfg.retType === 'msgId' ? ret : true
}
export function getDefaultUserSetting () {
return {
usePicture: Config.defaultUsePicture,
useTTS: Config.defaultUseTTS,
ttsRole: Config.defaultTTSRole
}
}
export function parseDuration (duration) {
const timeMap = {
: 1,
: 60,
小时: 60 * 60
}
// 去掉多余的空格并将单位转化为小写字母
duration = duration.trim().toLowerCase()
// 去掉末尾的 "钟" 字符
if (duration.endsWith('钟')) {
duration = duration.slice(0, -1)
}
// 提取数字和单位
const match = duration.match(/^(\d+)\s*([\u4e00-\u9fa5]+)$/)
if (!match) {
throw new Error('Invalid duration string: ' + duration)
}
const num = parseInt(match[1], 10)
const unit = match[2]
if (!(unit in timeMap)) {
throw new Error('Unknown time unit: ' + unit)
}
return num * timeMap[unit]
}
export function formatDuration (duration) {
const timeMap = {
小时: 60 * 60,
分钟: 60,
秒钟: 1
}
const units = Object.keys(timeMap)
let result = ''
for (let i = 0; i < units.length; i++) {
const unit = units[i]
const value = Math.floor(duration / timeMap[unit])
if (value > 0) {
result += value + unit
duration -= value * timeMap[unit]
}
}
return result || '0秒钟'
}
/**
* 判断服务器所在地是否为中国
* @returns {Promise<boolean>}
*/
export async function isCN () {
if (await redis.get('CHATGPT:COUNTRY_CODE')) {
return await redis.get('CHATGPT:COUNTRY_CODE') === 'CN'
} else {
try {
let response = await fetch('https://ipinfo.io/country')
let countryCode = (await response.text()).trim()
await redis.set('CHATGPT:COUNTRY_CODE', countryCode, { EX: 3600 })
return countryCode === 'CN'
} catch (err) {
console.warn(err)
// 没拿到归属地默认CN
return true
}
}
}
export function limitString (str, maxLength) {
if (str.length <= maxLength) {
return str
} else {
return str.slice(0, maxLength) + '...'
}
}
/**
* ```
* var text = '你好こんにちはHello';
* var wrappedText = wrapTextByLanguage(text);
* console.log(wrappedText);
* ```
* @param text
* @returns {string}
*/
export function wrapTextByLanguage (text) {
// 根据标点符号分割句子
const symbols = /([。!?,])/
let sentences = text.split(symbols)
sentences = sentences.reduce((acc, cur, index) => {
if (symbols.test(cur)) {
// 如果当前字符串是标点符号,则将其添加到前一个字符串的末尾
acc[acc.length - 1] += cur
} else {
// 否则,将当前字符串添加到结果数组中
acc.push(cur)
}
return acc
}, [])
let wrappedSentences = []
for (let i = 0; i < sentences.length; i++) {
let sentence = sentences[i]
// 如果是标点符号,则跳过
if (sentence === '。' || sentence === '' || sentence === '' || sentence === '') {
continue
}
const pattern = /[a-zA-Z]/g
sentence = sentence.replace(pattern, '')
// 判断这一句话是中文还是日语
let isChinese = true
let isJapanese = false
for (let j = 0; j < sentence.length; j++) {
let char = sentence.charAt(j)
if (char.match(/[\u3040-\u309F\u30A0-\u30FF]/)) {
isJapanese = true
isChinese = false
break
}
}
// 包裹句子
if (isChinese) {
sentence = `[ZH]${sentence}[ZH]`
} else if (isJapanese) {
sentence = `[JA]${sentence}[JA]`
}
wrappedSentences.push(sentence)
}
const mergedSentences = wrappedSentences.reduce((acc, cur) => {
if (cur === '') {
// 如果当前字符串为空或者是标点符号,则直接将其添加到结果数组中
acc.push(cur)
} else {
// 否则,判断前一个字符串和当前字符串是否为同种语言
const prev = acc[acc.length - 1]
let curPrefix = `${cur.slice(0, 4)}`
if (prev && prev.startsWith(curPrefix)) {
// 如果前一个字符串和当前字符串为同种语言,则将它们合并
let a = (acc[acc.length - 1] + cur)
a = lodash.replace(a, curPrefix + curPrefix, '')
acc[acc.length - 1] = a
} else {
// 否则,将当前字符串添加到结果数组中
acc.push(cur)
}
}
return acc
}, [])
return mergedSentences.join('')
}
// console.log(wrapTextByLanguage('你好这里是哈哈こんにちはHello'))
export function maskQQ (qq) {
if (!qq) {
return '未知'
}
let len = qq.length // QQ号长度
let newqq = qq.slice(0, 3) + '*'.repeat(len - 7) + qq.slice(len - 3) // 替换中间3位为*
return newqq
}
export function completeJSON(input) {
let result = {}
let inJson = false
let inQuote = false
let onStructure = false
let isKey = true
let tempKey = ''
let tempValue = ''
for (let i = 0; i < input.length; i++) {
// 获取当前字符
let char = input[i];
// 获取到json头
if (!inJson && char === '{') {
inJson = true
continue
}
// 如果不再json中忽略当前字符
if (!inJson) continue
// 获取结构引号
if (char === '"' && input[i - 1] != '\\') {
inQuote = !inQuote
// 如果是开始数据,则确保当前结构开放
if (inQuote) onStructure = true
continue
}
// 获取:切换kv
if (!inQuote && onStructure && char === ':') {
isKey = !isKey
continue
}
// 将字符写入缓存
if (inQuote && onStructure) {
// 根据当前类型写入对应缓存
if (isKey) {
tempKey += char
} else {
tempValue += char
}
}
// 结束结构追加数据
if (!inQuote && onStructure && char === ',') {
// 追加结构
result[tempKey] = tempValue.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t")
// 结束结构清除数据
onStructure = false
inQuote = false
isKey = true
tempKey = ''
tempValue = ''
}
}
// 处理截断的json数据
if (onStructure && tempKey != '') {
result[tempKey] = tempValue.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t")
}
return result
}
export async function isImage(link) {
try {
let response = await fetch(link)
let body = await response.arrayBuffer()
let buf = buffer.Buffer.from(body)
let magic = buf.toString('hex', 0, 4)
return ['ffd8', '8950', '4749'].includes(magic)
} catch (error) {
return false
}
}
export async function getPublicIP() {
try {
if (localIP === '') {
const res = await fetch('https://api.ipify.org?format=json')
const data = await res.json()
localIP = data.ip
}
return localIP
} catch (err) {
return '127.0.0.1'
}
}