UNPKG

@shangxueink/koishi-plugin-qq-markdown-button

Version:

[<ruby>**QQ机器人按钮菜单**<rp>(</rp><rt>点我查看使用说明</rt><rp>)</rp></ruby>](https://www.npmjs.com/package/@shangxueink/koishi-plugin-qq-markdown-button) 自用小插件咪~ 使用json文件设置你的机器人菜单这样就不需要一堆配置项还很烧脑了。自用插件哦~

504 lines (456 loc) 27.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.apply = exports.Config = exports.usage = exports.inject = exports.name = void 0; const { Schema, Logger, h } = require("koishi"); const fs = require('fs'); const path = require('path'); exports.name = "qq-markdown-button"; exports.reusable = true; // 声明此插件可重用 exports.inject = []; exports.inject = { // required: [''], optional: ["database"] }; exports.usage = ` <div> <p>本插件可帮助你自定义QQ官方机器人按钮菜单,支持以下三种类型的菜单配置:</p> <ol> <li><strong>JSON 按钮</strong>:可以发送带有交互按钮的JSON消息。</li> <li><strong>被动模板 Markdown</strong>:适用于发送自定义的Markdown模板消息。</li> <li><strong>原生 Markdown</strong>:支持发送更复杂的原生Markdown消息。</li> </ol> <h3>如何配置</h3> <ul> <li>在左侧活动栏找到【资源管理器】->【data】->【qq-markdown-button】->【按钮菜单配置1】目录,在该目录下,你会看到对应的文件夹下有<code>.md</code> 和 <code>.json</code> 文件。</li> <li>根据你选择的菜单类型,编辑对应的 <code>.md</code> 和 <code>.json</code> 文件,修改你的菜单配置。</li> </ul> <h3>关于变量替换</h3> <p>在配置文件(例如 <code>.json</code>)中,你可能会看到一些变量占位符,如:</p> <ul> <li><code>\${session.messageId}</code>:运行时会替换为当前会话的消息ID。</li> <li><code>\${INTERACTION_CREATE}</code>:运行时会替换为当前回调按钮的interaction_id。</li> <li><code>\${markdown}</code>:会被替换为从对应 <code>.md</code> 文件读取的Markdown内容。</li> </ul> <p>无需手动修改这些变量,它们将在运行时自动替换为相应的真实值。</p> <h3>新增功能说明</h3> <h4>1. 频道数据导入导出功能</h4> <ul> <li><strong>导出功能</strong>:可以将当前数据库中的QQ频道数据导出为JSON文件。</li> <li><strong>导出的文件地址</strong>:<code>data/qq-markdown-button/按钮菜单配置1/channelofqq.json</code>。</li> <li><strong>导入功能</strong>:可以将导出的JSON文件内容导入到数据库中,支持增量导入(跳过已存在的频道数据)。</li> <li><strong>导入的内容地址</strong>:<code>data/qq-markdown-button/按钮菜单配置1/channelofqq.json</code>。</li> </ul> <h4>2. 屏蔽广播的频道ID列表功能</h4> <ul> <li>在广播消息时,可以指定某些频道ID不进行广播。</li> <li>这些频道ID会被添加到屏蔽列表中,广播时会自动跳过这些频道。</li> <li>屏蔽列表可以在配置项中设置,支持动态修改。</li> </ul> <h4>3. 群组广播间隔功能</h4> <ul> <li>在广播消息时,可以设置每个群组的广播间隔时间。</li> <li>间隔时间以毫秒为单位,例如:<code>1000</code> 表示1秒,<code>100</code> 表示0.1秒。</li> <li>该功能可以有效控制广播速度,避免对服务器造成过大压力。</li> </ul> <p>支持重用,你可以开多个这个插件,然后改成不同的指令名称/文件夹名称,以注册多个按钮菜单功能</p> <p>本插件会自动使用对应的文件夹下的 json / markdown 文件来发送消息<br>使用多重配置时,你通常只需要修改 <code>按钮菜单配置1</code> 那一行</p> <p>不要手动重命名 json/md文件!</p> <hr> <p>赶快选择你需要的配置,开始自定义你的菜单吧!</p> <p>更多说明 <a href="https://github.com/shangxueink/koishi-shangxue-apps/tree/main/plugins/qq-markdown-button" target="_blank">详见➩项目README</a></p> <p>相关链接:</p> <ul> <li><a href="https://github.com/shangxueink/koishi-shangxue-apps/tree/main/plugins/qq-markdown-button" target="_blank">https://github.com/shangxueink/koishi-shangxue-apps/tree/main/plugins/qq-markdown-button</a></li> <li><a href="https://forum.koishi.xyz/t/topic/10439" target="_blank">https://forum.koishi.xyz/t/topic/10439</a></li> </ul> </div> `; exports.Config = Schema.intersect([ Schema.object({ command_name: Schema.string().default('按钮菜单').description('注册的指令名称'), markdown_id: Schema.string().default('123456789_1234567890').description('markdown模板的ID'), json_button_id: Schema.string().default('123456789_1234567890').description('按钮模板的ID'), }).description('基础设置'), Schema.object({ file_name: Schema.array(String).role('table').description('存储文件的文件夹名称<br>请依次填写 相对于`koishi根目录`的 **文件夹** 路径<br>本插件会自动使用对应的文件夹下的 json / markdown 文件来发送消息<br>使用多重配置时,你通常只需要修改 `按钮菜单配置1` 那一行') .default([ "data", "qq-markdown-button", "按钮菜单配置1" ]), type_switch: Schema.union([ Schema.const('json').description('json按钮(./json/json.json)'), Schema.const('markdown').description('被动md,模板md(./markdown/markdown.json)'), Schema.const('raw').description('原生md(./raw/raw_markdown.json 、 ./raw/raw_markdown.md)'), ]).role('radio').description('选择菜单发送方式。<br>即 使用的json文件'), }).description('发送设置'), Schema.object({ Allow_INTERACTION_CREATE: Schema.boolean().default(false).description("是否自动执行所有回调按钮内容(通过`session.execute`)"), }).description('高级设置'), Schema.object({ broadcast: Schema.boolean().default(false).description("是否遍历数据库qq平台的`群组` 以实现广播推送主动消息 `谨慎开启!`"), }).description('广播设置'), Schema.union([ Schema.object({ broadcast: Schema.const(true).required(), broadcastcooldowntime: Schema.number().default(100).description("每个群组的广播间隔(毫秒)。<br>例如:` 1000 即为1秒,100 即为0.1秒`"), broadcastblakclist: Schema.array(String).role('table').description("屏蔽广播的频道ID列表。<br>广播时 不对下面的群组处理。不影响其他情况。"), antitouchCooldown: Schema.number().default(30).description("指令可用间隔(分钟)。防止误触导致的多次触发。"), }), Schema.object({ }), ]), Schema.object({ consoleinfo: Schema.boolean().default(false).description("日志调试模式`推荐主动广播时开启,以查看日志错误`"), }).description('调试设置'), Schema.object({ channelcommand: Schema.boolean().default(false).description("是否启用 数据库channel导入导出功能。<br>多开 本插件时 务必 注意 指令 不要重复。").experimental(), }).description('数据导入设置'), Schema.union([ Schema.object({ channelcommand: Schema.const(true).required(), command_name_channelout: Schema.string().default('channel导出').description('导出channel表 注册的指令名称<br>注意数据会导出到`file_name`配置项下的`channelofqq.json`文件。').experimental(), command_name_channelin: Schema.string().default('channel导入').description('导入channel表 注册的指令名称<br>注意数据会从`file_name`配置项下的`channelofqq.json`文件导入到你的数据库!请提前放好文件。').experimental(), }), Schema.object({}), ]), Schema.object({ RangeBroadcasting: Schema.boolean().default(false).description("范围广播测试。模拟数据库的全部群组数据`非开发者请勿改动`").experimental(), }).description('开发者选项'), Schema.union([ Schema.object({ RangeBroadcasting: Schema.const(true).required(), RangeBroadcastList: Schema.array(String).role('table').description("范围广播测试。模拟全部群组数据。填入所需广播的频道ID。`非开发者请勿改动`").experimental(), }), Schema.object({}), ]), ]) function apply(ctx, config) { // 用于存储上次广播的时间戳 let lastBroadcastTime = 0; // 用于存储已发送消息的 channelId,每次广播后重新填充并逐步清空 let sentChannelIds = new Set(); // 广播冷却时间 (15 分钟) const broadcastCooldown = config.antitouchCooldown * 60 * 1000; ctx.on('ready', () => { // 使用配置项中的 file_name 数组构建 baseDir 路径 const baseDirArray = [ctx.baseDir].concat(config.file_name); const baseDir = path.join(...baseDirArray); logInfo(baseDir) // 确保目录存在,如果不存在则创建 (包括子目录) if (!fs.existsSync(baseDir)) { fs.mkdirSync(baseDir, { recursive: true }); } const filesToCopy = { json: ['json.json'], markdown: ['markdown.json'], raw: ['raw_markdown.json', 'raw_markdown.md'], }; // 复制文件到配置的目录下,并按照新的子目录结构存放 for (const type in filesToCopy) { filesToCopy[type].forEach(file => { const srcPath = path.join(__dirname, 'qq', type, file); // 源文件路径,根据新的目录结构调整 const destPath = path.join(baseDir, type, file); // 目标文件路径,保持新的目录结构 // 确保目标目录存在 const destDir = path.dirname(destPath); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } if (!fs.existsSync(destPath)) { fs.copyFileSync(srcPath, destPath); } }); } if (config.Allow_INTERACTION_CREATE) { ctx.on("interaction/button", async session => { const buttoncontent = session?.event?.button['data']; if (buttoncontent) { logInfo(`接收到回调按钮内容:\n${buttoncontent}`) try { session.qq.acknowledgeInteraction(session.event._data.id, { code: 0 }).catch(error => { // 非阻塞执行 ctx.logger.error(`执行 acknowledgeInteraction 时出错 (后台任务):`, error); // 只记录错误 }); await session.execute(`${buttoncontent}`) } catch (error) { ctx.logger.error(`执行 acknowledgeInteraction 时出错:`, error); } return } }) } ctx.command(`${config.command_name}`, '发送按钮菜单') .action(async ({ session }) => { if (!(session.platform === "qq" || session.platform === "qqguild")) { await session.send(`仅支持QQ官方平台使用本指令。`) return; } const type = config.type_switch; let INTERACTION_CREATE = session.event._data.id let Menu_message; try { let jsonFilePath, mdFilePath; if (type === 'json') { jsonFilePath = path.join(baseDir, 'json', 'json.json'); mdFilePath = null; // json 类型不需要 md 文件 } else if (type === 'markdown') { jsonFilePath = path.join(baseDir, 'markdown', 'markdown.json'); mdFilePath = null; // 被动模板 md 类型也不需要额外的 md 文件,内容在 json 中 } else if (type === 'raw') { jsonFilePath = path.join(baseDir, 'raw', 'raw_markdown.json'); mdFilePath = path.join(baseDir, 'raw', 'raw_markdown.md'); } Menu_message = await processMarkdownCommand(jsonFilePath, mdFilePath, session, config, { INTERACTION_CREATE: INTERACTION_CREATE }); logInfo("完整的 Menu_message 内容为:", Menu_message); if (!config.broadcast) { await sendsomeMessage(Menu_message, session); } else { // 检查是否在冷却时间内 (防止用户不小心多次触发指令) const now = Date.now(); if (now - lastBroadcastTime < broadcastCooldown) { const timeLeft = Math.ceil((broadcastCooldown - (now - lastBroadcastTime)) / 60000); await session.send(`广播消息冷却中,请 ${timeLeft} 分钟后再试。`); return; } // 执行广播消息 await sendbroadcastMessage(Menu_message, session); // 更新上次广播时间 lastBroadcastTime = now; } } catch (error) { ctx.logger.error(`处理命令时出错: ${error}`); } }); if (config.channelcommand) { ctx.command(`${config.command_name_channelout}`, '导出 QQ 频道数据') .action(async ({ session }) => { const baseDirArray = [ctx.baseDir].concat(config.file_name); const baseDir = path.join(...baseDirArray); const exportPath = path.join(baseDir, 'channelofqq.json'); try { const channels = await ctx.database.get('channel', { platform: "qq" }); const jsonData = JSON.stringify(channels, null, 2); // 确保目录存在 if (!fs.existsSync(baseDir)) { fs.mkdirSync(baseDir, { recursive: true }); } fs.writeFileSync(exportPath, jsonData); logInfo(`已将 ${channels.length} 条 QQ 频道数据导出到 ${exportPath}`); await session.send(`已将 ${channels.length} 条 QQ 频道数据导出到 ${exportPath}`); } catch (error) { ctx.logger.error(`导出 QQ 频道数据时出错:`, error); await session.send(`导出 QQ 频道数据时出错: ${error.message}`); } }); ctx.command(`${config.command_name_channelin}`, '导入 QQ 频道数据') .action(async ({ session }) => { const baseDirArray = [ctx.baseDir].concat(config.file_name); const baseDir = path.join(...baseDirArray); const importPath = path.join(baseDir, 'channelofqq.json'); try { if (!fs.existsSync(importPath)) { await session.send(`未找到导入文件 ${importPath}`); return; } const rawData = fs.readFileSync(importPath, 'utf-8'); const channels = JSON.parse(rawData); if (!Array.isArray(channels)) { await session.send('导入文件内容格式不正确,应为数组'); return; } const total = channels.length; let imported = 0; for (let i = 0; i < total; i++) { const channel = channels[i]; try { // 检查是否已存在相同 id 和 platform 的数据,避免重复插入 const existingChannels = await ctx.database.get('channel', { id: channel.id, platform: channel.platform }); if (existingChannels.length > 0) { logInfo(`频道 ${channel.id} (${channel.platform}) 已存在,跳过导入`); } else { await ctx.database.create('channel', channel); imported++; logInfo(`已导入频道 ${channel.id} (${channel.platform})`); } } catch (error) { ctx.logger.error(`导入频道 ${channel.id} (${channel.platform}) 时发生错误:${error.message}`); } const progress = Math.round(((i + 1) / total) * 100); logInfo(`导入进度:${progress}%`); // 添加 10ms 间隔 await new Promise(resolve => ctx.setTimeout(resolve, 10)); } logInfo(`数据库写入完成!成功导入 ${imported} 条频道数据,跳过 ${total - imported} 条已存在数据。`); await session.send(`数据库写入完成!成功导入 ${imported} 条频道数据,跳过 ${total - imported} 条已存在数据。`); } catch (error) { ctx.logger.error(`导入 QQ 频道数据时出错:`, error); await session.send(`导入 QQ 频道数据时出错: ${error.message}`); } }); } function logInfo(message, message2) { if (config.consoleinfo) { if (message2) { ctx.logger.info(message, message2) } else { ctx.logger.info(message); } } } async function sendbroadcastMessage(message, session) { try { let channels; if (config.RangeBroadcasting) { // 当 RangeBroadcasting 为 true 时,使用 RangeBroadcastList 中的频道 ID if (config.RangeBroadcastList && config.RangeBroadcastList.length > 0) { channels = config.RangeBroadcastList.map(channelId => ({ id: channelId })); // 模拟数据库返回的频道对象数组,每个对象至少包含 id 属性 logInfo(`[范围广播测试] 使用配置项 RangeBroadcastList 中的 ${channels.length} 个频道 ID 进行广播.`); } else { logInfo("[范围广播测试] RangeBroadcasting 已开启,但 RangeBroadcastList 为空,广播已取消。"); return; } } else { // 否则,从数据库获取所有 QQ 平台的群组列表 (原有逻辑) channels = await ctx.database.get('channel', { platform: "qq", }); } if (!channels || channels.length === 0) { logInfo("没有找到任何 QQ 群组频道,广播消息已取消。"); return; } const totalChannels = channels.length; logInfo(`开始向 ${totalChannels} 个 QQ 群组频道广播消息...`); // 在广播前,填充 sentChannelIds sentChannelIds = new Set(channels.map(channel => channel.id)); // 用于存储广播失败的群组及其原因 const failedChannels = {}; const startTime = Date.now(); // 遍历群组列表并发送消息 for (let i = 0; i < channels.length; i++) { const channel = channels[i]; try { const channelId = channel.id; // 从数据库记录中获取 channelId if (config.broadcastblakclist && config.broadcastblakclist.includes(channelId)) { logInfo(`群组频道 ${channelId} 在广播黑名单中,跳过发送。`); continue; // 跳过当前频道 } if (channelId && sentChannelIds.has(channelId)) { logInfo(`正在向群组频道 ${channelId} 发送广播消息...`); if (session.qq) { await session.qq.sendMessage(channelId, message); } else if (session.qqguild) { await session.qqguild.sendMessage(channelId, message); // 理论上广播到群组应该用 session.qq } const progress = Math.round(((i + 1) / totalChannels) * 100); logInfo(`已向群组频道 ${channelId} 发送广播消息。${config.broadcastcooldowntime} ms后广播下一个群组。当前进度${progress}%`); // 等待 broadcastcooldowntime 毫秒 await new Promise(resolve => ctx.setTimeout(resolve, config.broadcastcooldowntime)); } else { logInfo(`群组频道记录 ${channel} 缺少 channelId 或已发送,跳过发送。`); } } catch (error) { ctx.logger.error(`向群组频道 ${channel.id} 广播消息时出错:`, error); failedChannels[channel.id] = error.message || 'Unknown error'; // 出错了也继续广播 } finally { // 无论成功与否,都从 sentChannelIds 中移除,确保最终清空 if (channel.id) { logInfo("已移除channel.id:", channel.id); sentChannelIds.delete(channel.id); } else { logInfo("已移除channel:", channel); sentChannelIds.delete(channel); // 尝试删除 channel 对象本身,以防万一 } } } const endTime = Date.now(); const duration = Math.round((endTime - startTime) / 1000); // 总耗时,单位秒 const minutes = Math.floor(duration / 60); const seconds = duration % 60; let failedMessage = ""; if (Object.keys(failedChannels).length > 0) { failedMessage = "\n本次广播有以下群组未能成功广播:\n" + Object.entries(failedChannels) .map(([channelId, error]) => `${channelId}: ${error}`) .join('\n'); } logInfo(`QQ 群组频道广播消息发送完成。`); logInfo(`本次广播共耗时${minutes}分钟${seconds}秒。${failedMessage}`); // 清空 sentChannelIds sentChannelIds.clear(); } catch (error) { ctx.logger.error(`获取 QQ 群组频道列表或广播消息时出错:`, error); } } async function sendsomeMessage(message, session) { try { const { guild, user } = session.event; const { qq, qqguild, channelId } = session; if (guild?.id) { if (qq) { await qq.sendMessage(channelId, message); } else if (qqguild) { await qqguild.sendMessage(channelId, message); } } else if (user?.id && qq) { await qq.sendPrivateMessage(user.id, message); } } catch (error) { ctx.logger.error(`发送markdown消息时出错:`, error); } } function processMarkdownCommand(jsonFilePath, mdFilePath, session, config, variables = {}) { try { const rawJsonData = fs.readFileSync(jsonFilePath, 'utf-8'); let markdownContent = mdFilePath ? fs.readFileSync(mdFilePath, 'utf-8') : ''; const allVariables = { ...variables, session, config }; const replacePlaceholders = (data) => { if (typeof data === 'string') { return data.replace(/\$\{([^}]+)\}/g, (_, key) => { const keys = key.split('.').reduce((prev, curr) => prev && prev[curr], allVariables); return keys !== undefined ? keys : `$\{${key}\}`; }); } else if (Array.isArray(data)) { return data.map(replacePlaceholders); } else if (typeof data === 'object' && data !== null) { return Object.fromEntries( Object.entries(data).map(([k, v]) => [k, replacePlaceholders(v)]) ); } return data; }; markdownContent = replacePlaceholders(markdownContent).replace(/\n/g, ''); allVariables.markdown = markdownContent; let rawJsonObject = JSON.parse(rawJsonData); let replacedJsonObject = replacePlaceholders(rawJsonObject); // 在广播模式下,移除 msg_id 和 event_id 字段 if (config.broadcast || config.RangeBroadcasting) { delete replacedJsonObject.msg_id; delete replacedJsonObject.event_id; } else { // 根据 session.messageId 是否存在,动态删除 JSON 对象中不需要的 ID 字段 if (session.messageId) { if (replacedJsonObject.msg_id) { // 检查 msg_id 字段是否存在 // session.messageId 存在,删除 event_id delete replacedJsonObject.event_id; } } else { if (replacedJsonObject.event_id) { // 检查 event_id 字段是否存在 // session.messageId 不存在,删除 msg_id delete replacedJsonObject.msg_id; } } } return replacedJsonObject; } catch (error) { ctx.logger.error(`读取或解析文件时出错:`, error); return '处理文件时出错。'; } } }); } exports.apply = apply;