koishi-plugin-dialogue-fork
Version:
[keyword-dialogue](https://www.npmjs.com/package/@shangxueink/koishi-plugin-keyword-dialogue)的[fork](https://github.com/hellomykami/koishi-plugin-keyword-dialogue-fork)版本,添加部分功能
1,248 lines (1,110 loc) • 53.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.apply = exports.usage = exports.Config = exports.name = void 0;
const fs = require('node:fs');
const { pathToFileURL } = require('node:url');
const { Schema, Logger, h } = require("koishi");
const path = require("node:path");
const logger = new Logger('keyword-dialogue');
exports.inject = {
optional: ['puppeteer']
};
exports.usage = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>插件使用说明</title>
</head>
<body>
<h1>插件使用说明</h1>
<p>本插件是专门为图文教程和问答场景设计的,能让你的机器人根据关键词自动回复内容哦。</p>
<p>如果你使用过云崽的喵喵插件的添加/删除功能,那你会更容易上手本插件的!</p>
<h2>核心功能</h2>
<ul>
<li><strong>关键词管理:</strong>你可以通过指令添加或删除关键词及其回复内容。</li>
<ul>
<li>添加关键词:使用指令 <code>添加 [关键词]</code> 来添加关键词。</li>
<li>删除关键词:使用指令 <code>删除 [关键词]</code> 来删除关键词。<code>删除 -q 2 你好 </code>来删除“你好”的第二条回复</li>
<li>全局添加/删除:使用指令 <code>全局添加 [关键词]</code> 和 <code>全局删除 [关键词]</code> 来在全局范围内管理关键词。<code>全局删除 -q 2 你好 </code>来删除“你好”的第二条回复。</li>
</ul>
<li><strong>回复内容:</strong>支持文本和图片回复,图片会被保存到本地,避免失效。</li>
<li><strong>正则表达式支持:</strong>你可以使用正则表达式来匹配关键词。例如,输入 <code>添加 你好 -x</code>,可以匹配任何包含“你好”的消息,如“你好,包含了你好的句子就可以触发哦”。</li>
<li><strong>自定义输入时限:</strong>你可以设置添加回复的输入时限,超过时限将视为超时,自动取消添加操作。</li>
<li><strong>多段回复效果:</strong>你可以选择多段输入的回复效果,支持逐条发送或合并为一条消息。</li>
<li><strong>后缀支持空格匹配:</strong>使用数字指定回复序号,空格和无空格都能正常匹配。例如,<code>你好 2</code> 和 <code>你好2</code> 都会触发【你好】的第二个回复。</li>
<li><strong>修改指定序号的回复内容:</strong>使用 <code>修改 [关键词] -q [序号]</code>,可以修改某个关键词的指定回复。例如,输入 <code>修改 你好 -q 2</code>,将会修改“你好”的第二条回复,并显示当前内容,供你确认后再输入新的回复内容。使用 <code>修改 [关键词] -g</code>,可以修改全局关键词。使用 <code>全局修改 [关键词]</code>,可以修改全局关键词。暂时仅支持修改指令一次性输入回复,暂不支持多段添加。</li>
<h2>使用示例</h2>
<ul>
<li>添加关键词:<code>添加 你好</code></li>
<li>添加正则关键词:<code>添加 你好 -x</code></li>
<li>删除关键词:<code>删除 你好</code>、<code>删除 -q 2 你好 </code>(删除“你好”的第二条回复)</li>
<li>全局添加关键词:<code>全局添加 你好</code></li>
<li>全局删除关键词:<code>全局删除 你好</code>、<code>全局删除 -q 2 你好 </code>(删除“你好”的第二条回复)</li>
<li>修改指定回复:<code>修改 -q 2 你好 </code>(修改“你好”的第二条回复)</li>
<li>全局修改指定回复:<code>修改 -g 你好 </code>(修改“你好”的全局回复)</li>
<li>全局修改指定回复:<code>全局修改 你好 </code>(修改“你好”的全局回复)</li>
</ul>
<hr>
<h2>修改插件回复内容</h2>
<p>如果你需要修改这个插件的回复内容,你需要具备一定的编辑 JSON 文件的能力。</p>
<p>你可以在 Koishi 控制台左侧的活动栏找到【资源管理器】页面,</p>
<p>然后依次找到文件夹【data】->【keyword-dialogue】文件夹下的各种【***(群号)***.json】文件。</p>
<p>你可以在里面编辑和修改 JSON 内容。</p>
<hr>
<h2>注意事项</h2>
<ul>
<li>默认情况下,<code>添加/删除</code> 的问答是按群组分隔的。只有 <code>全局添加/删除</code> 才支持多群组同样的回复。</li>
<li>请确保解析到的图片链接是可以访问的。</li>
<li>这个插件需要确保 接入平台 端和 koishi 端运行在同一台机器上,以确保本地图片能够正确保存和发送。</li>
<li>在使用正则表达式时,请确保输入的模式是有效的,以避免匹配错误。</li>
<li>如果你添加了语音/视频消息回复,请确保已经安装了 silk、ffmpeg 等服务!</li>
</ul>
</body>
</html>
`;
exports.Config = Schema.intersect([
Schema.object({
command: Schema.object({
TriggerPrefix: Schema.string().default('添加').description('触发`添加关键词`功能的指令'),
DeleteKeyword: Schema.string().default('删除').description('触发`删除关键词`功能的指令'),
KeywordOfEsc: Schema.string().default('取消添加').description('取消`添加关键词`功能的关键词'),
KeywordOfEnd: Schema.string().default('结束添加').description('退出`添加关键词`功能的关键词'),
GlobalTriggerPrefix: Schema.string().default('全局添加').description('触发`全局添加关键词`功能的指令(可在全局范围内生效)'),
GlobalDeleteKeyword: Schema.string().default('全局删除').description('触发`全局删除关键词`功能的指令'),
KeywordOfSearch: Schema.string().default('查找关键词').description('触发`搜索关键词`功能的指令'),
KeywordOfFix: Schema.string().default('修改').description('触发`修改问答`功能的指令'),
GlobalKeywordOfFix: Schema.string().default('全局修改').description('触发`全局修改问答`功能的指令'),
ViewKeywordList: Schema.string().default('查看关键词列表').description('触发`查看关键词列表`功能的指令(仅返回当前群组的关键词)'),
}).collapse().description('指令注册设置'),
addKeywordTime: Schema.number().role('slider').min(1).max(30).step(1).default(5).description('添加回复的输入时限,超过则视为超时,取消添加。`单位 分钟`'),
defaultImageExtension: Schema.union(['jpg', 'png', 'gif']).default('png').description('输入图片保存的后缀名'),
}).description('基础设置'),
Schema.object({
admin_list: Schema.array(Schema.object({
adminID: Schema.string().description('管理员用户ID'),
allowcommand: Schema.array(Schema.union(['添加', '删除', '全局添加', '全局删除', '查找关键词', '修改', '全局修改', '查看关键词列表'])).default(['添加', '删除', '修改', '查找关键词']).description('可以使用的指令'),
})).role('table').description('管理员列表( 0 代表所有用户)<br>独立于 channel_admin_auth ').default([
{
"adminID": "0"
}
]),
channel_admin_auth: Schema.boolean().default(false).description('开启后 自动允许 管理员/群主 使用本插件的全部指令 `须确保适配器支持获取群员角色`').experimental(),
Delete_Branch_Only: Schema.boolean().default(true).description('开启后 在删除多段回复的关键词时,必须指定需要删除的序号,而不会直接删除掉这个关键词'),
Treat_all_as_lowercase: Schema.boolean().default(true).description('开启后 英文关键词匹配无视大写字母`解决英文大小写匹配问题`'),
Prompt: Schema.string().role('textarea', { rows: [2, 4] }).default('请输入回复内容(输入 取消添加 以取消,输入 结束添加 以结束):').description('添加时,返回的文字提示'),
picture_save_to_local_send: Schema.union([
Schema.const('1').description('不保存图片,使用平台的图片链接'),
Schema.const('2').description('保存图片为文件,发送时使用图片文件绝对路径'),
Schema.const('3').description('保存图片为文件,发送时图片文件转换为base64'),
Schema.const('4').description('保存图片为base64到json,发送时使用base64 `json会变得好长唔,不推荐`'),
]).role('radio').default('2').description('开启后 图片回复保存到本地路径`防止平台图片链接失效`'),
MatchPatternForExit: Schema.union([
Schema.const('1').description('不使用KeywordOfEnd(不使用多段输入),仅接受一次性的输入'),
Schema.const('2').description('完全匹配KeywordOfEnd时退出'),
Schema.const('3').description('包含KeywordOfEnd时退出'),
Schema.const('4').description('包含KeywordOfEnd,或者完全匹配KeywordOfEnd均可退出'),
]).role('radio').default('4').description("如何退出添加"),
AlwayPrompt: Schema.union([
Schema.const('1').description('不返回文字提示(真的很难用)'),
Schema.const('2').description('仅返回一次文字提示'),
Schema.const('3').description('每次输入都返回文字提示(有点太吵了)'),
]).role('radio').default('2').description("如何返回文字提示"),
HandleDuplicateKeywords: Schema.union([
Schema.const('1').description('不返回文字提示,直接替换/覆盖'),
Schema.const('2').description('不返回文字提示,直接在原关键词回答上添加并列回答(每次仅从中选择随机一种回复)'),
Schema.const('3').description('返回文字提示,不允许重复添加(删除后才可添加)'),
]).role('radio').default('2').description("如何处理待添加的重复关键词"),
MultisegmentAdditionRecoveryEffect: Schema.union([
Schema.const('1').description('按照原版输入,原版输出(多段消息发送)'),
Schema.const('2').description('合为图文消息/多行消息(一次发出,不是合并转发)'),
Schema.const('3').description('合为一条图文消息,并且合并转发发送 `需要适配器支持哦~`'),
Schema.const('4').description('按照原版输入,合并转发发送 `需要适配器支持哦~`'),
]).role('radio').default('2').description("多段添加的回复效果"),
}).description('进阶设置'),
Schema.object({
prefix: Schema.array(String).role('table').default(["", "/", "#"]).description('指令前缀。将被用于指令的匹配。<br>与全局设置的那个,效果差不多,但是仅针对本插件的关键词。'),
}).description('关键词设置'),
Schema.object({
Frequency_limitation: Schema.number().description('同一问答的最小触发间隔 单位:秒').default(0),
Type_of_restriction: Schema.union([
Schema.const('1').description('对同一个问题(全部对象)'),
Schema.const('2').description('仅对同一个频道(不同频道独立记数间隔)'),
]).role('radio').default('2').description("最小间隔时间的限制对象"),
Type_of_ViewKeywordList: Schema.union([
Schema.const('1').description('返回文字列表'),
Schema.const('2').description('返回图片列表(需要puppeteer服务)'),
]).role('radio').default('2').description("`查看关键词列表`的返回设置"),
}).description('回复设置'),
Schema.object({
Search_Range: Schema.union([
Schema.const('1').description('仅在当前频道搜索问答'),
Schema.const('2').description('搜索全部频道的问答'),
Schema.const('3').description('当前频道搜索问答 + 全局问答'),
]).role('radio').default('3').description("搜索范围,`查看关键词列表`和`查找关键词`指令的搜索范围"),
Find_Return_Preset: Schema.union([
Schema.const('1').description('仅返回问答的内容'),
Schema.const('2').description('仅返回问答所在的频道ID/位置'),
Schema.const('3').description('返回问答的内容,并且返回问答所在的频道ID/位置'),
]).role('radio').default('1').description("搜索关键词的 返回信息。"),
Return_Limit: Schema.union([
Schema.const('1').description('返回查找到的全部问答'),
Schema.const('2').description('仅返回一条问答(模糊匹配)'),
]).role('radio').default('2').description("返回限制"),
}).description('查找问答设置'),
Schema.object({
Preposition_middleware: Schema.boolean().default(false).description('开启后 使用前置中间件`匹配到关键词后,不会触发 同实例下 同名称的指令`<br>可以实现回复“指令正在维护中”的效果'),
Unified_at_field: Schema.boolean().default(false).description('统一at消息的内容,统一为:`<at id="11514"/>`<br>因为有时候at内容是`<at id="11514" name="臭"/>`<br>这样可以防止更换了实现端导致的at字段不一致'),
consoleInfo: Schema.boolean().default(false).description('日志调试模式')
}).description('调试设置'),
]);
const lastTriggerTimes = {}; // 用于记录每个关键词的最后触发时间
function apply(ctx, config) {
const add_command = config.command.TriggerPrefix; // 添加
const global_add_command = config.command.GlobalTriggerPrefix; // 全局添加
const delete_command = config.command.DeleteKeyword; // 删除
const global_delete_command = config.command.GlobalDeleteKeyword; // 全局删除
const KeywordOfEsc = config.command.KeywordOfEsc;
const KeywordOfEnd = config.command.KeywordOfEnd;
const KeywordOfSearch = config.command.KeywordOfSearch; // 搜索关键词
const KeywordOfFix = config.command.KeywordOfFix; // 修改
const GlobalKeywordOfFix = config.command.GlobalKeywordOfFix; // 全局修改
const ViewKeywordList = config.command.ViewKeywordList; // 查看关键词列表
const zh_CN_default = {
commands: {
[ViewKeywordList]: {
description: `查看关键词列表`,
messages: {
"channel_admin_auth": "你没有权限操作此指令。"
}
},
[add_command]: {
description: `添加关键词`,
messages: {
"channel_admin_auth": "你没有权限操作此指令。",
"no_Valid_Keyword": "请提供一个有效的关键词。",
"Input_Timeout": "输入超时。",
"Cancel_operation": "添加操作已取消。",
"Keyword_exists": "关键词 \"{0}\" 已存在,不能添加重复的关键词。\n或者请删除这个关键词后重新添加。",
"Reply_added": "关键词 \"{0}\" 的回复已添加。"
}
},
[global_add_command]: {
description: `添加全局关键词`,
messages: {
"channel_admin_auth": "你没有权限操作此指令。",
"no_Valid_Keyword": "请提供一个有效的关键词。",
"Input_Timeout": "输入超时。",
"Cancel_operation": "添加操作已取消。",
"Keyword_exists": "关键词 \"{0}\" 已存在,不能添加重复的关键词。\n或者请删除这个关键词后重新添加。",
"Reply_added": "关键词 \"{0}\" 的回复已添加。"
}
},
[delete_command]: {
description: `删除关键词`,
messages: {
"channel_admin_auth": "你没有权限操作此指令。",
"no_Valid_Keyword": "请提供一个有效的关键词。",
"Keyword_does_not_exist": "关键词 \"{0}\" 不存在。",
"Reply_deleted": "关键词 \"{0}\" 的回复已删除。"
}
},
[global_delete_command]: {
description: `删除全局关键词`,
messages: {
"channel_admin_auth": "你没有权限操作此指令。",
"no_Valid_Keyword": "请提供一个有效的关键词。",
"Keyword_does_not_exist": "关键词 \"{0}\" 不存在。",
"Reply_deleted": "关键词 \"{0}\" 的回复已删除。"
}
},
[KeywordOfSearch]: {
description: `查找关键词`,
messages: {
"channel_admin_auth": "你没有权限操作此指令。",
"no_Valid_Keyword": "请提供一个有效的关键词。",
"Keyword_not_found": "未找到关键词 \"{0}\" 的相关问答。",
"Keyword_found": "在 {0} 下找到:\n关键词:{1}\n回复:\n{2}",
"Keyword_found_in_channel": "在 {0} 下找到关键词:{1}",
"Keyword_found_content_only": "关键词:{0}\n回复:\n{1}"
}
},
[KeywordOfFix]: {
description: `修改关键词`,
messages: {
"channel_admin_auth": "你没有权限操作此指令。",
"no_Valid_Keyword": "请提供一个有效的关键词。",
"Keyword_not_found": "未找到关键词 \"{0}\" 的相关问答。",
"Keyword_found": "在 {0} 下找到:\n关键词:{1}\n回复:\n{2}",
"Keyword_found_in_channel": "在 {0} 下找到关键词:{1}",
"Keyword_found_content_only": "关键词:{0}\n回复:\n{1}",
"Input_Timeout": "输入超时。",
"Cancel_operation": "添加操作已取消。",
"Keyword_exists": "关键词 \"{0}\" 已存在,不能添加重复的关键词。\n或者请删除这个关键词后重新添加。",
"Reply_added": "关键词 \"{0}\" 的回复已修改。"
}
},
[GlobalKeywordOfFix]: {
description: `全局修改关键词`,
messages: {
"channel_admin_auth": "你没有权限操作此指令。",
"no_Valid_Keyword": "请提供一个有效的关键词。",
"Keyword_not_found": "未找到关键词 \"{0}\" 的相关问答。",
"Keyword_found": "在 {0} 下找到:\n关键词:{1}\n回复:\n{2}",
"Keyword_found_in_channel": "在 {0} 下找到关键词:{1}",
"Keyword_found_content_only": "关键词:{0}\n回复:\n{1}",
"Input_Timeout": "输入超时。",
"Cancel_operation": "添加操作已取消。",
"Keyword_exists": "关键词 \"{0}\" 已存在,不能添加重复的关键词。\n或者请删除这个关键词后重新添加。",
"Reply_added": "关键词 \"{0}\" 的回复已修改。"
}
}
}
};
ctx.i18n.define("zh-CN", zh_CN_default);
const root = path.join(ctx.baseDir, 'data', 'keyword-dialogue');
if (!fs.existsSync(root)) {
fs.mkdirSync(root, { recursive: true });
}
ctx.command("keyword-dialogue")
function logInfo(message) {
if (config.consoleInfo) {
logger.info(message);
}
}
async function parseReplyContent(reply, root, session, options) {
const elements = h.parse(reply);
logInfo('Parsed elements: ')
logInfo(elements)
const replyData = await Promise.all(elements.map(async element => {
if (element.type === 'img' || element.type === 'image') {
let localPath;
switch (config.picture_save_to_local_send) {
case '1':
localPath = element.attrs.src;
break;
case '2':
localPath = await downloadImage(element.attrs.src, root, session, options.global);
break;
case '3':
localPath = await downloadImage(element.attrs.src, root, session, options.global);
break;
case '4':
localPath = await downloadImageAsBase64(element.attrs.src);
break;
default:
localPath = element.attrs.src;
}
return {
type: 'image',
text: `${localPath}`,
fileSize: element.attrs.fileSize,
replyway: options.forward || config.MultisegmentAdditionRecoveryEffect
};
} else if (element.type === 'text') {
return {
type: 'text',
text: element.attrs.content,
replyway: options.forward || config.MultisegmentAdditionRecoveryEffect
};
} else if (element.type === 'at') {
return {
type: 'at',
text: element.attrs.id,
replyway: options.forward || config.MultisegmentAdditionRecoveryEffect
};
} else if (element.type === 'audio') {
return {
type: 'audio',
text: element.attrs.path || element.attrs.url,
replyway: options.forward || config.MultisegmentAdditionRecoveryEffect
};
} else if (element.type === 'video') {
return {
type: 'video',
text: element.attrs.src,
replyway: options.forward || config.MultisegmentAdditionRecoveryEffect
};
} else if (element.type === 'mface') {
return {
type: 'mface',
text: element.attrs.url || "无法获取该 mface 的图片链接",
replyway: options.forward || config.MultisegmentAdditionRecoveryEffect
};
} else {
return {
type: 'unknown',
text: reply,
replyway: options.forward || config.MultisegmentAdditionRecoveryEffect
};
}
})).then(results => results.filter(item => item !== null));
return replyData;
}
// 判断是否为管理员
function isAdmin(session) {
const sessionRoles = session.event.member.roles;
return sessionRoles && (sessionRoles.includes('admin') || sessionRoles.includes('owner'));
}
// 检查用户是否有权限执行该指令
function hasPermission(session, command) {
const userId = session.userId;
// 查找特定用户的权限配置
const adminConfig = config.admin_list.find(admin => admin.adminID === userId);
// 查找 adminID 为 0 的配置,表示所有用户的默认权限
const defaultConfig = config.admin_list.find(admin => admin.adminID === '0');
if (defaultConfig && defaultConfig.allowcommand.includes(command)) {
return true;
}
if (adminConfig) {
return adminConfig.allowcommand.includes(command);
}
// 如果 channel_admin_auth 开启
if (config.channel_admin_auth) {
// 如果用户是管理员,检查其权限
if (isAdmin(session)) {
if (adminConfig) {
return adminConfig.allowcommand.includes(command);
}
return true; // 默认管理员拥有所有权限
}
// 如果用户不在 admin_list 中,且不是管理员,则按照 defaultConfig 返回
if (!adminConfig) {
if (defaultConfig && defaultConfig.allowcommand.includes(command)) {
return true;
}
}
}
return false;
}
// 删除关键词回复分支
async function deleteKeywordReply(session, filePath, keyword, config, specifiedIndex) {
if (!fs.existsSync(filePath)) {
await session.send(h.unescape(session.text(`.Keyword_does_not_exist`)));
return;
}
let data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// 转换关键词为小写
if (config.Treat_all_as_lowercase) {
keyword = keyword.toLowerCase();
}
// 查找关键词
let found = false;
let replies;
let matchedKey;
for (const key in data) {
const normalizedKey = config.Treat_all_as_lowercase ? key.toLowerCase() : key;
const strippedKey = normalizedKey.startsWith('regex:') ? normalizedKey.slice(6) : normalizedKey;
if (strippedKey === keyword) {
replies = data[key];
matchedKey = key;
found = true;
break;
}
}
if (!found) {
await session.send(h.unescape(session.text(`.Keyword_does_not_exist`, [keyword])));
return;
}
// 检查多段回复时,是否要求删除指定序号
if (config.Delete_Branch_Only && replies.length > 1) {
if (specifiedIndex === null || specifiedIndex === undefined) {
// 未指定删除的分支,直接提示用户使用删除命令
await session.send(`关键词 "${keyword}" 有多个回复,请指定需要删除的分支。\n请使用如下指令来删除指定序号的回复:\n删除 -q [数字] ${keyword}`);
return;
} else if (specifiedIndex > 0 && specifiedIndex <= replies.length) {
// 删除指定序号的回复
replies.splice(specifiedIndex - 1, 1);
// 如果删除后没有回复了,则删除整个关键词
if (replies.length === 0) {
delete data[matchedKey];
await session.send(h.unescape(session.text(`.Reply_deleted`, [keyword])));
} else {
await session.send(h.unescape(`已删除关键词 "${keyword}" 的第 ${specifiedIndex} 条回复。`));
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
return;
} else {
await session.send(`无效的回复序号,请指定正确的序号。`);
return;
}
} else {
// 不使用分支删除,直接删除整个关键词
delete data[matchedKey];
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
await session.send(h.unescape(session.text(`.Reply_deleted`, [keyword])));
}
}
function escapeRegExp(string) {
return string
.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') // 转义正则元字符
.replace(/\s/g, '\\s') // 转义空白字符
.replace(/\\\\/g, '\\\\'); // 双反斜杠只处理一次
}
async function addKeywordReply(session, filePath, keyword, config, options) {
let data;
try {
data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch (err) {
data = {};
}
// 将关键词转换为小写
if (config.Treat_all_as_lowercase) {
keyword = keyword.toLowerCase();
}
const addKeyword = options.unescape ? `regex:${keyword}` : `regex:${escapeRegExp(keyword)}`;
const key = (options.regex || options.unescape) ? addKeyword : keyword;
if (!data[key]) {
data[key] = [];
}
if (data[key].length > 0 && config.HandleDuplicateKeywords === '3') {
await session.send(h.unescape(session.text(`.Keyword_exists`, [keyword])));
return;
}
if (config.HandleDuplicateKeywords === '1') {
data[key] = [];
}
if (config.AlwayPrompt === '2' || config.AlwayPrompt === '3') {
await session.send(h.text(config.Prompt));
}
let currentReplies = [];
// 如果 MatchPatternForExit 为 '1',则直接等待用户输入一次回复内容
if (config.MatchPatternForExit === '1') {
const timeout = config.addKeywordTime * 60000; // 转换为毫秒
const reply = await session.prompt(timeout);
if (reply.includes(KeywordOfEsc)) {
await session.send(h.text(session.text(`.Cancel_operation`)));
return;
}
if (!reply) {
await session.send(h.text(session.text(`.Input_Timeout`)));
return;
}
const replyData = await parseReplyContent(reply, root, session, options);
currentReplies.push(...replyData);
// 将回复内容保存到 data 中
data[key].push(currentReplies);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
await session.send(h.unescape((session.text(`.Reply_added`, [keyword]))));
return;
}
while (true) {
if (config.AlwayPrompt === '3') {
await session.send(h.text(config.Prompt));
}
const timeout = config.addKeywordTime * 60000; // 转换为毫秒
const reply = await session.prompt(timeout);
// 检查是否输入了取消添加的关键词
if (reply.includes(KeywordOfEsc)) {
await session.send(h.text(session.text(`.Cancel_operation`)));
return;
}
if ((config.MatchPatternForExit === '2' && reply === KeywordOfEnd) ||
(config.MatchPatternForExit === '3' && reply.includes(KeywordOfEnd)) ||
(config.MatchPatternForExit === '4' && (reply === KeywordOfEnd || reply.includes(KeywordOfEnd)))) {
break;
}
if (!reply) {
await session.send(h.text(session.text(`.Input_Timeout`)));
return;
}
const replyData = await parseReplyContent(reply, root, session, options);
currentReplies.push(...replyData);
}
data[key].push(currentReplies);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
await session.send(h.unescape((session.text(`.Reply_added`, [keyword]))));
}
async function downloadImage(url, outputPath, session, isGlobal) {
try {
let absoluteOutputPath;
if (isGlobal) {
absoluteOutputPath = path.resolve(outputPath, 'global'); // 为全局关键词设定专门的文件夹
} else {
absoluteOutputPath = path.resolve(outputPath, session.channelId); // 使用群聊ID作为路径
}
// 如果目录不存在,则创建目录
if (!fs.existsSync(absoluteOutputPath)) {
fs.mkdirSync(absoluteOutputPath, { recursive: true });
}
// 生成文件名,使用时间戳命名
const timestamp = Date.now();
let fileName = `${timestamp}.${config.defaultImageExtension}`;
absoluteOutputPath = path.join(absoluteOutputPath, fileName);
// 获取图片数据
const response = await ctx.http.get(url, { responseType: 'arraybuffer' });
const buffer = Buffer.from(response);
// 写入文件
await fs.promises.writeFile(absoluteOutputPath, buffer);
const imageURL = pathToFileURL(absoluteOutputPath).href;
logInfo(imageURL)
// 返回图片的绝对路径
return imageURL;
} catch (error) {
logger.error(`下载图片失败: ${error.message}`);
throw error;
}
}
async function downloadImageAsBase64(imagePath) {
try {
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
// 处理网络图片链接
const response = await ctx.http.get(imagePath, { responseType: 'arraybuffer' });
const buffer = Buffer.from(response);
const base64String = buffer.toString('base64');
return base64String;
} else {
// 去掉 'file:///','file://' 和 'file:/' 前缀
if (imagePath.startsWith('file:///')) {
imagePath = imagePath.slice(8);
} else if (imagePath.startsWith('file://')) {
imagePath = imagePath.slice(7);
} else if (imagePath.startsWith('file:/')) {
imagePath = imagePath.slice(6);
}
const imageBuffer = fs.readFileSync(imagePath);
// 将图片 buffer 转换为 Base64 字符串
const base64String = imageBuffer.toString('base64');
return base64String;
}
} catch (error) {
logger.error('Error converting image to base64:', error);
return null;
}
}
async function formatReply(reply, forwardreturn = false) {
let formattedReply;
if (reply.type === 'img' || reply.type === 'image') {
if (config.picture_save_to_local_send === '3') {
const base64fileData = await downloadImageAsBase64(reply.text);
formattedReply = h.image('data:image/png;base64,' + base64fileData);
} else if (config.picture_save_to_local_send === '4') {
formattedReply = h.image('data:image/png;base64,' + reply.text);
} else {
formattedReply = h.image(reply.text);
}
} else if (reply.type === 'mface') {
formattedReply = h.image(reply.text);
} else if (reply.type === 'text') {
formattedReply = h.text(reply.text);
} else if (reply.type === 'audio') {
formattedReply = h.audio(reply.text);
} else if (reply.type === 'video') {
formattedReply = h.video(reply.text);
} else if (reply.type === 'at') {
formattedReply = h.at(reply.text);
} else if (reply.type === 'unknown') {
formattedReply = reply.text;
}
const returnformatReply = forwardreturn ? h('message', {}, formattedReply) : formattedReply;
logInfo("returnformatReply:")
logInfo(returnformatReply)
return returnformatReply;
};
function isValidKeyword(keyword) {
return keyword && keyword.trim().length > 0;
}
function calculateFontSize(numKeywords, maxFontSize, minFontSize) {
const m = (minFontSize - maxFontSize) / (40 - 1);
const b = maxFontSize - m * 1;
return m * numKeywords + b;
}
// 查看关键词列表指令
ctx.command(`keyword-dialogue/${ViewKeywordList}`)
.action(async ({ session }) => {
if (!hasPermission(session, '查看关键词列表')) {
await session.send(session.text(".channel_admin_auth"));
return;
}
// 根据配置项确定搜索范围
const searchFiles = (() => {
switch (config.Search_Range) {
case '1':
return [`${session.channelId}.json`]; // 仅搜索当前频道
case '2':
return fs.readdirSync(root).filter(file => file.endsWith('.json')); // 搜索所有文件
case '3':
return [`${session.channelId}.json`, 'global.json']; // 搜索当前频道和全局
default:
return [];
}
})();
let keywords = [];
for (const file of searchFiles) {
const filePath = path.join(root, file);
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
keywords = keywords.concat(Object.keys(data));
}
}
keywords = Array.from(new Set(keywords)); // 去重
if (keywords.length === 0) {
await session.send('当前没有关键词。');
return;
}
if (config.Type_of_ViewKeywordList === '1') {
// 返回文字列表
const message = keywords.join('\n');
await session.send(h.text(message));
} else if (config.Type_of_ViewKeywordList === '2') {
// 分页处理
const pages = [];
for (let i = 0; i < keywords.length; i += 40) {
pages.push(keywords.slice(i, i + 40));
}
for (const pageKeywords of pages) {
const page = await ctx.puppeteer.page();
// 设置页面内容
const fontSize = calculateFontSize(pageKeywords.length, 120, 40);
const content = `
<html>
<head>
<style>
body {
background-color: black;
color: white;
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
padding: 20px;
width: 1080px;
height: 1920px;
box-sizing: border-box;
}
.keyword {
word-wrap: break-word;
text-align: left;
white-space: pre-wrap;
background-color: #333;
padding: 10px;
border-radius: 5px;
font-size: ${fontSize}px; /* 使用计算出的字体大小 */
}
</style>
</head>
<body>
<div class="container">
${pageKeywords.map(keyword => `<div class="keyword">${keyword}</div>`).join('')}
</div>
</body>
</html>
`;
await page.setContent(content);
await page.setViewport({ width: 1080, height: 1920 });
const imageBuffer = await page.screenshot({ encoding: "binary" });
await page.close();
const imageMessage = h.image(imageBuffer, "image/png");
await session.send(imageMessage);
}
}
});
// 搜索关键词
ctx.command(`keyword-dialogue/${KeywordOfSearch} [Keyword]`)
.action(async ({ session }, Keyword) => {
if (!hasPermission(session, '查找关键词')) {
await session.send(session.text(".channel_admin_auth"));
return;
}
if (!Keyword) {
await session.send(h.text(session.text(`.no_Valid_Keyword`)));
return;
}
const searchRange = config.Search_Range;
const returnPreset = config.Find_Return_Preset;
const returnLimit = config.Return_Limit;
const searchFiles = (() => {
switch (searchRange) {
case '1':
return [`${session.channelId}.json`];
case '2':
return fs.readdirSync(root).filter(file => file.endsWith('.json'));
case '3':
return [`${session.channelId}.json`, 'global.json'];
default:
return [`${session.channelId}.json`]; // 默认本频道内搜索
}
})();
let results = [];
for (const file of searchFiles) {
const filePath = path.join(root, file);
if (fs.existsSync(filePath)) {
// 读取文件内容
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// 搜索关键词
for (const key in data) {
const normalizedKey = config.Treat_all_as_lowercase ? key.toLowerCase() : key;
if (normalizedKey.includes(Keyword.toLowerCase())) {
const channelId = file.replace('.json', '');
// 使用 formatReply 处理每个回复组,确保图片和文字格式化正确
const formattedReplies = await Promise.all(data[key].map(async replyGroup => {
let combinedReply = '';
for (const reply of replyGroup) {
combinedReply += await formatReply(reply, false); // 使用 formatReply 函数处理图片和文本
}
return combinedReply.trim();
}));
if (returnPreset === '1') {
results.push(h.unescape(session.text(`.Keyword_found_content_only`, [key, formattedReplies.join('\n\n')])));
} else if (returnPreset === '2') {
results.push(h.unescape(session.text(`.Keyword_found_in_channel`, [channelId, key])));
} else if (returnPreset === '3') {
results.push(h.unescape(session.text(`.Keyword_found`, [channelId, key, formattedReplies.join('\n\n')])));
}
if (returnLimit === '2') {
break;
}
}
}
}
if (returnLimit === '2' && results.length > 0) {
break;
}
}
if (results.length === 0) {
await session.send(session.text(`.Keyword_not_found`, [Keyword]));
} else {
await session.send(results.join('\n\n'));
}
});
// 删除关键词
ctx.command(`keyword-dialogue/${delete_command} [Keyword]`)
.alias('删除关键词')
.option('question', '-q [number] 指定删除回复的序号')
.action(async ({ session, options }, Keyword) => {
if (!hasPermission(session, '删除')) {
await session.send(session.text(".channel_admin_auth"));
return;
}
if (!Keyword) {
await session.send(h.text(session.text(`.no_Valid_Keyword`)));
return;
}
const filePath = path.join(root, `${session.channelId}.json`);
const specifiedIndex = options.question ? options.question : null;
await deleteKeywordReply(session, filePath, Keyword, config, specifiedIndex);
});
// 全局删除关键词
ctx.command(`keyword-dialogue/${global_delete_command} [Keyword]`)
.alias('全局删除关键词')
.option('question', '-q [number] 指定删除回复的序号')
.action(async ({ session, options }, Keyword) => {
if (!hasPermission(session, '全局删除')) {
await session.send(session.text(".channel_admin_auth"));
return;
}
if (!Keyword) {
await session.send(h.text(session.text(`.no_Valid_Keyword`)));
return;
}
const filePath = path.join(root, 'global.json');
const specifiedIndex = options.question ? options.question : null;
await deleteKeywordReply(session, filePath, Keyword, config, specifiedIndex);
});
// 全局添加关键词
ctx.command(`keyword-dialogue/${global_add_command} [Keyword]`)
.alias('全局添加关键词')
.option('regex', '-x 添加正则关键词')
.option('global', '-g 添加全局关键词')
.option('unescape', '-u 取消转义以使用自定义正则')
.option('forward', '-f <number> 指定回复方式:1.按照原版输入,原版输出 2.合为图文消息 3.合为一条图文消息的合并转发 4.按照原版输入,合并转发发送')
.example("全局添加关键词 使用教程 -x -f 2")
.action(async ({ session, options }, Keyword) => {
if (!hasPermission(session, '全局添加')) {
await session.send(session.text(".channel_admin_auth"));
return;
}
if (!isValidKeyword(Keyword)) {
await session.send(h.text(session.text(`.no_Valid_Keyword`)));
return;
}
options.global = true;
const filePath = path.join(root, 'global.json');
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify({}, null, 2), 'utf-8');
}
await addKeywordReply(session, filePath, Keyword.trim(), config, options);
});
//添加关键词
ctx.command(`keyword-dialogue/${add_command} [Keyword]`)
.alias('添加关键词')
.option('global', '-g 添加全局关键词')
.option('regex', '-x 添加正则关键词')
.option('unescape', '-u 取消转义以使用自定义正则')
.option('forward', '-f <number> 指定回复方式:1.按照原版输入,原版输出 2.合为图文消息 3.合为一条图文消息的合并转发 4.按照原版输入,合并转发发送')
.example("添加关键词 使用教程 -x -f 2")
.action(async ({ session, options }, Keyword) => {
if (!hasPermission(session, '添加')) {
await session.send(session.text(".channel_admin_auth"));
return;
}
if (!isValidKeyword(Keyword)) {
await session.send(h.text(session.text(`.no_Valid_Keyword`)));
return;
}
const filePath = options.global ? path.join(root, 'global.json') : path.join(root, `${session.channelId}.json`);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify({}, null, 2), 'utf-8');
}
await addKeywordReply(session, filePath, Keyword.trim(), config, options);
});
// 修改问答
ctx.command(`keyword-dialogue/${KeywordOfFix} [Keyword]`)
.option('global', '-g 全局修改关键词')
.option('question', '-q [number] 指定回复序号')
.action(async ({ session, options }, Keyword) => {
if (!hasPermission(session, '修改')) {
await session.send(session.text(".channel_admin_auth"));
return;
}
if (!isValidKeyword(Keyword)) {
await session.send(h.text(session.text(`.no_Valid_Keyword`)));
return;
}
const filePath = options.global
? path.join(root, 'global.json')
: path.join(root, `${session.channelId}.json`);
if (!fs.existsSync(filePath)) {
await session.send(h.text("未找到相关问答数据。"));
return;
}
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
const key = config.Treat_all_as_lowercase ? Keyword.toLowerCase() : Keyword;
const replies = data[key];
if (!replies) {
await session.send(h.text(`关键词 "${Keyword}" 不存在。`));
return;
}
const index = options.question ? options.question - 1 : 0;
if (index < 0 || index >= replies.length) {
await session.send(h.text(`指定的回复序号无效,请提供正确的序号。`));
return;
}
// 提示用户正在修改哪一条回复以及显示该回复内容
await session.send(h.unescape(`您正在修改的是【${Keyword}】的第【${index + 1}】条回复`));
// 获取当前需要修改的回复内容
const currentReply = replies[index];
// 定义一个变量存储完整的格式化内容
let fullReplyContent = '';
// 遍历所有的回复段落并格式化显示
for (const replyPart of currentReply) {
const formattedReply = await formatReply(replyPart, false);
fullReplyContent += formattedReply;
}
// 将完整的内容发送给用户
await session.send(fullReplyContent.trim());
// 提示用户输入新的回复内容
await session.send(h.text("请一次性将回复内容完整输入以修改:\n➣输入 取消添加 以取消"));
const timeout = config.addKeywordTime * 60000; // 转换为毫秒
const reply = await session.prompt(timeout);
if (reply.includes(KeywordOfEsc)) {
await session.send(h.text(session.text(`.Cancel_operation`)));
return;
}
if (!reply) {
await session.send(h.text(session.text(`.Input_Timeout`)));
return;
}
const replyData = await parseReplyContent(reply, root, session, options);
data[key][index] = replyData; // 修改指定的回复
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
await session.send(h.unescape((session.text(`.Reply_added`, [Keyword]))));
});
// 全局修改问答
ctx.command(`keyword-dialogue/${GlobalKeywordOfFix} [Keyword]`)
.option('question', '-q [number] 指定回复序号')
.action(async ({ session, options }, Keyword) => {
if (!hasPermission(session, '全局修改')) {
await session.send(session.text(".channel_admin_auth"));
return;
}
if (!isValidKeyword(Keyword)) {
await session.send(h.text(session.text(`.no_Valid_Keyword`)));
return;
}
const filePath = path.join(root, 'global.json');
if (!fs.existsSync(filePath)) {
await session.send(h.text("未找到相关问答数据。"));
return;
}
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
const key = config.Treat_all_as_lowercase ? Keyword.toLowerCase() : Keyword;
const replies = data[key];
if (!replies) {
await session.send(h.text(`关键词 "${Keyword}" 不存在。`));
return;
}
const index = options.question ? options.question - 1 : 0;
if (index < 0 || index >= replies.length) {
await session.send(h.text(`指定的回复序号无效,请提供正确的序号。`));
return;
}
// 提示用户正在修改哪一条回复以及显示该回复内容
await session.send(h.unescape(`您正在修改的是【${Keyword}】的第【${index + 1}】条回复`));
// 获取当前需要修改的回复内容
const currentReply = replies[index];
// 定义一个变量存储完整的格式化内容
let fullReplyContent = '';
// 遍历所有的回复段落并格式化显示
for (const replyPart of currentReply) {
const formattedReply = await formatReply(replyPart, false);
fullReplyContent += formattedReply;
}
// 将完整的内容发送给用户
await session.send(fullReplyContent.trim());
// 提示用户输入新的回复内容
await session.send(h.text("请一次性将回复内容完整输入以修改:\n➣输入 取消添加 以取消"));
const timeout = config.addKeywordTime * 60000; // 转换为毫秒
const reply = await session.prompt(timeout);
if (reply.includes(KeywordOfEsc)) {
await session.send(h.text(session.text(`.Cancel_operation`)));
return;
}
if (!reply) {
await session.send(h.text(session.text(`.Input_Timeout`)));
return;
}
const replyData = await parseReplyContent(reply, root, session, options);
data[key][index] = replyData; // 修改指定的回复
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
await session.send(h.unescape((session.text(`.Reply_added`, [Keyword]))));
});
const middlewareFunction = async (session, next) => {
let { channelId, platform } = session;
let anothercontent = unescapeHtml(session.content).trim();
// 移除开头的at内容
if (platform === 'qq' || platform === 'qqguild') {
anothercontent = unescapeHtml(session.stripped.content).trim();
}
// 将输入内容转换为小写
if (config.Treat_all_as_lowercase) {
anothercontent = anothercontent.toLowerCase();
}
// 统一at消息的格式
if (config.Unified_at_field) {
anothercontent = anothercontent.replace(/<at id="(\d+)" name="[^"]*"\s*\/>/g, '<at id="$1"/>');
}
logInfo(`用户输入内容为\n${anothercontent}`)
const globalFilePath = path.join(root, 'global.json');
const channelFilePath = path.join(root, `${channelId}.json`);
// 添加前缀处理逻辑
const getPrefixedKeywords = (keyword, prefixes) => {
return prefixes.map(prefix => prefix + keyword);
};
// 获取关键词的指定后缀,支持【关键词+序号】与【关键词+空格+序号】来指定触发回复的第几条
const getSuffixIndex = (inputKeyword, baseKeyword) => {
const suffixPattern = new RegExp(`^${escapeRegExp(baseKeyword)}\\s*(\\d+)$`);
const match = inputKeyword.match(suffixPattern);
if (match) {
return parseInt(match[1], 10); // 返回指定的序号
}
return null; // 没有匹配后缀
};
const sendReplies = async (session, replyGroup) => {
// 检查每个回复项是否有 replyway 字段
const replyway = replyGroup[0]?.replyway || config.MultisegmentAdditionRecoveryEffect;
if (replyway === '1') {
for (const reply of replyGroup) {
logInfo(reply);
const formattedReply = await formatReply(reply, false);
await session.send(formattedReply); // 逐条发送
}
} else if (replyway === '2') {
let combinedReply = '';
for (const reply of replyGroup) {
combinedReply += await formatReply(reply, false); // 累加
}
combinedReply = combinedReply.trim();
logInfo(combinedReply);
await session.send(combinedReply); // 发送累加的图文消息
} else if (replyway === '3') {
const result = h('figure');
for (const reply of replyGroup) {
const formattedReply = await formatReply(reply, false);
result.children.push(formattedReply);
}
logInfo(result);
await session.send(result); // 发送合并转发消息
} else if (replyway === '4') {
const result = h('figure');
for (const reply of replyGroup) {
const formattedReply = await formatReply(reply, true);
result.children.push(formattedReply);
}
logInfo(result);
await session.send(result); // 发送合并转发消息
}
};
const checkAndSendRandomReply = async (filePath) => {
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
for (const keyword in data) {
const replies = data[keyword];
let isMatch = false;
// 获取当前关键词对应的所有可能的带前缀的关键词
const prefixedKeywords = getPrefixedKeywords(keyword, config.prefix || ["", "/", "#"]);
// 遍历所有带前缀的关键词,看是否匹配
for (const prefixedKeyword of prefixedKeywords) {
const suffixIndex = getSuffixIndex(anothercontent, prefixedKeyword);
if (keyword.startsWith('regex:')) {
const regexPattern = keyword.substring(6);
const regex = new RegExp(regexPattern);
isMatch = regex.test(anothercontent);
} else {
// 新增:根据后缀序号来判断匹配情况
if (anothercontent === prefixedKeyword) {
isMatch = true; // 直接匹配
} else if (suffixIndex !== null && suffixIndex > 0 && suffixIndex <= replies.length) {
// 指定序号范围内
await sendReplies(session, replies[suffixIndex - 1]);
return true;
}
}
if (isMatch) {
const now = Date.now();
const key = config.Type_of_restriction === '2' ? `${keyword}:${channelId}` : keyword;
if (lastTriggerTimes[key] && now - lastTriggerTimes[key] < config.Frequency_limitation * 1000) {
logInfo("间隔时间未到,不触发回复");
return true; // 间隔时间未到,不触发回复
}
lastTriggerTimes[key] = now;
const randomReplyGroup = replies[Math.floor(Math.random() * replies.length)];
await sendReplies(session, randomReplyGroup);
return true;
}
}
}
}
return false;
};
if (await checkAndSendRandomReply(globalFilePath) || await checkAndSendRandomReply(channelFilePath)) {
return;
}
return next();
};
const unescapeHtml = (str) => {
return str.replace(/&|<|>|"|'/g, (match) => {
const unescapeMap = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
''': "'"
};
return unescapeMap[match];
});
};
if (config.Preposition_middleware) {
ctx.middleware(middlewareFunction, true); // 使用前置中间件
} else {
ctx.middleware(middlewareFunction); // 使用普通中间件
}
}
exports.apply = apply;