mirror of
https://github.com/ikechan8370/chatgpt-plugin.git
synced 2025-12-16 13:27:08 +00:00
* feat: add support for ‘greeting’ and ‘global reply mode’ commands, improve variable naming and remove unnecessary backend output. * feat: Add support for black and white lists, global reply mode and voice role settings, private chat switch, and active greeting configuration. Refactor some variable names and comment out redundant code for better readability and reduced backend output. * feat: 为新功能完善了帮助面板 * docs: 完善了‘打招呼’的帮助说明 * Commit Type: feat, bugfix Add functionality to view plugin command table, fix bug in blacklist/whitelist, and fix bug where chat mode can still be used in private messaging when disabled. * Commit Type: feat, bugfix Add functionality to view plugin command table, fix bug in blacklist/whitelist, and fix bug where chat mode can still be used in private messaging when disabled. * refactor: Remove redundant log output. * Refactor: optimize code logic * Fix: 修复绘图指令表被抢指令的bug。 * Refactor:1. Add support for automatically translating replies to Japanese and generating voice messages in VITS voice mode (please monitor remaining quota after enabling). 2. Add translation function. 3. Add emotion configuration for Azure voice mode, allowing the robot to select appropriate emotional styles for replies. * Refactor:Handle the issue of exceeding character setting limit caused by adding emotion configuration. * Fix: fix bugs * Refactor: Added error feedback to translation service * Refactor: Added support for viewing the list of supported roles for each language mode, and fixed some bugs in the emotion switching feature of the auzre mode. * Refactor: Optimized some command feedback and added owner restriction to chat record export function. * Refactor: Optimized feedback when viewing role list to avoid excessive messages. * Refactor: Optimized feedback when configuring multi-emotion mode. * Feature: Added help instructions for translation feature. * chore: Adjust help instructions for mood settings * Fix: Fixed issue where only first line of multi-line replies were being read and Azure voice was pronouncing punctuation marks. * Fix: Fixed bug where switching to Azure voice mode prompted for missing key and restricted ability to view voice role list to only when in voice mode. * Refactor: Add image OCR function and support translation for both quoted text and image. * fix: Fix issue with error caused by non-image input. * Refactor: Optimize code to filter emojis that cannot be displayed properly in claude mode. * Refactor: Optimize some code structures. * fix: Fix the bug of returning only one result when entering multiple lines of text on Windows system. * Refactor: Optimize code logic for better user experience * Refactor: Fix the conflict issue with other plugin translation commands * Refactor: Replace Baidu Translation with Youdao Translation to eliminate configuration steps; optimize translation experience; add missing dependency prompts instead of causing program errors.Optimize the experience of switching between voice mode and setting global reply mode. * Refactor: Remove unused files and dependencies in the project. * Feature: Add Youdao translation service to provide more comprehensive translation support. * Refactor: Optimize translation experience * Refactor: Optimize translation experience * Feature: Add functionality of keyword search command * Feature: Add functionality of keyword search command. * Refactor: Remove redundant code * Add: Add feature to support randomly selecting roles for Azure voice. Refactor the code to support existing voice services for the ‘greeting’ feature. Fix the display issue of Azure voice role selection on the Guoba panel. * Refactor: Remove redundant code * Refactor: Improve the function of setting global voice roles and viewing role lists. Now you can set default roles for each voice service separately or view the supported role list. * Refactor: Remove redundant code * Feature: Add new function to support random character dialogues in all voice modes, add the ability to view the current user’s reply settings, and improve related functions in the global settings. * Refactor: Add compatibility directive for viewing reply settings feature * Feature: support adding QQ number to blacklist/whitelist * fix: 处理全局设置指令被上下班指令占用的问题 * fix: 处理全局设置指令被上下班指令占用的问题 * Refactor: Preprocess dialogue blacklist/whitelist when filling in the form in Guoba panel * Fix: Fixed the issue where black and white lists were not effective when filled in the Guoba panel, and the issue where no results were returned when viewing the voice role list without parameters in azure tts mode. * fix: 2.7 dev start * feat: 初步支持function call(WIP) * fix: syntax error * fix: syntax error * feat: 群聊上下文 * fix: 暂时阉割掉全员禁言功能 * fix: 修改禁言时间范围 * fix: 修复一些功能易用性 * fix: 只有管理员和群主才能用jinyan和kickout * fix: 加回来禁言和踢出 * fix: 修复管理员权限判断问题(可能吧) * fix: 试图优化逻辑 * fix: fuck openai documents * fix: 删掉认主不然一直禁言我烦死了 * fix: 哔哩哔哩封面损坏问题 * fix: 加个天气小工具 * fix: 天气不存在城市 * fix: website工具用浏览器 * feat: serp tool * feat: 增加一个google搜索源 * fix: 加一句描述 * feat: 增加搜索来源选项 * feat: 搜图和发图 * fix: groupId format error * Refactor: Optimized the HTML parsing rules * fix: Fixed the bug where conversations could not be properly ended in at mode, now it works normally * refactor: Added EliMovieTool and EliMusicTool. Modified some tool’s prompt to make AI make better choices. Optimized the display of chat history.Remove SendMusicTool and replace it with EliMusicTool. * chore: trivial changes. * refactor:Keep SearchMusicTool and SendMusicTool when avocado plugin is not installed, waiting for more testing~~~ * Refactor: Move the parameter processing logic of some tools into each tool internally.🥑Rename ttstool to SendAudioMessageTool, defaulting to the voice role configured by the current user, and support personalized configuration of the existing tts mode in the plugin.🥑Add SendMessageToSpecificGroupOrUserTool, which allows the robot to send messages to specific groups or friends.🥑Encapsulate the function of generating audio messages into an independent function for easy tool invocation.🥑Separate SendPictureTool and SendAvatarTool to avoid unnecessary bugs.🥑 * chore: Remove unnecessary log file🥑 * refactor: Trivial changes and fixed a bug in QueryUserinfoTool.🥑 * Refactor: Rewrite blacklist and whitelist functionality, optimize code calls Description: Rewrote the blacklist and whitelist functionality to support group, QQ number, and a combination of group and QQ number configurations. Removed the command to set the blacklist and whitelist functionality and unified it through the Guoba panel. Also optimized some code calls to EliMusicTool and EliMovieTool.🥑 * chore: Adjust some tool prompts * chore: adjust prompt * Refactor: Improve the user experience of EliMovieTool and EliMusicTool🥑 * Fix: Fix the bug that AI sends content to itself using the tool🥑 * refactor: Instructions adapted to the new version of avocado-plugin * refactor: optimize avocadoRender🥑 * Chore: Optimize regex commands related to management functions * Chore: trivial changes --------- Co-authored-by: Sean <1519059137@qq.com> Co-authored-by: ikechan8370 <geyinchibuaa@gmail.com> Co-authored-by: ikechan8370 <geyinchi@buaa.edu.cn>
942 lines
26 KiB
JavaScript
942 lines
26 KiB
JavaScript
// 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 yaml from 'yaml'
|
||
import puppeteer from '../../../lib/puppeteer/puppeteer.js'
|
||
import {Config} from './config.js'
|
||
import {convertSpeaker, generateVitsAudio, speakers as vitsRoleList} from './tts.js'
|
||
import VoiceVoxTTS, {supportConfigurations as voxRoleList} from './tts/voicevox.js'
|
||
import AzureTTS, {supportConfigurations as azureRoleList} from './tts/microsoft-azure.js'
|
||
import {translate} from './translate.js'
|
||
import uploadRecord from './uploadRecord.js'
|
||
// export function markdownToText (markdown) {
|
||
// return remark()
|
||
// .use(stripMarkdown)
|
||
// .processSync(markdown ?? '')
|
||
// .toString()
|
||
// }
|
||
|
||
let _puppeteer
|
||
try {
|
||
const Puppeteer = (await import('../../../renderers/puppeteer/lib/puppeteer.js')).default
|
||
let puppeteerCfg = {}
|
||
let configFile = './renderers/puppeteer/config.yaml'
|
||
if (fs.existsSync(configFile)) {
|
||
try {
|
||
puppeteerCfg = yaml.parse(fs.readFileSync(configFile, 'utf8'))
|
||
} catch (e) {
|
||
puppeteerCfg = {}
|
||
}
|
||
}
|
||
_puppeteer = new Puppeteer(puppeteerCfg)
|
||
} catch (e) {
|
||
logger.debug('未能加载puppeteer,尝试降级到Yunzai的puppeteer尝试')
|
||
_puppeteer = puppeteer
|
||
}
|
||
|
||
let localIP = ''
|
||
export function escapeHtml (str) {
|
||
const htmlEntities = {
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
'/': '/'
|
||
}
|
||
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) {
|
||
try {
|
||
let info = await Bot.getGroupMemberInfo(e.group_id, Bot.uin)
|
||
nickname = info.card || info.nickname
|
||
} catch (err) {
|
||
console.error(`Failed to get group member info: ${err}`)
|
||
}
|
||
}
|
||
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 function formatDate2 (date) {
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
|
||
return `${year}-${month}-${day}`
|
||
}
|
||
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 = {}) {
|
||
// 云渲染
|
||
if (Config.cloudRender) {
|
||
url = url.replace(`127.0.0.1:${Config.serverPort || 3321}`, Config.serverHost || `${await getPublicIP()}:${Config.serverPort || 3321}`)
|
||
const resultres = await fetch(`${Config.cloudTranscode}/screenshot`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
url,
|
||
option: {
|
||
width: renderCfg.Viewport.width || 1280,
|
||
height: renderCfg.Viewport.height || 720,
|
||
timeout: 120000,
|
||
waitUtil: renderCfg.waitUtil || 'networkidle2',
|
||
wait: renderCfg.wait || 1000,
|
||
func: renderCfg.func || '',
|
||
dpr: renderCfg.deviceScaleFactor || 1
|
||
},
|
||
type: 'image'
|
||
})
|
||
})
|
||
if (resultres.ok) {
|
||
const buff = Buffer.from(await resultres.arrayBuffer())
|
||
if (buff) {
|
||
const base64 = segment.image(buff)
|
||
if (renderCfg.retType === 'base64') {
|
||
return base64
|
||
}
|
||
let ret = true
|
||
if (base64) {
|
||
ret = await e.reply(base64)
|
||
}
|
||
return renderCfg.retType === 'msgId' ? ret : true
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
})
|
||
await page.waitForTimeout(renderCfg.wait || 1000)
|
||
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 getDefaultReplySetting () {
|
||
return {
|
||
usePicture: Config.defaultUsePicture,
|
||
useTTS: Config.defaultUseTTS,
|
||
ttsRole: Config.defaultTTSRole,
|
||
ttsRoleAzure: Config.azureTTSSpeaker,
|
||
ttsRoleVoiceVox: Config.voicevoxTTSSpeaker
|
||
}
|
||
}
|
||
|
||
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, addDots = true) {
|
||
if (str.length <= maxLength) {
|
||
return str
|
||
} else {
|
||
if (addDots) {
|
||
return str.slice(0, maxLength) + '...'
|
||
} 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'
|
||
}
|
||
}
|
||
|
||
export async function getUserData (user) {
|
||
const dir = 'resources/ChatGPTCache/user'
|
||
const filename = `${user}.json`
|
||
const filepath = path.join(dir, filename)
|
||
try {
|
||
let data = fs.readFileSync(filepath, 'utf8')
|
||
return JSON.parse(data)
|
||
} catch (error) {
|
||
return {
|
||
user,
|
||
passwd: '',
|
||
chat: [],
|
||
mode: '',
|
||
cast: {
|
||
api: '', // API设定
|
||
bing: '', // 必应设定
|
||
bing_resource: '', // 必应扩展资料
|
||
slack: '' // Slack设定
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export function getVoicevoxRoleList () {
|
||
return voxRoleList.map(item => item.name).join(',')
|
||
}
|
||
|
||
export function getAzureRoleList () {
|
||
return azureRoleList.map(item => item.roleInfo + (item?.emotion ? '-> 支持:' + Object.keys(item.emotion).join(',') + ' 情绪。' : '')).join('\n\n')
|
||
}
|
||
|
||
export async function getVitsRoleList (e) {
|
||
const [firstHalf, secondHalf] = [vitsRoleList.slice(0, Math.floor(vitsRoleList.length / 2)).join('、'), vitsRoleList.slice(Math.floor(vitsRoleList.length / 2)).join('、')]
|
||
const [chunk1, chunk2] = [firstHalf.match(/[^、]+(?:、[^、]+){0,30}/g), secondHalf.match(/[^、]+(?:、[^、]+){0,30}/g)]
|
||
const list = [await makeForwardMsg(e, chunk1, 'vits角色列表1'), await makeForwardMsg(e, chunk2, 'vits角色列表2')]
|
||
return await makeForwardMsg(e, list, 'vits角色列表')
|
||
}
|
||
|
||
export async function getUserReplySetting (e) {
|
||
let userSetting = await redis.get(`CHATGPT:USER:${e.sender.user_id}`)
|
||
if (userSetting) {
|
||
userSetting = JSON.parse(userSetting)
|
||
if (Object.keys(userSetting).indexOf('useTTS') < 0) {
|
||
userSetting.useTTS = Config.defaultUseTTS
|
||
}
|
||
} else {
|
||
userSetting = getDefaultReplySetting()
|
||
}
|
||
return userSetting
|
||
}
|
||
|
||
export async function getImg (e) {
|
||
// 取消息中的图片、at的头像、回复的图片,放入e.img
|
||
if (e.at && !e.source) {
|
||
e.img = [`https://q1.qlogo.cn/g?b=qq&s=0&nk=${e.at}`]
|
||
}
|
||
if (e.source) {
|
||
let reply
|
||
if (e.isGroup) {
|
||
reply = (await e.group.getChatHistory(e.source.seq, 1)).pop()?.message
|
||
} else {
|
||
reply = (await e.friend.getChatHistory(e.source.time, 1)).pop()?.message
|
||
}
|
||
if (reply) {
|
||
let i = []
|
||
for (let val of reply) {
|
||
if (val.type === 'image') {
|
||
i.push(val.url)
|
||
}
|
||
}
|
||
e.img = i
|
||
}
|
||
}
|
||
return e.img
|
||
}
|
||
|
||
export async function getImageOcrText (e) {
|
||
const img = await getImg(e)
|
||
if (img) {
|
||
try {
|
||
let resultArr = []
|
||
let eachImgRes = ''
|
||
for (let i in img) {
|
||
const imgOCR = await Bot.imageOcr(img[i])
|
||
for (let text of imgOCR.wordslist) {
|
||
eachImgRes += (`${text?.words} \n`)
|
||
}
|
||
if (eachImgRes) resultArr.push(eachImgRes)
|
||
eachImgRes = ''
|
||
}
|
||
// logger.warn('resultArr', resultArr)
|
||
return resultArr
|
||
} catch (err) {
|
||
return false
|
||
// logger.error(err)
|
||
}
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
|
||
export function getMaxModelTokens (model = 'gpt-3.5-turbo') {
|
||
if (model.startsWith('gpt-3.5-turbo')) {
|
||
if (model.includes('16k')) {
|
||
return 16000
|
||
} else {
|
||
return 4000
|
||
}
|
||
} else {
|
||
if (model.includes('32k')) {
|
||
return 32000
|
||
} else {
|
||
return 16000
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成当前语音模式下可发送的音频信息
|
||
* @param e - 上下文对象
|
||
* @param pendingText - 待处理文本
|
||
* @param speakingEmotion - AzureTTSMode中的发言人情绪
|
||
* @param emotionDegree - AzureTTSMode中的发言人情绪强度
|
||
* @returns {Promise<{file: string, type: string}|undefined|boolean>}
|
||
*/
|
||
export async function generateAudio (e, pendingText, speakingEmotion, emotionDegree = 1) {
|
||
if (!Config.ttsSpace && !Config.azureTTSKey && !Config.voicevoxSpace) return false
|
||
let wav
|
||
const speaker = getUserSpeaker(await getUserReplySetting(e))
|
||
try {
|
||
if (Config.ttsMode === 'vits-uma-genshin-honkai' && Config.ttsSpace) {
|
||
if (Config.autoJapanese) {
|
||
try {
|
||
pendingText = await translate(pendingText, '日')
|
||
} catch (err) {
|
||
logger.warn(err.message + '\n将使用原始文本合成语音...')
|
||
return false
|
||
}
|
||
}
|
||
wav = await generateVitsAudio(pendingText, speaker, '中日混合(中文用[ZH][ZH]包裹起来,日文用[JA][JA]包裹起来)')
|
||
} else if (Config.ttsMode === 'azure' && Config.azureTTSKey) {
|
||
return await generateAzureAudio(pendingText, speaker, speakingEmotion, emotionDegree)
|
||
} else if (Config.ttsMode === 'voicevox' && Config.voicevoxSpace) {
|
||
pendingText = (await translate(pendingText, '日')).replace('\n', '')
|
||
wav = await VoiceVoxTTS.generateAudio(pendingText, {
|
||
speaker
|
||
})
|
||
}
|
||
} catch (err) {
|
||
logger.error(err)
|
||
return false
|
||
}
|
||
let sendable
|
||
try {
|
||
try {
|
||
sendable = await uploadRecord(wav, Config.ttsMode)
|
||
if (sendable) {
|
||
await e.reply(sendable)
|
||
} else {
|
||
// 如果合成失败,尝试使用ffmpeg合成
|
||
sendable = segment.record(wav)
|
||
}
|
||
} catch (err) {
|
||
logger.error(err)
|
||
sendable = segment.record(wav)
|
||
}
|
||
} catch (err) {
|
||
logger.error(err)
|
||
return false
|
||
}
|
||
if (Config.ttsMode === 'azure' && Config.azureTTSKey) {
|
||
// 清理文件
|
||
try {
|
||
fs.unlinkSync(wav)
|
||
} catch (err) {
|
||
logger.warn(err)
|
||
}
|
||
}
|
||
return sendable
|
||
}
|
||
|
||
/**
|
||
* 生成可发送的AzureTTS音频
|
||
* @param pendingText - 待转换文本
|
||
* @param role - 发言人
|
||
* @param speakingEmotion - 发言人情绪
|
||
* @param emotionDegree - 发言人情绪强度
|
||
* @returns {Promise<{file: string, type: string}|boolean>}
|
||
*/
|
||
export async function generateAzureAudio (pendingText, role = '随机', speakingEmotion, emotionDegree = 1) {
|
||
if (!Config.azureTTSKey) return false
|
||
let speaker
|
||
try {
|
||
if (role !== '随机') {
|
||
// 判断传入的是不是code
|
||
if (azureRoleList.find(s => s.code === role.trim())) {
|
||
speaker = role
|
||
} else {
|
||
speaker = azureRoleList.find(s => s.roleInfo.includes(role.trim()))
|
||
if (!speaker) {
|
||
logger.warn('找不到名为' + role + '的发言人,将使用默认发言人 晓晓 发送音频.')
|
||
speaker = 'zh-CN-XiaoxiaoNeural'
|
||
} else {
|
||
speaker = speaker.code
|
||
}
|
||
}
|
||
let languagePrefix = azureRoleList.find(config => config.code === speaker).languageDetail.charAt(0)
|
||
languagePrefix = languagePrefix.startsWith('E') ? '英' : languagePrefix
|
||
pendingText = (await translate(pendingText, languagePrefix)).replace('\n', '')
|
||
|
||
} else {
|
||
let role, languagePrefix
|
||
role = azureRoleList[Math.floor(Math.random() * azureRoleList.length)]
|
||
speaker = role.code
|
||
languagePrefix = role.languageDetail.charAt(0).startsWith('E') ? '英' : role.languageDetail.charAt(0)
|
||
pendingText = (await translate(pendingText, languagePrefix)).replace('\n', '')
|
||
if (role?.emotion) {
|
||
const keys = Object.keys(role.emotion)
|
||
speakingEmotion = keys[Math.floor(Math.random() * keys.length)]
|
||
}
|
||
emotionDegree = 2
|
||
logger.info('using speaker: ' + speaker)
|
||
logger.info('using language: ' + languagePrefix)
|
||
logger.info('using emotion: ' + speakingEmotion)
|
||
}
|
||
let ssml = AzureTTS.generateSsml(pendingText, {
|
||
speaker,
|
||
emotion: speakingEmotion,
|
||
pendingText,
|
||
emotionDegree
|
||
})
|
||
return await uploadRecord(
|
||
await AzureTTS.generateAudio(pendingText, {
|
||
speaker
|
||
}, await ssml)
|
||
, Config.ttsMode
|
||
)
|
||
} catch (err) {
|
||
logger.error(err)
|
||
return false
|
||
}
|
||
}
|
||
export function getUserSpeaker (userSetting) {
|
||
if (Config.ttsMode === 'vits-uma-genshin-honkai') {
|
||
return convertSpeaker(userSetting.ttsRole || Config.defaultTTSRole)
|
||
} else if (Config.ttsMode === 'azure') {
|
||
return userSetting.ttsRoleAzure || Config.azureTTSSpeaker
|
||
} else if (Config.ttsMode === 'voicevox') {
|
||
return userSetting.ttsRoleVoiceVox || Config.voicevoxTTSSpeaker
|
||
}
|
||
}
|
||
|