From e5bba729df5b4f8a0da4032cd2e5a4d2b169db2a Mon Sep 17 00:00:00 2001 From: walli Date: Thu, 12 Mar 2026 00:22:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E9=BD=90=20Go=20SDK=20?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=EF=BC=8C=E8=A1=A5=E9=BD=90=20Node=20SDK=20?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 认证机制重构 - 新增 OAuth2 AccessToken 认证(AppID + AppSecret) - Token 自动刷新(后台定时,带随机抖动和失败重试) - Token 缓存与防并发请求合并 - 支持自定义 Token 域名 ## 群消息 & C2C 消息 - 新增群消息 API(postGroupMessage / retractGroupMessage / shareGroupMessage) - 新增 C2C 私聊消息 API(postC2CMessage / retractC2CMessage / shareC2CMessage) - 新增 patchMessage(修改频道消息)、retractDMMessage(撤回私信) ## Webhook 支持 - 新增 Ed25519 签名验证与生成 - 新增 Webhook HTTP Handler(签名验证、回调校验、心跳回复、事件分发) ## 新消息类型 - Markdown、流式消息、并行消息、聊天记录、卡片消息 - 输入状态通知、消息操作按钮、键盘增强(订阅按钮、Modal) - 富媒体消息(视频/语音)、MessageType 枚举扩展 ## 新事件类型 - GROUP_AT_MESSAGE_CREATE、C2C_MESSAGE_CREATE - FRIEND_ADD / FRIEND_DEL、ENTER_AIO、SUBSCRIBE_MESSAGE_STATUS ## 补齐其他 API - createPrivateChannel、guildRoleMembers、getMessageSetting - cleanChannelAnnounce / cleanGuildAnnounce / cleanPins - deleteGuildMember 增强(加黑名单/撤回天数)、settingGuide ## 工程改进 - 升级 ESLint v7 → v9(flat config),移除 eslint-config-alloy - 完善 example 示例(命令系统、群/C2C/频道 handler) --- .eslintignore | 6 - .eslintrc.js | 35 --- .gitignore | 3 + .vscode/settings.json | 6 +- README.md | 101 ++++++++- eslint.config.mjs | 79 +++++++ example/.env.example | 12 + example/README.md | 104 ++++++++- example/commands/help.js | 50 +++++ example/commands/index.js | 20 ++ example/commands/media.js | 95 ++++++++ example/commands/registry.js | 80 +++++++ example/handlers/c2c.js | 153 +++++++++++++ example/handlers/group.js | 142 ++++++++++++ example/handlers/guild.js | 70 ++++++ example/index.js | 247 +++++++++++++++----- example/package.json | 5 + example/utils/message.js | 157 +++++++++++++ package.json | 21 +- rollup.config.js | 2 +- src/bot.ts | 5 +- src/client/session/session.ts | 56 +++-- src/client/websocket/websocket.ts | 77 ++++--- src/index.ts | 2 + src/interaction/index.ts | 5 + src/interaction/signature.ts | 116 ++++++++++ src/interaction/webhook.ts | 286 ++++++++++++++++++++++++ src/openapi/v1/announce.ts | 10 + src/openapi/v1/c2c-message.ts | 59 +++++ src/openapi/v1/channel.ts | 17 ++ src/openapi/v1/direct-message.ts | 18 ++ src/openapi/v1/group-message.ts | 59 +++++ src/openapi/v1/guild.ts | 35 ++- src/openapi/v1/message-setting.ts | 73 ++++++ src/openapi/v1/message.ts | 18 ++ src/openapi/v1/openapi.ts | 81 +++++-- src/openapi/v1/pins-message.ts | 5 + src/openapi/v1/resource.ts | 23 +- src/token/index.ts | 2 + src/token/token-source.ts | 250 +++++++++++++++++++++ src/types/openapi/index.ts | 13 +- src/types/openapi/v1/announce.ts | 4 + src/types/openapi/v1/channel.ts | 6 + src/types/openapi/v1/direct-message.ts | 2 + src/types/openapi/v1/group-message.ts | 272 ++++++++++++++++++++++ src/types/openapi/v1/guild.ts | 28 ++- src/types/openapi/v1/message-setting.ts | 43 ++++ src/types/openapi/v1/message.ts | 142 ++++++++++-- src/types/openapi/v1/pins-message.ts | 2 + src/types/openapi/v1/webhook.ts | 52 +++++ src/types/websocket-types.ts | 21 +- src/utils/utils.ts | 39 +++- test/openapi/v1/audio.spec.ts | 8 +- test/openapi/v1/guild.spec.ts | 3 +- test/openapi/v1/reaction.spec.ts | 18 +- 55 files changed, 2993 insertions(+), 245 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.js create mode 100644 eslint.config.mjs create mode 100644 example/.env.example create mode 100644 example/commands/help.js create mode 100644 example/commands/index.js create mode 100644 example/commands/media.js create mode 100644 example/commands/registry.js create mode 100644 example/handlers/c2c.js create mode 100644 example/handlers/group.js create mode 100644 example/handlers/guild.js create mode 100644 example/utils/message.js create mode 100644 src/interaction/index.ts create mode 100644 src/interaction/signature.ts create mode 100644 src/interaction/webhook.ts create mode 100644 src/openapi/v1/c2c-message.ts create mode 100644 src/openapi/v1/group-message.ts create mode 100644 src/openapi/v1/message-setting.ts create mode 100644 src/token/index.ts create mode 100644 src/token/token-source.ts create mode 100644 src/types/openapi/v1/group-message.ts create mode 100644 src/types/openapi/v1/message-setting.ts create mode 100644 src/types/openapi/v1/webhook.ts diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 48957b8..0000000 --- a/.eslintignore +++ /dev/null @@ -1,6 +0,0 @@ -dist -node_modules -example -es -lib -typings \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index b569b3f..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,35 +0,0 @@ -module.exports = { - extends: ['alloy', 'alloy/typescript'], - parserOptions: { - ecmaVersion: 2018, - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, - }, - plugins: ['jest'], - env: { - jest: true, - }, - rules: { - '@typescript-eslint/indent': 'off', - '@typescript-eslint/explicit-member-accessibility': 'off', - '@typescript-eslint/no-var-requires': 'off', - '@typescript-eslint/consistent-type-assertions': 'off', - '@typescript-eslint/typedef': 'off', - 'no-new-func': 'off', - '@typescript-eslint/no-empty-interface': 'off', - complexity: 'off', - '@typescript-eslint/no-this-alias': 'off', - '@typescript-eslint/no-require-imports': 'off', - '@typescript-eslint/prefer-optional-chain': 'off', - 'max-params': 'off', - 'no-param-reassign': 'off', - 'no-trailing-spaces': [ - 'error', - { - skipBlankLines: true, - }, - ], - }, -}; diff --git a/.gitignore b/.gitignore index 91f7b95..169c646 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ lib test-config package-lock.json example/config.json +.env +.codebuddy/ +pnpm-lock.yaml diff --git a/.vscode/settings.json b/.vscode/settings.json index c8415c6..88f7e16 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,8 +5,8 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "eslint.validate": ["javascript", "typescript",], "editor.codeActionsOnSave": { - "source.fixAll.eslint": true, - "source.fixAll": true, - "source.fixAll.stylelint": true + "source.fixAll.eslint": "explicit", + "source.fixAll": "explicit", + "source.fixAll.stylelint": "explicit" }, } diff --git a/README.md b/README.md index aa2b5f5..a732104 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,107 @@ -# QQ 频道机器人 SDK qq-guild-bot +# QQ Bot NodeSDK -用于开发 QQ 频道机器人的 Node.js SDK。 +QQ 机器人 Node.js SDK,支持频道、群、C2C 单聊场景。 -## 使用文档 +## 功能特性 -[NodeSDK 文档](https://bot.q.qq.com/wiki/develop/nodesdk/) +- **OAuth2 认证** — 基于 AppID + Secret 自动获取和刷新 AccessToken +- **WebSocket** — 长连接接收事件推送(频道 / 群 / C2C / 好友等) +- **Webhook** — HTTP 回调接收事件(Ed25519 签名验证) +- **频道消息 API** — 发送文本、Markdown、Ark、Embed、图片等 +- **群消息 API** — 发送群文本、Markdown、富媒体、流式消息等 +- **C2C 消息 API** — 单聊文本、富媒体(图片/语音/视频/文件) +- **富媒体上传** — 分步上传 + file_info 发送 +- **HTTPS 代理** — 支持 `https_proxy` 环境变量(CONNECT 隧道,方便 Whistle/Charles 抓包) + +## 安装 + +```bash +npm install qq-guild-bot +``` + +## 快速开始 + +```js +const { createOpenAPI, createWebsocket } = require('qq-guild-bot'); + +// 1. 创建 OpenAPI 客户端 +const client = createOpenAPI({ + appID: '你的AppID', + secret: '你的AppSecret', +}); + +// 2. 创建 WebSocket 连接 +const ws = createWebsocket({ + appID: '你的AppID', + secret: '你的AppSecret', + intents: ['GROUP_MESSAGES'], +}); + +// 3. 监听事件 +ws.on('READY', (data) => { + console.log('机器人已上线:', data); +}); + +ws.on('GROUP_MESSAGES', async (eventData) => { + if (eventData.eventType === 'C2C_MESSAGE_CREATE') { + const msg = eventData.msg; + await client.c2cMessageApi.postC2CMessage(msg.author.id, { + content: '收到!', + msg_type: 0, + msg_id: msg.id, + }); + } +}); +``` + +## 示例项目 + +`example/` 目录包含一个完整的模块化示例,覆盖 C2C / 群 / 频道场景: + +```bash +# 先构建 SDK +npm run build + +# 进入示例目录 +cd example +cp .env.example .env # 填入 AppID 和 Secret +npm install +npm run dev +``` + +详见 [example/README.md](example/README.md)。 ## 本地开发 -```shell -# clone repo +```bash +# 克隆仓库 git clone https://github.com/tencent-connect/bot-node-sdk.git - -# cd repo cd bot-node-sdk -# run +# 安装依赖 +npm install + +# 开发模式(watch) npm run dev -# run example -npm run linkdev +# 生产构建 +npm run build + +# 运行示例 npm run example + +# 运行测试 +npm run test ``` +> **注意**:修改 SDK 源码后需要 `npm run build`,然后在 `example/` 目录执行 `npm install` 同步最新构建产物。 + +## 使用文档 + +[QQ 机器人官方文档](https://bot.q.qq.com/wiki/develop/api-v2/) + +[NodeSDK 文档](https://bot.q.qq.com/wiki/develop/nodesdk/) + ## 参与共建 [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) - 👏 如果您有针对 SDK 的错误修复,请以分支`fix/xxx`向`main`分支发 PR diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..0ebed5b --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,79 @@ +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import eslintPluginPrettier from 'eslint-plugin-prettier'; +import jest from 'eslint-plugin-jest'; + +export default tseslint.config( + // 全局忽略(替代 .eslintignore) + { + ignores: ['dist/**', 'node_modules/**', 'example/**', 'es/**', 'lib/**', 'typings/**'], + }, + + // JS/TS 基础推荐规则 + ...tseslint.configs.recommended, + + // Prettier 兼容(关闭冲突规则) + eslintConfigPrettier, + + // 通用配置 + { + languageOptions: { + ecmaVersion: 2018, + sourceType: 'module', + globals: { + ...globals.node, + }, + }, + plugins: { + prettier: eslintPluginPrettier, + }, + rules: { + 'prettier/prettier': 'warn', + + // 从原 .eslintrc.js 迁移的规则 + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-empty-interface': 'off', + '@typescript-eslint/no-this-alias': 'off', + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/prefer-optional-chain': 'off', + 'no-new-func': 'off', + complexity: 'off', + 'max-params': 'off', + 'no-param-reassign': 'off', + 'no-trailing-spaces': [ + 'error', + { + skipBlankLines: true, + }, + ], + + // 放宽一些对旧代码过于严格的规则 + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-empty-object-type': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + }, + + // 测试文件配置 + { + files: ['test/**/*.ts'], + plugins: { + jest, + }, + languageOptions: { + globals: { + ...globals.jest, + }, + }, + rules: { + ...jest.configs.recommended.rules, + 'jest/no-conditional-expect': 'warn', + }, + }, +); diff --git a/example/.env.example b/example/.env.example new file mode 100644 index 0000000..b5b096f --- /dev/null +++ b/example/.env.example @@ -0,0 +1,12 @@ +# QQ Bot 配置 +BOT_APP_ID=你的AppID +BOT_APP_SECRET=你的AppSecret + +# 是否沙箱环境 (true/false) +BOT_SANDBOX=false + +# 启动模式: ws | webhook +MODE=ws + +# Webhook 端口(仅 webhook 模式) +PORT=8080 diff --git a/example/README.md b/example/README.md index 613a8a0..5e75b2b 100644 --- a/example/README.md +++ b/example/README.md @@ -1,6 +1,102 @@ -# 简单示例 +# QQ Bot SDK 示例项目 -## 步骤 +## 快速开始 -- 1、将 `config.example.json` 重命名为 `config.json`,并更新相关配置信息 -- 2、`yarn example` +1. 复制 `.env.example` 为 `.env`,填入你的 AppID 和 Secret: + + ```bash + cp .env.example .env + ``` + +2. 安装依赖并启动: + + ```bash + npm install + npm run dev + ``` + + 或者从根目录启动: + + ```bash + # 在 bot-node-sdk 根目录 + npm install # 安装 SDK 依赖 + npm run build # 构建 SDK + cd example + npm install # 安装示例依赖(同步最新 SDK 构建产物) + npm run dev + ``` + +## 项目结构 + +``` +example/ +├── index.js # 入口:配置加载 + WebSocket/Webhook 事件分发 +├── .env.example # 环境变量模板 +├── handlers/ +│ ├── c2c.js # C2C 单聊消息处理(指令路由 + 富媒体回声) +│ ├── group.js # 群消息处理(与 C2C 对称) +│ └── guild.js # 频道消息处理 +├── commands/ +│ ├── index.js # 指令注册入口(所有指令在此注册) +│ ├── registry.js # 指令路由中心(cmdMap) +│ ├── help.js # /help — Markdown 帮助菜单(参数指令标签) +│ └── media.js # /发图片 /发语音 /发视频 /发文件 /发MD +└── utils/ + └── message.js # 消息体构建工具(类型推断、按钮构造等) +``` + +## 支持的指令 + +| 指令 | 说明 | +| ---------- | --------------------------------- | +| `/help` | 显示帮助菜单(Markdown 参数指令) | +| `/发图片` | 发送一张示例图片 | +| `/发视频` | 发送一段示例视频 | +| `/发语音` | 发送一段示例语音 | +| `/发文件` | 发送一个示例文件 | +| `/发MD` | 发送 Markdown 示例(可带自定义内容) | + +- `/发MD` 不带参数时发送默认示例,带参数时用参数内容作为 Markdown 正文 +- 富媒体发送内部自动分两步(上传获取 file_info → 用 file_info 发消息),对用户透明 + +## C2C / 群消息回声 + +收到以下类型的消息会先回复内容信息(文件名、类型、大小、URL),再回声发回原文件: + +- ✅ 纯文本 +- ✅ 图片 +- ✅ 语音 +- ✅ 视频 +- ✅ 文件 + +## 添加新指令 + +在 `commands/` 目录新建文件,然后在 `commands/index.js` 中注册即可: + +```js +// commands/ping.js +async function pingHandler(ctx) { + await ctx.reply({ content: 'pong! 🏓', msg_type: 0 }); +} +module.exports = { pingHandler }; +``` + +```js +// commands/index.js 中添加: +const { pingHandler } = require('./ping'); +registry.register('/ping', pingHandler, '测试延迟'); +``` + +重启后生效,`/help` 菜单会自动包含新指令。 + +## ctx 上下文对象 + +指令处理器接收一个 `ctx` 对象,包含以下方法: + +| 属性/方法 | 说明 | +| --- | --- | +| `ctx.msg` | 原始消息对象 | +| `ctx.args` | 指令参数(指令名之后的文本) | +| `ctx.reply(body)` | 回复消息(自动带 msg_id 做被动回复) | +| `ctx.uploadMedia(body)` | 上传富媒体文件,返回 file_info | +| `ctx.nextSeq()` | 获取递增的消息序号(用于去重) | diff --git a/example/commands/help.js b/example/commands/help.js new file mode 100644 index 0000000..8e47ada --- /dev/null +++ b/example/commands/help.js @@ -0,0 +1,50 @@ +// ============================================================ +// /help 指令 — 回复 Markdown(使用参数指令标签) +// +// :点击后插入输入框,用户可编辑后再发送 +// 参考:https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/trans/text-chain.html +// ============================================================ +const registry = require('./registry'); +const { MsgType } = require('../utils/message'); + +/** + * 生成 help Markdown 消息体 + */ +function buildHelpMessage() { + const commands = registry.listCommands(); + + const lines = [ + '🤖 **机器人帮助菜单**', + '', + '点击指令可插入输入框:', + '', + ]; + + commands.forEach(({ command, description }) => { + const encoded = encodeURIComponent(command); + lines.push(` ${description}`); + }); + + lines.push(''); + lines.push('直接发送图片、语音、视频、文件,机器人会展示内容并回声发回 🔊'); + + return { + msg_type: MsgType.MARKDOWN, + markdown: { content: lines.join('\n') }, + }; +} + +/** + * help 指令处理器 + */ +async function helpHandler(ctx) { + const helpMsg = buildHelpMessage(); + try { + await ctx.reply(helpMsg); + console.log('[help] 帮助菜单已发送'); + } catch (e) { + console.error('[help] 发送失败:', e?.data || e.message); + } +} + +module.exports = { helpHandler, buildHelpMessage }; diff --git a/example/commands/index.js b/example/commands/index.js new file mode 100644 index 0000000..28f6f61 --- /dev/null +++ b/example/commands/index.js @@ -0,0 +1,20 @@ +// ============================================================ +// 命令模块入口 — 注册所有指令到 registry +// ============================================================ +const registry = require('./registry'); +const { helpHandler } = require('./help'); +const { sendImage, sendVideo, sendAudio, sendFile, sendMarkdown } = require('./media'); + +// 帮助 +registry.register('/help', helpHandler, '显示帮助菜单'); + +// 富媒体发送(内部自动上传 → 发送) +registry.register('/发图片', sendImage, '发送一张示例图片'); +registry.register('/发视频', sendVideo, '发送一段示例视频'); +registry.register('/发语音', sendAudio, '发送一段示例语音'); +registry.register('/发文件', sendFile, '发送一个示例文件'); + +// Markdown +registry.register('/发MD', sendMarkdown, '发送 Markdown 示例'); + +module.exports = registry; diff --git a/example/commands/media.js b/example/commands/media.js new file mode 100644 index 0000000..3d90d26 --- /dev/null +++ b/example/commands/media.js @@ -0,0 +1,95 @@ +// ============================================================ +// 富媒体指令 — /发图片 /发语音 /发视频 /发文件 /发MD +// +// 代码内部分两步:上传 → 用 file_info 发消息 +// 对用户来说就是一个指令 +// ============================================================ +const { FileType, fileTypeName, MsgType, buildTextMessage } = require('../utils/message'); + +// 示例资源 URL(来自 adelie_robot_svr) +const SAMPLE_URLS = { + [FileType.IMAGE]: 'https://qqminiapp.cdn-go.cn/open-platform/53e29134/img/qqInterconncet.aa1a9d9c.png', + [FileType.VIDEO]: 'https://world-channel-image-1251316161.cos.ap-guangzhou.myqcloud.com/botdata/botqqtest.mp4', + [FileType.AUDIO]: 'https://world-channel-image-1251316161.cos.ap-guangzhou.myqcloud.com/botdata/9bd3288796c60895fea18aa5db76967e.silk', + [FileType.FILE]: 'https://bot-resource-1251316161.file.myqcloud.com/temp/test_8MB.txt', +}; + +/** + * 创建富媒体发送指令处理器 + * 内部流程:上传(srv_send_msg=false) → 获取 file_info → 用 file_info 发消息 + */ +function createMediaSendHandler(fileType) { + const name = fileTypeName(fileType); + + return async function mediaHandler(ctx) { + const url = SAMPLE_URLS[fileType]; + try { + // 第一步:上传,获取 file_info + const uploadRes = await ctx.uploadMedia({ + file_type: fileType, + url, + srv_send_msg: false, + }); + + const fileInfo = uploadRes?.data?.file_info; + if (!fileInfo) { + await ctx.reply(buildTextMessage(`❌ ${name}上传失败:未获取到 file_info`)); + return; + } + + console.log(`[media] ${name}上传成功, file_uuid=${uploadRes?.data?.file_uuid || ''}`); + + // 第二步:用 file_info 发消息 + await ctx.reply({ + msg_type: MsgType.RICH_MEDIA, + media: { file_info: fileInfo }, + msg_seq: ctx.nextSeq(), + }); + + console.log(`[media] ${name}发送成功`); + } catch (e) { + console.error(`[media] ${name}发送失败:`, e?.data || e.message); + try { + await ctx.reply(buildTextMessage(`❌ 发送${name}失败: ${e?.data?.message || e.message}`)); + } catch (_) {} + } + }; +} + +// ======================== 发 Markdown ======================== + +const DEFAULT_MD = [ + '# 📋 Markdown 示例', + '', + '**加粗文字** *斜体文字*', + '', + '- 列表项 1', + '- 列表项 2', + '- 列表项 3', + '', + '> 这是一段引用文字', + '', + '`代码片段` 和 [链接](https://bot.q.qq.com)', +].join('\n'); + +async function sendMarkdown(ctx) { + const content = ctx.args?.trim() || DEFAULT_MD; + try { + await ctx.reply({ + msg_type: MsgType.MARKDOWN, + markdown: { content }, + }); + console.log('[media] Markdown 发送成功'); + } catch (e) { + console.error('[media] Markdown 发送失败:', e?.data || e.message); + await ctx.reply(buildTextMessage(`❌ 发送 Markdown 失败: ${e?.data?.message || e.message}`)).catch(() => {}); + } +} + +module.exports = { + sendImage: createMediaSendHandler(FileType.IMAGE), + sendVideo: createMediaSendHandler(FileType.VIDEO), + sendAudio: createMediaSendHandler(FileType.AUDIO), + sendFile: createMediaSendHandler(FileType.FILE), + sendMarkdown, +}; diff --git a/example/commands/registry.js b/example/commands/registry.js new file mode 100644 index 0000000..2af36ca --- /dev/null +++ b/example/commands/registry.js @@ -0,0 +1,80 @@ +// ============================================================ +// 命令注册中心 — 类似 adelie_robot_svr 的 cmdMap +// +// 用法: +// registry.register('/help', helpHandler); +// const handler = registry.match(content); +// if (handler) await handler(ctx); +// ============================================================ + +class CommandRegistry { + constructor() { + /** @type {Map} */ + this._commands = new Map(); + /** @type {Function|null} 兜底处理器 */ + this._fallback = null; + } + + /** + * 注册指令 + * @param {string} command - 指令名(如 "/help"),会统一转小写 + * @param {Function} handler - async (ctx) => void + * @param {string} description - 指令描述(用于 help 菜单) + */ + register(command, handler, description = '') { + this._commands.set(command.toLowerCase(), { handler, description }); + } + + /** + * 设置兜底处理器(无匹配指令时执行) + */ + setFallback(handler) { + this._fallback = handler; + } + + /** + * 从消息内容中匹配指令 + * - 去除 @机器人 的前缀空格后,取第一个 / 开头的词 + * - 返回 { handler, args } 或 null + */ + match(content) { + if (!content) return null; + + // 去除前导 @ 提及和空白 + const cleaned = content.replace(/<@!\d+>/g, '').trim(); + if (!cleaned) return null; + + // 提取指令词(支持 /cmd 和 cmd 两种格式) + const parts = cleaned.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1).join(' '); + + const entry = this._commands.get(cmd); + if (entry) { + return { handler: entry.handler, args }; + } + return null; + } + + /** + * 获取兜底处理器 + */ + getFallback() { + return this._fallback; + } + + /** + * 获取所有已注册指令(用于 help 菜单) + * @returns {Array<{command: string, description: string}>} + */ + listCommands() { + const list = []; + for (const [command, { description }] of this._commands) { + list.push({ command, description }); + } + return list; + } +} + +// 单例导出 +module.exports = new CommandRegistry(); diff --git a/example/handlers/c2c.js b/example/handlers/c2c.js new file mode 100644 index 0000000..1b3311f --- /dev/null +++ b/example/handlers/c2c.js @@ -0,0 +1,153 @@ +// ============================================================ +// C2C(单聊)消息处理器 +// 场景: +// 1. 指令路由(/help, /上传图片, /发图片 等) +// 2. 收到富媒体 → 回复内容信息 + 回声发回 +// 3. 收到纯文本 → 回声 +// ============================================================ +const registry = require('../commands'); +const { + MsgType, + FileType, + fileTypeName, + inferFileType, + buildTextMessage, +} = require('../utils/message'); + +/** 全局递增消息序号 */ +let msgSeq = 1; +function nextSeq() { + return msgSeq++; +} + +/** + * 构建 C2C 回复上下文 + */ +function buildCtx(client, msg, args) { + return { + client, + msg, + args, + nextSeq, + + async reply(body) { + return client.c2cMessageApi.postC2CMessage(msg.author.id, { + ...body, + msg_id: msg.id, + msg_seq: body.msg_seq ?? nextSeq(), + }); + }, + + async uploadMedia(richMediaBody) { + return client.c2cMessageApi.postC2CRichMedia(msg.author.id, richMediaBody); + }, + }; +} + +/** + * 处理收到的附件 + * 1. 先回复一条文本,展示收到的富媒体内容信息 + * 2. 再回声发回原文件 + */ +async function handleAttachments(ctx) { + const { msg } = ctx; + const attachments = msg.attachments; + if (!attachments || attachments.length === 0) return false; + + for (const att of attachments) { + console.log(`[C2C] attachment:`, JSON.stringify(att, null, 2)); + + const url = att.url?.startsWith('http') ? att.url : `https://${att.url}`; + const contentType = att.content_type || ''; + const filename = att.filename || ''; + const size = att.size || ''; + const fileType = inferFileType(att.content_type); + const typeName = fileTypeName(fileType); + + console.log(`[C2C] 收到${typeName}(file_type=${fileType}): ${url}`); + + // ---- 第一步:回复内容信息 ---- + const infoLines = [`📎 收到${typeName}(file_type=${fileType})`]; + if (filename) infoLines.push(`文件名: ${filename}`); + if (contentType) infoLines.push(`类型: ${contentType}`); + if (size) infoLines.push(`大小: ${size}`); + infoLines.push(`URL: ${url}`); + + try { + await ctx.reply(buildTextMessage(infoLines.join('\n'))); + } catch (e) { + console.error(`[C2C] 信息回复失败:`, e?.data || e.message); + } + + // ---- 第二步:回声发回 ---- + try { + const uploadRes = await ctx.uploadMedia({ + file_type: fileType, + url, + srv_send_msg: false, + }); + + const fileInfo = uploadRes?.data?.file_info; + if (!fileInfo) { + await ctx.reply(buildTextMessage(`⚠️ ${typeName}回声上传失败:未获取到 file_info`)); + continue; + } + + await ctx.reply({ + msg_type: MsgType.RICH_MEDIA, + media: { file_info: fileInfo }, + msg_seq: nextSeq(), + }); + + console.log(`[C2C] ${typeName}回声成功`); + } catch (e) { + console.error(`[C2C] ${typeName}回声失败:`, e?.data || e.message); + try { + await ctx.reply(buildTextMessage(`⚠️ ${typeName}回声失败: ${e?.data?.message || e.message}`)); + } catch (_) {} + } + } + + return true; +} + +/** + * C2C_MESSAGE_CREATE 事件主处理函数 + */ +async function handleC2CMessage(client, eventData) { + const msg = eventData.msg; + if (!msg) return; + + const content = msg.content?.trim() || ''; + console.log(`[C2C] 收到消息 from=${msg.author?.id}: ${content || '[富媒体]'}`); + + // 1. 指令匹配 + const matched = registry.match(content); + if (matched) { + const ctx = buildCtx(client, msg, matched.args); + await matched.handler(ctx); + return; + } + + // 2. 附件处理(展示信息 + 回声) + const ctx = buildCtx(client, msg, ''); + const hasAttachment = await handleAttachments(ctx); + if (hasAttachment) return; + + // 3. 纯文本回声 + if (content) { + try { + await ctx.reply(buildTextMessage(`📝 收到:${content}`)); + } catch (e) { + console.error('[C2C] 文本回声失败:', e?.data || e.message); + } + return; + } + + // 4. 兜底 + try { + await ctx.reply(buildTextMessage('收到你的消息了,但我还不认识这种格式 🤔')); + } catch (_) {} +} + +module.exports = { handleC2CMessage }; diff --git a/example/handlers/group.js b/example/handlers/group.js new file mode 100644 index 0000000..dff7dea --- /dev/null +++ b/example/handlers/group.js @@ -0,0 +1,142 @@ +// ============================================================ +// 群消息处理器 — GROUP_AT_MESSAGE_CREATE +// 功能与 C2C 对称:指令路由 + 附件信息展示 + 回声 +// ============================================================ +const registry = require('../commands'); +const { + MsgType, + FileType, + fileTypeName, + inferFileType, + buildTextMessage, +} = require('../utils/message'); + +let msgSeq = 1; +function nextSeq() { + return msgSeq++; +} + +/** + * 构建群回复上下文 + */ +function buildCtx(client, msg, args) { + return { + client, + msg, + args, + nextSeq, + + async reply(body) { + return client.groupMessageApi.postGroupMessage(msg.group_id, { + ...body, + msg_id: msg.id, + msg_seq: body.msg_seq ?? nextSeq(), + }); + }, + + async uploadMedia(richMediaBody) { + return client.groupMessageApi.postGroupRichMedia(msg.group_id, richMediaBody); + }, + }; +} + +/** + * 处理群消息附件:展示内容信息 + 回声 + */ +async function handleAttachments(ctx) { + const { msg } = ctx; + const attachments = msg.attachments; + if (!attachments || attachments.length === 0) return false; + + for (const att of attachments) { + const url = att.url?.startsWith('http') ? att.url : `https://${att.url}`; + const contentType = att.content_type || '未知'; + const filename = att.filename || ''; + const size = att.size || ''; + const fileType = inferFileType(att.content_type); + const typeName = fileTypeName(fileType); + + console.log(`[Group] 收到${typeName}: ${url}`); + + // 回复内容信息 + const infoLines = [`📎 收到${typeName}`]; + if (filename) infoLines.push(`文件名: ${filename}`); + infoLines.push(`类型: ${contentType}`); + if (size) infoLines.push(`大小: ${size}`); + infoLines.push(`URL: ${url}`); + + try { + await ctx.reply(buildTextMessage(infoLines.join('\n'))); + } catch (e) { + console.error(`[Group] 信息回复失败:`, e?.data || e.message); + } + + // 回声发回 + try { + const uploadRes = await ctx.uploadMedia({ + file_type: fileType, + url, + srv_send_msg: false, + }); + + const fileInfo = uploadRes?.data?.file_info; + if (!fileInfo) { + await ctx.reply(buildTextMessage(`⚠️ ${typeName}回声上传失败`)); + continue; + } + + await ctx.reply({ + msg_type: MsgType.RICH_MEDIA, + media: { file_info: fileInfo }, + msg_seq: nextSeq(), + }); + + console.log(`[Group] ${typeName}回声成功`); + } catch (e) { + console.error(`[Group] ${typeName}回声失败:`, e?.data || e.message); + } + } + + return true; +} + +/** + * GROUP_AT_MESSAGE_CREATE 事件主处理函数 + */ +async function handleGroupMessage(client, eventData) { + const msg = eventData.msg; + if (!msg) return; + + const content = msg.content?.trim() || ''; + console.log(`[Group] 收到群消息 group=${msg.group_id}: ${content || '[富媒体]'}`); + + // 1. 指令匹配 + const matched = registry.match(content); + if (matched) { + const ctx = buildCtx(client, msg, matched.args); + await matched.handler(ctx); + return; + } + + // 2. 附件处理 + const ctx = buildCtx(client, msg, ''); + const hasAttachment = await handleAttachments(ctx); + if (hasAttachment) return; + + // 3. 文本回声 + if (content) { + try { + await ctx.reply(buildTextMessage(`📝 收到:${content}`)); + } catch (e) { + console.error('[Group] 文本回声失败:', e?.data || e.message); + } + return; + } + + // 4. 兜底 + try { + await ctx.reply(buildTextMessage('收到你的消息了 👋')); + } catch (_) {} +} + +module.exports = { handleGroupMessage }; diff --git a/example/handlers/guild.js b/example/handlers/guild.js new file mode 100644 index 0000000..c28cef5 --- /dev/null +++ b/example/handlers/guild.js @@ -0,0 +1,70 @@ +// ============================================================ +// 频道消息处理器 — PUBLIC_GUILD_MESSAGES / DIRECT_MESSAGE +// ============================================================ +const registry = require('../commands'); +const { buildTextMessage } = require('../utils/message'); + +let msgSeq = 1; +function nextSeq() { + return msgSeq++; +} + +/** + * 构建频道回复上下文 + */ +function buildCtx(client, msg, args) { + return { + client, + msg, + args, + nextSeq, + + async reply(body) { + return client.messageApi.postMessage(msg.channel_id, { + ...body, + msg_id: msg.id, + }); + }, + + // 频道暂不支持富媒体上传接口,使用 image URL 直接发 + async uploadMedia() { + throw new Error('频道场景暂不支持 uploadMedia'); + }, + }; +} + +/** + * AT_MESSAGE_CREATE 事件处理(公域 @机器人) + */ +async function handleGuildMessage(client, eventData) { + const msg = eventData.msg; + if (!msg) return; + + const content = msg.content?.trim() || ''; + console.log(`[Guild] 收到频道消息 channel=${msg.channel_id}: ${content}`); + + // 1. 指令匹配 + const matched = registry.match(content); + if (matched) { + const ctx = buildCtx(client, msg, matched.args); + await matched.handler(ctx); + return; + } + + // 2. 文本回声 + try { + const ctx = buildCtx(client, msg, ''); + await ctx.reply(buildTextMessage(`你好!收到你的消息: ${content}`)); + } catch (e) { + console.error('[Guild] 回复失败:', e?.data || e.message); + } +} + +/** + * 私信消息处理 + */ +async function handleDirectMessage(client, eventData) { + console.log('[DM] 收到私信:', JSON.stringify(eventData.msg?.content || eventData)); +} + +module.exports = { handleGuildMessage, handleDirectMessage }; diff --git a/example/index.js b/example/index.js index e71854d..fec505f 100644 --- a/example/index.js +++ b/example/index.js @@ -1,61 +1,192 @@ -// 以下仅为用法示意,详情请参照文档:https://bot.q.qq.com/wiki/develop/nodesdk/ -const { createOpenAPI, createWebsocket } = require('qq-guild-bot'); +// ============================================================ +// QQ Guild Bot Node SDK — Example 入口 +// 模块结构: +// handlers/ 事件处理器(c2c / group / guild) +// commands/ 指令系统(help / echo / media …) +// utils/ 消息构建工具 +// ============================================================ +require('dotenv').config(); -const testConfigWs = { - appID: '', - token: '', +const { + createOpenAPI, + createWebsocket, + webhookHandler, + registerWebhookHandler, + registerPlainHandler, +} = require('qq-guild-bot'); + +const http = require('http'); + +// ---- 加载指令注册(会自动注册所有指令到 registry) ---- +require('./commands'); + +// ---- 加载事件处理器 ---- +const { handleC2CMessage } = require('./handlers/c2c'); +const { handleGroupMessage } = require('./handlers/group'); +const { handleGuildMessage, handleDirectMessage } = require('./handlers/guild'); + +// ============================================================ +// 1. 配置(从 .env 读取) +// ============================================================ +const config = { + appID: process.env.BOT_APP_ID || '', + secret: process.env.BOT_APP_SECRET || '', + sandbox: process.env.BOT_SANDBOX === 'true', }; -const client = createOpenAPI(testConfigWs); - -const ws = createWebsocket(testConfigWs); -ws.on('READY', (wsdata) => { - console.log('[READY] 事件接收 :', wsdata); -}); - -ws.on('ERROR', (data) => { - console.log('[ERROR] 事件接收 :', data); -}); -ws.on('GUILDS', (data) => { - console.log('[GUILDS] 事件接收 :', data); -}); -ws.on('GUILD_MEMBERS', (data) => { - console.log('[GUILD_MEMBERS] 事件接收 :', data); -}); -ws.on('GUILD_MESSAGES', (data) => { - console.log('[GUILD_MESSAGES] 事件接收 :', data); -}); -ws.on('GUILD_MESSAGE_REACTIONS', (data) => { - console.log('[GUILD_MESSAGE_REACTIONS] 事件接收 :', data); -}); -ws.on('DIRECT_MESSAGE', (data) => { - console.log('[DIRECT_MESSAGE] 事件接收 :', data); -}); -ws.on('INTERACTION', (data) => { - console.log('[INTERACTION] 事件接收 :', data); -}); -ws.on('MESSAGE_AUDIT', (data) => { - console.log('[MESSAGE_AUDIT] 事件接收 :', data); -}); -ws.on('FORUMS_EVENT', (data) => { - console.log('[FORUMS_EVENT] 事件接收 :', data); -}); -ws.on('AUDIO_ACTION', (data) => { - console.log('[AUDIO_ACTION] 事件接收 :', data); -}); -ws.on('PUBLIC_GUILD_MESSAGES', async (eventData) => { - console.log('[PUBLIC_GUILD_MESSAGES] 事件接收 :', eventData); - const {data} = await client.messageApi.postMessage('', { - content: 'test' - }) - console.log(data); -}); - -// client.guildApi.guild('').then((data) => { -// console.log(data); -// }); - -// // ✅ -// client.channelApi.channels(guildID).then((res) => { -// console.log(res.data); -// }); +if (!config.appID || !config.secret) { + console.error('❌ 请先在 .env 文件中配置 BOT_APP_ID 和 BOT_APP_SECRET'); + console.error(' 可参考 .env.example 模板'); + process.exit(1); +} + +// ============================================================ +// 2. 创建 OpenAPI 客户端 +// ============================================================ +const client = createOpenAPI(config); + +// ============================================================ +// 3. WebSocket 模式 +// ============================================================ +function startWebSocket() { + const ws = createWebsocket({ + ...config, + intents: [ + 'GUILDS', + 'GUILD_MEMBERS', + 'GUILD_MESSAGES', + 'GUILD_MESSAGE_REACTIONS', + 'DIRECT_MESSAGE', + 'PUBLIC_GUILD_MESSAGES', + 'INTERACTION', + 'MESSAGE_AUDIT', + 'FORUMS_EVENT', + 'AUDIO_ACTION', + 'GROUP_MESSAGES', + ], + }); + + // ---- 基础事件 ---- + ws.on('READY', (data) => { + console.log('[READY] 🤖 机器人已上线:', JSON.stringify(data, null, 2)); + }); + + ws.on('ERROR', (data) => { + console.error('[ERROR]', JSON.stringify(data, null, 2)); + }); + + // ---- 频道消息(公域 @机器人) ---- + ws.on('PUBLIC_GUILD_MESSAGES', (eventData) => { + console.log('[EVENT] PUBLIC_GUILD_MESSAGES', eventData.eventType, JSON.stringify(eventData.msg, null, 2)); + if (eventData.eventType === 'AT_MESSAGE_CREATE') { + handleGuildMessage(client, eventData); + } + }); + + // ---- 频道私域消息 ---- + ws.on('GUILD_MESSAGES', (eventData) => { + console.log('[EVENT] GUILD_MESSAGES', eventData.eventType, JSON.stringify(eventData.msg, null, 2)); + }); + + // ---- 私信消息 ---- + ws.on('DIRECT_MESSAGE', (eventData) => { + console.log('[EVENT] DIRECT_MESSAGE', eventData.eventType, JSON.stringify(eventData.msg, null, 2)); + handleDirectMessage(client, eventData); + }); + + // ---- 群 / C2C 消息 ---- + ws.on('GROUP_MESSAGES', (eventData) => { + console.log('[EVENT]', eventData.eventType, JSON.stringify(eventData.msg, null, 2)); + + switch (eventData.eventType) { + case 'GROUP_AT_MESSAGE_CREATE': + handleGroupMessage(client, eventData); + break; + + case 'C2C_MESSAGE_CREATE': + handleC2CMessage(client, eventData); + break; + + case 'FRIEND_ADD': + case 'FRIEND_DEL': + break; + + default: + break; + } + }); + + // ---- 互动事件(按钮回调) ---- + ws.on('INTERACTION', (data) => { + console.log('[EVENT] INTERACTION', JSON.stringify(data, null, 2)); + }); + + // ---- 其他事件 ---- + ws.on('GUILDS', (d) => console.log('[EVENT] GUILDS', d.eventType, JSON.stringify(d.msg, null, 2))); + ws.on('GUILD_MEMBERS', (d) => console.log('[EVENT] GUILD_MEMBERS', d.eventType, JSON.stringify(d.msg, null, 2))); + ws.on('GUILD_MESSAGE_REACTIONS', (d) => console.log('[EVENT] GUILD_MESSAGE_REACTIONS', d.eventType, JSON.stringify(d.msg, null, 2))); + ws.on('MESSAGE_AUDIT', (d) => console.log('[EVENT] MESSAGE_AUDIT', d.eventType, JSON.stringify(d.msg, null, 2))); + ws.on('FORUMS_EVENT', (d) => console.log('[EVENT] FORUMS_EVENT', d.eventType, JSON.stringify(d.msg, null, 2))); + ws.on('AUDIO_ACTION', (d) => console.log('[EVENT] AUDIO_ACTION', d.eventType, JSON.stringify(d.msg, null, 2))); +} + +// ============================================================ +// 4. Webhook 模式 +// ============================================================ +function startWebhook(port = 8080) { + registerWebhookHandler('GROUP_AT_MESSAGE_CREATE', async (payload) => { + console.log('[Webhook] 群@消息:', JSON.stringify(payload.d)); + return true; + }); + + registerWebhookHandler('C2C_MESSAGE_CREATE', async (payload) => { + console.log('[Webhook] C2C消息:', JSON.stringify(payload.d)); + return true; + }); + + registerWebhookHandler('AT_MESSAGE_CREATE', async (payload) => { + console.log('[Webhook] 频道@消息:', JSON.stringify(payload.d)); + return true; + }); + + registerPlainHandler((payload) => { + console.log(`[Webhook] 未注册事件: ${payload.t}`, JSON.stringify(payload.d)); + return true; + }); + + const server = http.createServer((req, res) => { + if (req.method === 'POST' && req.url === '/webhook') { + webhookHandler(req, res, { + appID: config.appID, + appSecret: config.secret, + }); + } else { + res.writeHead(404); + res.end('Not Found'); + } + }); + + server.listen(port, () => { + console.log(`[Webhook] HTTP 服务已启动: http://localhost:${port}/webhook`); + }); +} + +// ============================================================ +// 5. 启动入口 +// ============================================================ +const MODE = process.env.MODE || 'ws'; + +switch (MODE) { + case 'ws': + console.log('🚀 以 WebSocket 模式启动...'); + startWebSocket(); + break; + + case 'webhook': + console.log('🚀 以 Webhook 模式启动...'); + startWebhook(process.env.PORT ? parseInt(process.env.PORT) : 8080); + break; + + default: + console.log(`未知模式: ${MODE},请设置 MODE=ws|webhook`); +} diff --git a/example/package.json b/example/package.json index cc82b15..f0b8ed6 100644 --- a/example/package.json +++ b/example/package.json @@ -4,8 +4,13 @@ "description": "", "main": "index.js", "scripts": { + "dev": "node index.js", "example": "node index.js" }, + "dependencies": { + "dotenv": "^16.3.1", + "qq-guild-bot": "file:.." + }, "author": "", "license": "ISC" } diff --git a/example/utils/message.js b/example/utils/message.js new file mode 100644 index 0000000..5f7a49e --- /dev/null +++ b/example/utils/message.js @@ -0,0 +1,157 @@ +// ============================================================ +// 消息构建工具 — 封装常用消息体的构造逻辑 +// ============================================================ + +/** + * 文件类型枚举(对齐 SDK FileType) + */ +const FileType = { + IMAGE: 1, + VIDEO: 2, + AUDIO: 3, + FILE: 4, +}; + +/** + * 消息类型枚举(对齐 SDK MessageType) + */ +const MsgType = { + TEXT: 0, + MARKDOWN: 2, + ARK: 3, + EMBED: 4, + INPUT_NOTIFY: 6, + RICH_MEDIA: 7, +}; + +/** + * 根据 content_type 推断文件类型 + */ +function inferFileType(contentType) { + if (!contentType) return FileType.FILE; + const ct = contentType.toLowerCase(); + if (ct.startsWith('image/')) return FileType.IMAGE; + if (ct.startsWith('video/')) return FileType.VIDEO; + if (ct.startsWith('audio/')) return FileType.AUDIO; + return FileType.FILE; +} + +/** + * 文件类型 → 中文名称 + */ +function fileTypeName(type) { + const names = { + [FileType.IMAGE]: '图片', + [FileType.VIDEO]: '视频', + [FileType.AUDIO]: '语音', + [FileType.FILE]: '文件', + }; + return names[type] || '未知'; +} + +// ======================== 按钮构建 ======================== + +/** + * 创建一个 AT 机器人按钮(点击后在输入框填入指令并发送) + * @param {string} id - 按钮 ID + * @param {string} label - 显示文字 + * @param {string} command - 指令内容(如 "/help") + * @param {object} [opts] - 可选项 { style, enter } + */ +function atBotButton(id, label, command, opts = {}) { + return { + id, + render_data: { + label, + visited_label: label, + style: opts.style ?? 1, // 默认蓝色线框 + }, + action: { + type: 2, // AT_BOT + permission: { type: 2 }, // 所有人可点 + data: command, + enter: opts.enter ?? true, // 直接发送 + }, + }; +} + +/** + * 创建回调按钮(触发 INTERACTION 事件) + */ +function callbackButton(id, label, data, opts = {}) { + return { + id, + render_data: { + label, + visited_label: opts.visitedLabel || label, + style: opts.style ?? 0, // 默认灰色线框 + }, + action: { + type: 1, // CALLBACK + permission: { type: 2 }, + data, + }, + }; +} + +/** + * 将按钮数组按每行 N 个分组,生成 keyboard.content.rows + * @param {Array} buttons - Button 数组 + * @param {number} [perRow] - 每行按钮数,默认 3 + */ +function buildKeyboardRows(buttons, perRow = 3) { + const rows = []; + for (let i = 0; i < buttons.length; i += perRow) { + rows.push({ buttons: buttons.slice(i, i + perRow) }); + } + return rows; +} + +/** + * 构建一条 Markdown + Keyboard 消息体 + */ +function buildMarkdownWithKeyboard(markdownContent, buttons, perRow = 3) { + return { + msg_type: MsgType.MARKDOWN, + markdown: { content: markdownContent }, + keyboard: { + content: { + rows: buildKeyboardRows(buttons, perRow), + }, + }, + }; +} + +/** + * 构建纯文本消息体 + */ +function buildTextMessage(content, msgId) { + const msg = { content, msg_type: MsgType.TEXT }; + if (msgId) msg.msg_id = msgId; + return msg; +} + +/** + * 构建富媒体消息体(图片/视频/语音/文件) + */ +function buildRichMediaMessage(fileType, url, opts = {}) { + return { + file_type: fileType, + url, + srv_send_msg: opts.srvSendMsg ?? false, + ...(opts.msgSeq != null && { msg_seq: opts.msgSeq }), + }; +} + +module.exports = { + FileType, + MsgType, + inferFileType, + fileTypeName, + atBotButton, + callbackButton, + buildKeyboardRows, + buildMarkdownWithKeyboard, + buildTextMessage, + buildRichMediaMessage, +}; diff --git a/package.json b/package.json index 5b8b6c9..6f8ddaa 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "prepare": "husky install", "dev": "npm run clean & cross-env NODE_ENV=dev rollup -c rollup.config.js -w", "build": "npm run clean &cross-env NODE_ENV=production rollup -c rollup.config.js", - "lint": "npx eslint \"./**/*.{js,ts}\" --fix", + "lint": "eslint \"src/**/*.{js,ts}\" \"test/**/*.{js,ts}\" --fix", "format": "prettier --write ./src", "test": "jest --config jest.config.json", "example": "cd example && npm run dev", @@ -55,18 +55,17 @@ "@rollup/plugin-replace": "^3.0.0", "@types/jest": "^27.0.2", "@types/lodash.assignin": "^4.2.6", + "@types/node": "^18.19.130", "@types/ws": "^8.2.0", - "@typescript-eslint/eslint-plugin": "^4.29.2", - "@typescript-eslint/parser": "^4.29.2", "chalk": "^4.1.2", "commitizen": "^4.2.4", "cross-env": "^7.0.3", "cz-conventional-changelog": "^3.3.0", - "eslint": "^7.32.0", - "eslint-config-alloy": "^4.1.0", - "eslint-config-prettier": "^8.3.0", - "eslint-plugin-jest": "^24.4.0", - "eslint-plugin-prettier": "^3.4.0", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-jest": "^28.14.0", + "eslint-plugin-prettier": "^5.5.5", + "globals": "^15.15.0", "handlebars": "^4.7.7", "husky": "^7.0.1", "inquirer": "^8.2.0", @@ -80,11 +79,15 @@ "rollup-plugin-dts": "^4.0.0", "rollup-plugin-typescript-paths": "^1.3.0", "standard-version": "^9.3.1", - "typescript": "^4.4.4" + "typescript": "^4.4.4", + "typescript-eslint": "^8.57.0" }, "dependencies": { + "@babel/runtime": "^7.15.0", + "https-proxy-agent": "^7.0.6", "loglevel": "^1.8.0", "resty-client": "0.0.5", + "tweetnacl": "^1.0.3", "ws": "^7.4.4" }, "resolutions": { diff --git a/rollup.config.js b/rollup.config.js index c0e7145..ce049a9 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -8,7 +8,7 @@ import json from '@rollup/plugin-json'; const ENV = process.env.NODE_ENV; const extensions = ['.ts', '.js']; -const external = ['ws', 'resty-client']; +const external = ['ws', 'resty-client', 'tweetnacl', 'https-proxy-agent', 'https']; export default [ { diff --git a/src/bot.ts b/src/bot.ts index 3adc627..5a498dd 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -16,10 +16,13 @@ export function selectOpenAPIVersion(version: APIVersion) { } defaultImpl = versionMapping[version]; } -// 如果需要使用其他版本的实现,需要在调用这个方法之前调用 SelectOpenAPIVersion 方法 + +// 创建 OpenAPI 客户端,使用 OAuth2 AccessToken 认证 +// config: { appID, secret, sandbox? } export function createOpenAPI(config: Config) { return defaultImpl.newClient(config); } + // ws连接新建 export function createWebsocket(config: GetWsParam) { return new WebsocketClient(config); diff --git a/src/client/session/session.ts b/src/client/session/session.ts index d615304..ce602b2 100644 --- a/src/client/session/session.ts +++ b/src/client/session/session.ts @@ -2,8 +2,9 @@ import { GetWsParam, SessionEvents, SessionRecord, WsObjRequestOptions } from '@ import { Ws } from '@src/client/websocket/websocket'; import { EventEmitter } from 'ws'; import resty from 'resty-client'; -import { addAuthorization } from '@src/utils/utils'; +import { QQBotTokenSource } from '@src/token'; import { BotLogger } from '@src/utils/logger'; +import { getProxyConfig } from '@src/utils/utils'; export default class Session { config: GetWsParam; @@ -11,10 +12,16 @@ export default class Session { ws!: Ws; event!: EventEmitter; sessionRecord: SessionRecord | undefined; + tokenSource: QQBotTokenSource; constructor(config: GetWsParam, event: EventEmitter, sessionRecord?: SessionRecord) { this.config = config; this.event = event; + // 创建 TokenSource + this.tokenSource = new QQBotTokenSource({ + appID: config.appID, + secret: config.secret, + }); // 如果会话记录存在的话,继续透传 if (sessionRecord) { this.sessionRecord = sessionRecord; @@ -23,28 +30,33 @@ export default class Session { } // 新建会话 - createSession() { - this.ws = new Ws(this.config, this.event, this.sessionRecord || undefined); - // 拿到 ws地址等信息 - const reqOptions = WsObjRequestOptions(this.config.sandbox as boolean); - - addAuthorization(reqOptions.headers, this.config.appID, this.config.token); - - resty - .create(reqOptions) - .get(reqOptions.url as string, {}) - .then((r) => { - const wsData = r.data; - if (!wsData) throw new Error('获取ws连接信息异常'); - this.ws.createWebsocket(wsData); - }) - .catch((e) => { - BotLogger.info('[ERROR] createSession: ', e); - this.event.emit(SessionEvents.EVENT_WS, { - eventType: SessionEvents.DISCONNECT, - eventMsg: this.sessionRecord, - }); + async createSession() { + this.ws = new Ws(this.config, this.event, this.tokenSource, this.sessionRecord || undefined); + + try { + // 获取 AccessToken + const token = await this.tokenSource.getToken(); + + // 拿到 ws地址等信息 + const reqOptions = WsObjRequestOptions(this.config.sandbox as boolean); + // 使用 QQBot AccessToken 鉴权 + reqOptions.headers.Authorization = `QQBot ${token.accessToken}`; + + // 合并代理配置(如果有 https_proxy 环境变量,使用 CONNECT 隧道) + const axiosOptions: any = { ...reqOptions, ...getProxyConfig() }; + + const r = await resty.create(axiosOptions).get(axiosOptions.url as string, {}); + const wsData = r.data; + if (!wsData) throw new Error('获取ws连接信息异常'); + BotLogger.info(`[SESSION] WebSocket 地址获取成功: ${wsData.url}`); + this.ws.createWebsocket(wsData); + } catch (e) { + BotLogger.info('[ERROR] createSession: ', e); + this.event.emit(SessionEvents.EVENT_WS, { + eventType: SessionEvents.DISCONNECT, + eventMsg: this.sessionRecord, }); + } } // 关闭会话 diff --git a/src/client/websocket/websocket.ts b/src/client/websocket/websocket.ts index dc2daf0..808152b 100644 --- a/src/client/websocket/websocket.ts +++ b/src/client/websocket/websocket.ts @@ -14,6 +14,7 @@ import WebSocket, { EventEmitter } from 'ws'; import { toObject } from '@src/utils/utils'; import { Properties } from '@src/utils/constants'; import { BotLogger } from '@src/utils/logger'; +import { QQBotTokenSource } from '@src/token'; // websocket连接 export class Ws { @@ -21,6 +22,7 @@ export class Ws { event!: EventEmitter; config: GetWsParam; heartbeatInterval!: number; + tokenSource: QQBotTokenSource; // 心跳参数,默认为心跳测试 heartbeatParam = { op: OpCode.HEARTBEAT, @@ -35,10 +37,11 @@ export class Ws { }; alive = false; - constructor(config: GetWsParam, event: EventEmitter, sessionRecord?: SessionRecord) { + constructor(config: GetWsParam, event: EventEmitter, tokenSource: QQBotTokenSource, sessionRecord?: SessionRecord) { this.config = config; this.isReconnect = false; this.event = event; + this.tokenSource = tokenSource; // 如果是重连,则拿到重新的会话记录,然后进入重连步骤 if (sessionRecord) { this.sessionRecord.sessionID = sessionRecord.sessionID; @@ -64,8 +67,6 @@ export class Ws { // 接受消息 this.ws.on('message', (data: wsResData) => { - // BotLogger.info(`[CLIENT] 收到消息: ${data}`); - // 先将消息解析 const wsRes = toObject(data); // 先判断websocket连接是否成功 @@ -107,7 +108,7 @@ export class Ws { }, this.heartbeatInterval); } - // 收到服务端锻炼重连的通知 + // 收到服务端断线重连的通知 if (wsRes.op === OpCode.RECONNECT) { // 通知会话,当前已断线 this.event.emit(SessionEvents.EVENT_WS, { eventType: SessionEvents.RECONNECT }); @@ -156,24 +157,30 @@ export class Ws { this.ws = new WebSocket(wsData.url); } - // 鉴权 - authWs() { - // 鉴权参数 - const authOp = { - op: OpCode.IDENTIFY, // 鉴权参数 - d: { - token: `Bot ${this.config.appID}.${this.config.token}`, // 根据配置转换token - intents: this.getValidIntents(), // todo 接受的类型 - shard: this.checkShards(this.config.shards) || [0, 1], // 分片信息,给一个默认值 - properties: { - $os: Properties.os, - $browser: Properties.browser, - $device: Properties.device, + // 鉴权(使用 QQBot AccessToken) + async authWs() { + try { + const token = await this.tokenSource.getToken(); + // 鉴权参数 + const authOp = { + op: OpCode.IDENTIFY, + d: { + token: `QQBot ${token.accessToken}`, + intents: this.getValidIntents(), + shard: this.checkShards(this.config.shards) || [0, 1], + properties: { + $os: Properties.os, + $browser: Properties.browser, + $device: Properties.device, + }, }, - }, - }; - // 发送鉴权请求 - this.sendWs(authOp); + }; + // 发送鉴权请求 + this.sendWs(authOp); + } catch (e) { + BotLogger.info('[CLIENT] 鉴权失败: ', e); + this.event.emit(SessionEvents.EVENT_WS, { eventType: SessionEvents.DISCONNECT }); + } } // 校验intents类型 @@ -249,17 +256,23 @@ export class Ws { BotLogger.info('[CLIENT] 等待断线重连'); } - // 重新重连Ws - reconnectWs() { - const reconnectParam = { - op: OpCode.RESUME, - d: { - token: `Bot ${this.config.appID}.${this.config.token}`, - session_id: this.sessionRecord.sessionID, - seq: this.sessionRecord.seq, - }, - }; - this.sendWs(reconnectParam); + // 重新重连Ws(使用 QQBot AccessToken) + async reconnectWs() { + try { + const token = await this.tokenSource.getToken(); + const reconnectParam = { + op: OpCode.RESUME, + d: { + token: `QQBot ${token.accessToken}`, + session_id: this.sessionRecord.sessionID, + seq: this.sessionRecord.seq, + }, + }; + this.sendWs(reconnectParam); + } catch (e) { + BotLogger.info('[CLIENT] 重连鉴权失败: ', e); + this.event.emit(SessionEvents.EVENT_WS, { eventType: SessionEvents.DISCONNECT }); + } } // OpenAPI事件分发 diff --git a/src/index.ts b/src/index.ts index 7564650..0dcbe15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,4 @@ export * from './bot'; export * from './types'; +export * from './token'; +export * from './interaction'; diff --git a/src/interaction/index.ts b/src/interaction/index.ts new file mode 100644 index 0000000..5fbb2bc --- /dev/null +++ b/src/interaction/index.ts @@ -0,0 +1,5 @@ +// 签名验证 +export { verify, generate, HEADER_SIG, HEADER_TIMESTAMP } from './signature'; + +// Webhook HTTP Handler +export { webhookHandler, registerWebhookHandler, registerPlainHandler, clearWebhookHandlers } from './webhook'; diff --git a/src/interaction/signature.ts b/src/interaction/signature.ts new file mode 100644 index 0000000..0c2778c --- /dev/null +++ b/src/interaction/signature.ts @@ -0,0 +1,116 @@ +/** + * Ed25519 签名验证模块 + * 对齐 Go SDK interaction/signature 包 + * + * 处理平台和机器人开发者之间的互动请求中的签名验证 + */ +import nacl from 'tweetnacl'; + +// HTTP Header 常量 +export const HEADER_SIG = 'X-Signature-Ed25519'; +export const HEADER_TIMESTAMP = 'X-Signature-Timestamp'; + +/** Ed25519 seed 长度(32 字节) */ +const ED25519_SEED_SIZE = 32; + +/** + * 从 secret 派生 Ed25519 密钥对 + * + * 规则:将 secret 重复拼接至不小于 32 字节,然后截取前 32 字节作为 seed + */ +function genKeyPair(secret: string): nacl.SignKeyPair { + if (!secret) { + throw new Error('secret invalid'); + } + let seed = secret; + while (seed.length < ED25519_SEED_SIZE) { + seed = seed + seed; // 等价于 strings.Repeat(seed, 2) + } + // 截取前 32 字节作为 seed + const seedBytes = Buffer.from(seed.slice(0, ED25519_SEED_SIZE), 'utf-8'); + return nacl.sign.keyPair.fromSeed(seedBytes); +} + +/** + * 拼接待签名内容: timestamp + body + */ +function genOriginalContent(timestamp: string, body: Buffer): Buffer { + if (!timestamp) { + throw new Error('timestamp is nil'); + } + return Buffer.concat([Buffer.from(timestamp, 'utf-8'), body]); +} + +/** + * 验证签名 + * + * @param secret - AppSecret + * @param headers - HTTP 请求头(需包含 X-Signature-Ed25519 和 X-Signature-Timestamp) + * @param httpBody - HTTP 请求体原始字节 + * @returns 签名是否有效 + */ +export function verify( + secret: string, + headers: Record, + httpBody: Buffer, +): boolean { + const keyPair = genKeyPair(secret); + + // 获取 signature hex 字符串 + const sigHex = getHeader(headers, HEADER_SIG); + if (!sigHex) { + throw new Error('not found signature'); + } + + // hex 解码 + const sigBuffer = Buffer.from(sigHex, 'hex'); + if (sigBuffer.length !== nacl.sign.signatureLength) { + throw new Error('signature decode result is not a valid buf'); + } + + // 获取 timestamp + const timestamp = getHeader(headers, HEADER_TIMESTAMP); + if (!timestamp) { + throw new Error('timestamp is nil'); + } + + const content = genOriginalContent(timestamp, httpBody); + return nacl.sign.detached.verify(content, sigBuffer, keyPair.publicKey); +} + +/** + * 生成签名 + * + * SDK 中主要用于配合验证签名进行测试,也用于 Webhook 回调校验回包 + * + * @param secret - AppSecret + * @param headers - HTTP 请求头(需包含 X-Signature-Timestamp) + * @param httpBody - HTTP 请求体原始字节 + * @returns hex 编码的签名字符串 + */ +export function generate( + secret: string, + headers: Record, + httpBody: Buffer, +): string { + const keyPair = genKeyPair(secret); + + const timestamp = getHeader(headers, HEADER_TIMESTAMP); + if (!timestamp) { + throw new Error('timestamp is nil'); + } + + const content = genOriginalContent(timestamp, httpBody); + const sig = nacl.sign.detached(content, keyPair.secretKey); + return Buffer.from(sig).toString('hex'); +} + +/** + * 从 headers 中获取值(兼容大小写不一致的情况) + */ +function getHeader(headers: Record, name: string): string | undefined { + // 直接查找 + const val = headers[name] ?? headers[name.toLowerCase()]; + if (Array.isArray(val)) return val[0]; + return val; +} diff --git a/src/interaction/webhook.ts b/src/interaction/webhook.ts new file mode 100644 index 0000000..4ea9080 --- /dev/null +++ b/src/interaction/webhook.ts @@ -0,0 +1,286 @@ +/** + * Webhook HTTP 回调处理模块 + * 对齐 Go SDK interaction/webhook 包 + * + * 功能: + * - 签名验证 + * - 心跳 ACK + * - 回调校验(OpCode 13) + * - 事件分发 + */ +import { IncomingMessage, ServerResponse } from 'http'; +import { verify, generate, HEADER_TIMESTAMP } from './signature'; +import type { + WebhookPayload, + WebhookACK, + WHValidationReq, + WHValidationRsp, + WebhookCredentials, + WebhookEventHandler, +} from '@src/types/openapi/v1/webhook'; +import { OpCode } from '@src/types/websocket-types'; +import { BotLogger } from '@src/utils/logger'; + +// HTTP 回调专用 OPCode(对齐 Go SDK) +const HTTP_CALLBACK_ACK = 12; +const HTTP_CALLBACK_VALIDATION = 13; + +// Header 常量 +const HEADER_TRACE_ID = 'X-Tps-trace-ID'; + +/** 事件处理器映射:eventType -> handler */ +const eventHandlers = new Map(); + +/** 通用兜底处理器 */ +let plainHandler: WebhookEventHandler | null = null; + +/** + * 注册事件处理器 + * + * @param eventType - 事件类型(如 GROUP_AT_MESSAGE_CREATE) + * @param handler - 事件处理函数 + */ +export function registerWebhookHandler(eventType: string, handler: WebhookEventHandler): void { + eventHandlers.set(eventType, handler); +} + +/** + * 注册通用兜底处理器 + * 当没有匹配的具体事件处理器时,会调用此处理器 + */ +export function registerPlainHandler(handler: WebhookEventHandler): void { + plainHandler = handler; +} + +/** + * 清除所有已注册的 webhook 事件处理器 + */ +export function clearWebhookHandlers(): void { + eventHandlers.clear(); + plainHandler = null; +} + +/** + * 生成心跳 ACK 响应 + */ +function genHeartbeatACK(seq: number): string { + const ack: WebhookACK = { op: OpCode.HEARTBEAT_ACK, d: seq }; + return JSON.stringify(ack); +} + +/** + * 生成事件分发 ACK 响应 + * @param success - 事件是否处理成功,失败时 d=1 表示服务端会重试 + */ +function genDispatchACK(success: boolean): string { + const ack: WebhookACK = { op: HTTP_CALLBACK_ACK, d: success ? 0 : 1 }; + return JSON.stringify(ack); +} + +/** + * 生成回调校验 ACK 响应 + * + * @param req - 回调校验请求数据 + * @param headers - 原始 HTTP 请求头 + * @param secret - AppSecret + */ +function genValidationACK( + req: WHValidationReq, + headers: Record, + secret: string, +): string | null { + // 构造签名用的 headers(设置 timestamp 为 event_ts) + const sigHeaders: Record = { + [HEADER_TIMESTAMP]: req.event_ts, + }; + + try { + const sig = generate(secret, sigHeaders, Buffer.from(req.plain_token, 'utf-8')); + const rsp: WHValidationRsp = { + plain_token: req.plain_token, + signature: sig, + }; + return JSON.stringify(rsp); + } catch (e) { + BotLogger.error(`[WEBHOOK] 生成回调校验签名失败: ${e}`); + return null; + } +} + +/** + * 读取请求体 + */ +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +/** + * 处理 Webhook HTTP 回调 + * + * 此函数实现了完整的 Webhook 回调处理流程: + * 1. 读取请求体 + * 2. Ed25519 签名验证 + * 3. 解析 Payload + * 4. 根据 OPCode 分发处理: + * - OpCode 13: 回调校验(Validation) + * - OpCode 1: 心跳 ACK + * - OpCode 0: 事件分发 + * + * @param req - Node.js HTTP IncomingMessage + * @param res - Node.js HTTP ServerResponse + * @param credentials - Webhook 凭证(appID + appSecret) + * + * @example + * ```ts + * import http from 'http'; + * import { webhookHandler, registerWebhookHandler } from 'qq-guild-bot'; + * + * // 注册事件处理器 + * registerWebhookHandler('GROUP_AT_MESSAGE_CREATE', (payload) => { + * console.log('收到群@消息:', payload.d); + * return true; + * }); + * + * const server = http.createServer((req, res) => { + * webhookHandler(req, res, { appID: 'xxx', appSecret: 'yyy' }); + * }); + * server.listen(8080); + * ``` + */ +export async function webhookHandler( + req: IncomingMessage, + res: ServerResponse, + credentials: WebhookCredentials, +): Promise { + try { + // 1. 读取 body + const body = await readBody(req); + + BotLogger.debug(`[WEBHOOK] body: ${body.toString()}, len: ${body.length}`); + BotLogger.debug(`[WEBHOOK] headers: ${JSON.stringify(req.headers)}`); + + const traceID = (req.headers[HEADER_TRACE_ID.toLowerCase()] as string) || ''; + + // 2. 签名验证 + const headers = req.headers as Record; + let pass: boolean; + try { + pass = verify(credentials.appSecret, headers, body); + } catch (e) { + BotLogger.error(`[WEBHOOK] 签名验证异常: ${e}, traceID: ${traceID}`); + res.writeHead(401); + res.end(); + return; + } + + if (!pass) { + BotLogger.error(`[WEBHOOK] 签名验证失败, traceID: ${traceID}`); + res.writeHead(401); + res.end(); + return; + } + + // 3. 解析 Payload + let payload: WebhookPayload; + try { + payload = JSON.parse(body.toString('utf-8')); + } catch (e) { + BotLogger.error(`[WEBHOOK] 解析 payload 失败: ${e}, traceID: ${traceID}`); + res.writeHead(400); + res.end(); + return; + } + + BotLogger.info(`[WEBHOOK] payload: op=${payload.op}, t=${payload.t || ''}, traceID: ${traceID}`); + + // 4. 回调校验(OpCode 13) + if (payload.op === HTTP_CALLBACK_VALIDATION) { + const data = payload.d as Record | undefined; + if (!data || typeof data.plain_token !== 'string' || typeof data.event_ts !== 'string') { + BotLogger.error(`[WEBHOOK] 回调校验数据无效: ${JSON.stringify(payload.d)}, traceID: ${traceID}`); + res.writeHead(400); + res.end(); + return; + } + + const validationReq: WHValidationReq = { + plain_token: data.plain_token, + event_ts: data.event_ts, + }; + + const validationRsp = genValidationACK(validationReq, headers, credentials.appSecret); + if (validationRsp) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(validationRsp); + } else { + res.writeHead(500); + res.end(); + } + return; + } + + // 5. 心跳处理(OpCode 1) + if (payload.op === OpCode.HEARTBEAT) { + const seq = typeof payload.d === 'number' ? payload.d : 0; + const ack = genHeartbeatACK(seq); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(ack); + return; + } + + // 6. 事件分发(OpCode 0) + if (payload.op === OpCode.DISPATCH) { + let success = false; + try { + success = await parseAndHandle(payload, body, traceID); + } catch (e) { + BotLogger.error(`[WEBHOOK] 事件处理异常: ${e}, traceID: ${traceID}`); + } + const ack = genDispatchACK(success); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(ack); + return; + } + + // 未知 OpCode + BotLogger.warn(`[WEBHOOK] 未知 OpCode: ${payload.op}, traceID: ${traceID}`); + res.writeHead(200); + res.end(); + } catch (e) { + BotLogger.error(`[WEBHOOK] 处理异常: ${e}`); + res.writeHead(500); + res.end(); + } +} + +/** + * 解析并处理事件 + */ +async function parseAndHandle(payload: WebhookPayload, rawMessage: Buffer, traceID: string): Promise { + const eventType = payload.t; + if (!eventType) { + BotLogger.warn(`[WEBHOOK] 事件类型为空, traceID: ${traceID}`); + return false; + } + + // 查找具体事件处理器 + const handler = eventHandlers.get(eventType); + if (handler) { + const result = await handler(payload, rawMessage); + return result; + } + + // 兜底处理器 + if (plainHandler) { + const result = await plainHandler(payload, rawMessage); + return result; + } + + BotLogger.warn(`[WEBHOOK] 未注册事件处理器: ${eventType}, traceID: ${traceID}`); + return true; // 没有处理器时默认成功,避免服务端重试 +} diff --git a/src/openapi/v1/announce.ts b/src/openapi/v1/announce.ts index cb21556..ff115ac 100644 --- a/src/openapi/v1/announce.ts +++ b/src/openapi/v1/announce.ts @@ -79,4 +79,14 @@ export default class Announce implements AnnounceAPI { }; return this.request(options); } + + // 清除频道全局公告(messageID="all") + public cleanGuildAnnounce(guildID: string): Promise> { + return this.deleteGuildAnnounce(guildID, 'all'); + } + + // 清除子频道公告(messageID="all") + public cleanChannelAnnounce(channelID: string): Promise> { + return this.deleteChannelAnnounce(channelID, 'all'); + } } diff --git a/src/openapi/v1/c2c-message.ts b/src/openapi/v1/c2c-message.ts new file mode 100644 index 0000000..2cc211e --- /dev/null +++ b/src/openapi/v1/c2c-message.ts @@ -0,0 +1,59 @@ +import { + Config, + OpenAPIRequest, + IMessage, + C2CMessageAPI, + C2CMessageToCreate, + RichMediaMessage, + MediaResponse, +} from '@src/types'; +import { RestyResponse } from 'resty-client'; +import { getURL } from './resource'; + +export default class C2CMessage implements C2CMessageAPI { + public request: OpenAPIRequest; + public config: Config; + constructor(request: OpenAPIRequest, config: Config) { + this.request = request; + this.config = config; + } + + // 发送 C2C 消息 + public postC2CMessage(userID: string, message: C2CMessageToCreate): Promise> { + const options = { + method: 'POST' as const, + url: getURL('c2cMessagesURI'), + rest: { + userID, + }, + data: message, + }; + return this.request(options); + } + + // 撤回 C2C 消息 + public retractC2CMessage(userID: string, messageID: string): Promise> { + const options = { + method: 'DELETE' as const, + url: getURL('retractC2CMessageURI'), + rest: { + userID, + messageID, + }, + }; + return this.request(options); + } + + // 发送 C2C 富媒体消息 + public postC2CRichMedia(userID: string, message: RichMediaMessage): Promise> { + const options = { + method: 'POST' as const, + url: getURL('c2cRichMediaURI'), + rest: { + userID, + }, + data: message, + }; + return this.request(options); + } +} diff --git a/src/openapi/v1/channel.ts b/src/openapi/v1/channel.ts index 00682be..b0f29e2 100644 --- a/src/openapi/v1/channel.ts +++ b/src/openapi/v1/channel.ts @@ -76,4 +76,21 @@ export default class Channel implements ChannelAPI { }; return this.request(options); } + + // 创建私密子频道(便捷封装) + public createPrivateChannel( + guildID: string, + channel: PostChannelObj, + userIDs?: string[], + ): Promise> { + // 对齐 Go SDK: 若指定 userIDs 则 PrivateType=1(仅群主管理员可见+指定成员) + // 否则 PrivateType=2(管理员+指定成员可见) + if (userIDs && userIDs.length > 0) { + channel.private_type = 1; + channel.private_user_ids = userIDs; + } else { + channel.private_type = 2; + } + return this.postChannel(guildID, channel); + } } diff --git a/src/openapi/v1/direct-message.ts b/src/openapi/v1/direct-message.ts index ef1b664..4ec198c 100644 --- a/src/openapi/v1/direct-message.ts +++ b/src/openapi/v1/direct-message.ts @@ -39,4 +39,22 @@ export default class DirectMessage implements DirectMessageAPI { }; return this.request(options); } + + // 撤回私信消息 + public retractDMMessage(guildID: string, messageID: string, hideTip?: boolean): Promise> { + const params = Object.create(null); + if (hideTip) { + params.hidetip = hideTip; + } + const options = { + method: 'DELETE' as const, + url: getURL('dmsMessageURI'), + rest: { + guildID, + messageID, + }, + params, + }; + return this.request(options); + } } diff --git a/src/openapi/v1/group-message.ts b/src/openapi/v1/group-message.ts new file mode 100644 index 0000000..810313e --- /dev/null +++ b/src/openapi/v1/group-message.ts @@ -0,0 +1,59 @@ +import { + Config, + OpenAPIRequest, + IMessage, + GroupMessageAPI, + GroupMessageToCreate, + RichMediaMessage, + MediaResponse, +} from '@src/types'; +import { RestyResponse } from 'resty-client'; +import { getURL } from './resource'; + +export default class GroupMessage implements GroupMessageAPI { + public request: OpenAPIRequest; + public config: Config; + constructor(request: OpenAPIRequest, config: Config) { + this.request = request; + this.config = config; + } + + // 发送群消息 + public postGroupMessage(groupID: string, message: GroupMessageToCreate): Promise> { + const options = { + method: 'POST' as const, + url: getURL('groupMessagesURI'), + rest: { + groupID, + }, + data: message, + }; + return this.request(options); + } + + // 撤回群消息 + public retractGroupMessage(groupID: string, messageID: string): Promise> { + const options = { + method: 'DELETE' as const, + url: getURL('retractGroupMessageURI'), + rest: { + groupID, + messageID, + }, + }; + return this.request(options); + } + + // 发送群富媒体消息 + public postGroupRichMedia(groupID: string, message: RichMediaMessage): Promise> { + const options = { + method: 'POST' as const, + url: getURL('groupRichMediaURI'), + rest: { + groupID, + }, + data: message, + }; + return this.request(options); + } +} diff --git a/src/openapi/v1/guild.ts b/src/openapi/v1/guild.ts index 07f839b..6a4f81d 100644 --- a/src/openapi/v1/guild.ts +++ b/src/openapi/v1/guild.ts @@ -1,4 +1,15 @@ -import { Config, OpenAPIRequest, GuildAPI, GuildMembersPager, IGuild, IMember, IVoiceMember } from '@src/types'; +import { + Config, + OpenAPIRequest, + GuildAPI, + GuildMembersPager, + IGuild, + IMember, + IVoiceMember, + MemberDeleteOpts, + GuildRoleMembersPager, + GuildRoleMembersRes, +} from '@src/types'; import { RestyResponse } from 'resty-client'; import { getURL } from './resource'; @@ -45,8 +56,8 @@ export default class Guild implements GuildAPI { }; return this.request(options); } - // 删除指定频道成员 - public deleteGuildMember(guildID: string, userID: string): Promise> { + // 删除指定频道成员(支持加黑名单、撤回消息天数) + public deleteGuildMember(guildID: string, userID: string, opts?: MemberDeleteOpts): Promise> { const options = { method: 'DELETE' as const, url: getURL('guildMemberURI'), @@ -54,6 +65,7 @@ export default class Guild implements GuildAPI { guildID, userID, }, + data: opts, }; return this.request(options); } @@ -68,4 +80,21 @@ export default class Guild implements GuildAPI { }; return this.request(options); } + // 获取频道身份组成员列表 + public guildRoleMembers( + guildID: string, + roleID: string, + pager?: GuildRoleMembersPager, + ): Promise> { + const options = { + method: 'GET' as const, + url: getURL('guildRoleMembersURI'), + rest: { + guildID, + roleID, + }, + params: pager || {}, + }; + return this.request(options); + } } diff --git a/src/openapi/v1/message-setting.ts b/src/openapi/v1/message-setting.ts new file mode 100644 index 0000000..272f342 --- /dev/null +++ b/src/openapi/v1/message-setting.ts @@ -0,0 +1,73 @@ +import { Config, OpenAPIRequest, MessageSettingAPI, IMessageSetting, SettingGuideAPI, IMessage } from '@src/types'; +import { RestyResponse } from 'resty-client'; +import { getURL } from './resource'; + +/** 消息设置 API 实现 */ +export class MessageSetting implements MessageSettingAPI { + public request: OpenAPIRequest; + public config: Config; + + constructor(request: OpenAPIRequest, config: Config) { + this.request = request; + this.config = config; + } + + /** 获取频道消息频率设置 */ + public getMessageSetting(guildID: string): Promise> { + const options = { + method: 'GET' as const, + url: getURL('messageSettingURI'), + rest: { + guildID, + }, + }; + return this.request(options); + } +} + +/** 设置引导 API 实现 */ +export class SettingGuideImpl implements SettingGuideAPI { + public request: OpenAPIRequest; + public config: Config; + + constructor(request: OpenAPIRequest, config: Config) { + this.request = request; + this.config = config; + } + + /** 发送频道设置引导消息 */ + public postSettingGuide(channelID: string, atUserIDs?: string[]): Promise> { + let content = ''; + if (atUserIDs && atUserIDs.length > 0) { + content = atUserIDs.map((id) => `<@${id}>`).join(''); + } + const options = { + method: 'POST' as const, + url: getURL('settingGuideURI'), + rest: { + channelID, + }, + data: { + content, + }, + }; + return this.request(options); + } + + /** 发送私信设置引导消息 */ + public postDMSettingGuide(guildID: string, jumpGuildID: string): Promise> { + const options = { + method: 'POST' as const, + url: getURL('dmSettingGuideURI'), + rest: { + guildID, + }, + data: { + setting_guide: { + guild_id: jumpGuildID, + }, + }, + }; + return this.request(options); + } +} diff --git a/src/openapi/v1/message.ts b/src/openapi/v1/message.ts index cda539e..a912bb4 100644 --- a/src/openapi/v1/message.ts +++ b/src/openapi/v1/message.ts @@ -53,6 +53,24 @@ export default class Message implements MessageAPI { return this.request(options); } + // 编辑消息 + public patchMessage( + channelID: string, + messageID: string, + message: MessageToCreate, + ): Promise> { + const options = { + method: 'PATCH' as const, + url: getURL('messageURI'), + rest: { + channelID, + messageID, + }, + data: message, + }; + return this.request(options); + } + // 撤回消息 public deleteMessage(channelID: string, messageID: string, hideTip?: boolean): Promise> { const params = Object.create(null); diff --git a/src/openapi/v1/openapi.ts b/src/openapi/v1/openapi.ts index 1a100c1..2fdf830 100644 --- a/src/openapi/v1/openapi.ts +++ b/src/openapi/v1/openapi.ts @@ -1,4 +1,3 @@ -/* eslint-disable prefer-promise-reject-errors */ import { register } from '@src/openapi/openapi'; import resty, { RequestOptions, RestyResponse } from 'resty-client'; import PinsMessage from './pins-message'; @@ -17,7 +16,11 @@ import Announce from './announce'; import Schedule from './schedule'; import GuildPermissions from './guild-permissions'; import Interaction from './interaction'; -import { addUserAgent, addAuthorization, buildUrl } from '@src/utils/utils'; +import GroupMessage from './group-message'; +import C2CMessage from './c2c-message'; +import { MessageSetting, SettingGuideImpl } from './message-setting'; +import { addUserAgent, addAuthorization, addAppID, buildUrl, getProxyConfig } from '@src/utils/utils'; +import { QQBotTokenSource } from '@src/token'; import { GuildAPI, ChannelAPI, @@ -37,8 +40,14 @@ import { ReactionAPI, PinsMessageAPI, InteractionAPI, + GroupMessageAPI, + C2CMessageAPI, + MessageSettingAPI, + SettingGuideAPI, } from '@src/types'; + export const apiVersion = 'v1'; + export class OpenAPI implements IOpenAPI { static newClient(config: Config) { return new OpenAPI(config); @@ -46,8 +55,12 @@ export class OpenAPI implements IOpenAPI { config: Config = { appID: '', - token: '', + secret: '', }; + + // Token 管理 + public tokenSource: QQBotTokenSource; + public guildApi!: GuildAPI; public channelApi!: ChannelAPI; public meApi!: MeAPI; @@ -64,9 +77,18 @@ export class OpenAPI implements IOpenAPI { public interactionApi!: InteractionAPI; public pinsMessageApi!: PinsMessageAPI; public guildPermissionsApi!: GuildPermissionsAPI; + public groupMessageApi!: GroupMessageAPI; + public c2cMessageApi!: C2CMessageAPI; + public messageSettingApi!: MessageSettingAPI; + public settingGuideApi!: SettingGuideAPI; constructor(config: Config) { this.config = config; + // 创建 TokenSource + this.tokenSource = new QQBotTokenSource({ + appID: config.appID, + secret: config.secret, + }); this.register(this); } @@ -88,25 +110,34 @@ export class OpenAPI implements IOpenAPI { client.reactionApi = new Reaction(this.request, this.config); client.interactionApi = new Interaction(this.request, this.config); client.pinsMessageApi = new PinsMessage(this.request, this.config); + client.groupMessageApi = new GroupMessage(this.request, this.config); + client.c2cMessageApi = new C2CMessage(this.request, this.config); + client.messageSettingApi = new MessageSetting(this.request, this.config); + client.settingGuideApi = new SettingGuideImpl(this.request, this.config); } - // 基础rest请求 - public request = any>(options: RequestOptions): Promise> { - const { appID, token } = this.config; - options.headers = { ...options.headers }; + // 基础rest请求(箭头函数绑定 this) + public request = async = any>(options: RequestOptions): Promise> => { + const opts = options as any; + // 获取 AccessToken + const token = await this.tokenSource.getToken(); + + opts.headers = { ...opts.headers }; // 添加 UA - addUserAgent(options.headers); - // 添加鉴权信息 - addAuthorization(options.headers, appID, token); + addUserAgent(opts.headers); + // 添加 AppID + addAppID(opts.headers, this.config.appID); + // 添加鉴权信息(QQBot AccessToken) + addAuthorization(opts.headers, token.accessToken); // 组装完整Url - const botUrl = buildUrl(options.url, this.config.sandbox); + const botUrl = buildUrl(opts.url, this.config.sandbox); // 简化错误信息,后续可考虑通过中间件形式暴露给用户自行处理 resty.useRes( (result) => result, (error) => { - let traceid = error?.response?.headers?.['x-tps-trace-id']; + const traceid = error?.response?.headers?.['x-tps-trace-id']; if (error?.response?.data) { return Promise.reject({ ...error.response.data, @@ -123,9 +154,29 @@ export class OpenAPI implements IOpenAPI { }, ); - const client = resty.create(options); - return client.request(botUrl!, options); - } + // 合并代理配置(如果有 https_proxy 环境变量,使用 CONNECT 隧道) + const proxyConfig = getProxyConfig(); + const requestOptions = { ...opts, ...proxyConfig }; + + // 打印请求日志 + const method = (opts.method || 'GET').toUpperCase(); + console.log(`[API Request] ${method} ${botUrl}`); + if (opts.data) { + console.log(`[API Request Body]`, JSON.stringify(opts.data, null, 2)); + } + + const client = resty.create(requestOptions); + const response = await client.request(botUrl!, requestOptions); + + // 打印响应日志 + const traceId = response.headers?.['x-tps-trace-id'] || ''; + console.log(`[API Response] ${method} ${botUrl} status=${response.status} trace=${traceId}`); + if (response.data) { + console.log(`[API Response Body]`, JSON.stringify(response.data, null, 2)); + } + + return response; + }; } export function v1Setup() { diff --git a/src/openapi/v1/pins-message.ts b/src/openapi/v1/pins-message.ts index f85b13a..1df9085 100644 --- a/src/openapi/v1/pins-message.ts +++ b/src/openapi/v1/pins-message.ts @@ -50,4 +50,9 @@ export default class PinsMessage implements PinsMessageAPI { }; return this.request(options); } + + // 清除全部精华消息(messageID="all") + public cleanPins(channelID: string): Promise> { + return this.deletePinsMessage(channelID, 'all'); + } } diff --git a/src/openapi/v1/resource.ts b/src/openapi/v1/resource.ts index 5135b30..3607667 100644 --- a/src/openapi/v1/resource.ts +++ b/src/openapi/v1/resource.ts @@ -2,6 +2,7 @@ const apiMap = { guildURI: '/guilds/:guildID', guildMembersURI: '/guilds/:guildID/members', guildMemberURI: '/guilds/:guildID/members/:userID', + guildRoleMembersURI: '/guilds/:guildID/roles/:roleID/members', channelsURI: '/guilds/:guildID/channels', channelURI: '/channels/:channelID', guildAnnouncesURI: '/guilds/:guildID/announces', @@ -23,6 +24,7 @@ const apiMap = { memberRoleURI: '/guilds/:guildID/members/:userID/roles/:roleID', userMeDMURI: '/users/@me/dms', dmsURI: '/dms/:guildID/messages', + dmsMessageURI: '/dms/:guildID/messages/:messageID', channelPermissionsURI: '/channels/:channelID/members/:userID/permissions', channelRolePermissionsURI: '/channels/:channelID/roles/:roleID/permissions', schedulesURI: '/channels/:channelID/schedules', @@ -34,7 +36,24 @@ const apiMap = { pinsMessageIdURI: '/channels/:channelID/pins/:messageID', pinsMessageURI: '/channels/:channelID/pins', interactionURI: '/interactions/:interactionID', - guildVoiceMembersURI: '/channels/:channelID/voice/members', // 语音子频道在线成员车查询 - botMic: '/channels/:channelID/mic', // 机器人上麦|下麦 + guildVoiceMembersURI: '/channels/:channelID/voice/members', + botMic: '/channels/:channelID/mic', + + // ======= 群消息 ======= + groupMessagesURI: '/v2/groups/:groupID/messages', + groupRichMediaURI: '/v2/groups/:groupID/files', + retractGroupMessageURI: '/v2/groups/:groupID/messages/:messageID', + + // ======= C2C 消息 ======= + c2cMessagesURI: '/v2/users/:userID/messages', + c2cRichMediaURI: '/v2/users/:userID/files', + retractC2CMessageURI: '/v2/users/:userID/messages/:messageID', + + // ======= 消息设置 ======= + messageSettingURI: '/guilds/:guildID/message/setting', + + // ======= 设置引导 ======= + settingGuideURI: '/channels/:channelID/settingguide', + dmSettingGuideURI: '/dms/:guildID/settingguide', }; export const getURL = (endpoint: keyof typeof apiMap) => apiMap[endpoint]; diff --git a/src/token/index.ts b/src/token/index.ts new file mode 100644 index 0000000..841e9c5 --- /dev/null +++ b/src/token/index.ts @@ -0,0 +1,2 @@ +export { QQBotTokenSource } from './token-source'; +export type { AccessToken, TokenSourceConfig } from './token-source'; diff --git a/src/token/token-source.ts b/src/token/token-source.ts new file mode 100644 index 0000000..7208cad --- /dev/null +++ b/src/token/token-source.ts @@ -0,0 +1,250 @@ +import { BotLogger } from '@src/utils/logger'; + +// Token 域名 +const DEFAULT_TOKEN_DOMAIN = 'https://bots.qq.com'; +const TOKEN_PATH = '/app/getAppAccessToken'; + +// 刷新策略常量 +const MIN_EXPIRY_DELTA_SEC = 60; // 最少提前 60 秒刷新 +const EXPIRY_DELTA_RATIO = 0.1; // 提前 Token TTL 的 10% 刷新 +const JITTER_RATIO = 0.05; // 随机抖动为 TTL 的 5% +const MAX_JITTER_MS = 5000; // 最大抖动 5 秒 +const MAX_CONSECUTIVE_FAILURES = 10; // 连续失败上限 +const RETRY_INTERVAL_SEC = 5; // 失败重试间隔 5 秒 + +// Token 请求体 +interface TokenRequest { + appId: string; + clientSecret: string; +} + +// Token 响应体(成功时只有 access_token + expires_in,失败时有 code + message) +interface TokenResponse { + code?: number; + message?: string; + access_token: string; + expires_in: string; // 服务端返回字符串 +} + +// Token 对象 +export interface AccessToken { + accessToken: string; + tokenType: string; + expiresIn: number; // 秒 + expiry: Date; +} + +// TokenSource 配置 +export interface TokenSourceConfig { + appID: string; + secret: string; + domain?: string; +} + +/** + * QQBotTokenSource + * 实现 AccessToken 的获取、缓存和自动刷新 + * + * 刷新策略(对齐 Go SDK botgo/token): + * - 首次调用 getToken() 时获取 Token 并自动启动后台定时刷新 + * - 提前量 = max(TTL * 10%, 60s),避免在 Token 过期瞬间才刷新 + * - 随机抖动 = min(TTL * 5%, 5s),多实例部署时错开刷新请求 + * - 连续刷新失败 10 次后停止,防止无限重试 + * - 防并发:多个 getToken() 调用复用同一个 pending 请求 + */ +export class QQBotTokenSource { + private config: TokenSourceConfig; + private cachedToken: AccessToken | null = null; + private pendingRequest: Promise | null = null; + private refreshTimer: ReturnType | null = null; + private consecutiveFailures = 0; + private _stopped = false; + private _refreshStarted = false; + + constructor(config: TokenSourceConfig) { + this.config = config; + } + + /** + * 获取当前有效的 AccessToken + * 首次调用时会获取 Token 并自动启动后台刷新 + */ + async getToken(): Promise { + // 缓存命中:Token 存在且未过期 + if (this.cachedToken && this.isTokenValid(this.cachedToken)) { + return this.cachedToken; + } + + // 防并发:复用正在进行的请求 + if (this.pendingRequest) { + return this.pendingRequest; + } + + this.pendingRequest = this.fetchNewToken(); + try { + const token = await this.pendingRequest; + this.cachedToken = token; + + // 首次获取成功后自动启动后台刷新 + if (!this._refreshStarted && !this._stopped) { + this._refreshStarted = true; + this.scheduleRefresh(token.expiresIn); + } + + return token; + } finally { + this.pendingRequest = null; + } + } + + /** + * 停止自动刷新 + */ + stopRefresh(): void { + this._stopped = true; + this._refreshStarted = false; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + BotLogger.info('[TOKEN] 自动刷新已停止'); + } + + /** + * 主动使缓存失效并重新获取 + */ + async invalidateAndRefresh(): Promise { + this.cachedToken = null; + return this.getToken(); + } + + /** + * 获取 appID + */ + getAppID(): string { + return this.config.appID; + } + + // ======= 私有方法 ======= + + private isTokenValid(token: AccessToken): boolean { + return token.expiry.getTime() > Date.now(); + } + + private async fetchNewToken(): Promise { + const url = this.getTokenURL(); + const body: TokenRequest = { + appId: this.config.appID, + clientSecret: this.config.secret, + }; + + BotLogger.info('[TOKEN] 开始获取 AccessToken'); + + const response: any = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`[TOKEN] HTTP 请求失败: ${response.status} ${response.statusText}`); + } + + const data: TokenResponse = await response.json(); + + // 失败时服务端返回 code + message;成功时直接返回 access_token + expires_in(无 code 字段) + if (data.code && data.code !== 0) { + throw new Error(`[TOKEN] 获取 Token 失败: ${data.code} ${data.message}`); + } + + if (!data.access_token) { + throw new Error(`[TOKEN] 响应缺少 access_token: ${JSON.stringify(data)}`); + } + + const expiresIn = parseInt(data.expires_in, 10); + const expiry = new Date(Date.now() + expiresIn * 1000); + + BotLogger.info(`[TOKEN] Token 获取成功, 有效期 ${expiresIn}s, 过期时间 ${expiry.toLocaleTimeString()}`); + + return { + accessToken: data.access_token, + tokenType: 'QQBot', + expiresIn, + expiry, + }; + } + + private getTokenURL(): string { + const domain = this.config.domain || DEFAULT_TOKEN_DOMAIN; + return `${domain}${TOKEN_PATH}`; + } + + /** + * 定时刷新调度器 + * 在 Token 过期前提前刷新,保证 getToken() 始终能返回有效 Token + */ + private scheduleRefresh(tokenTTLSec: number): void { + if (this._stopped) return; + + // 清除旧定时器 + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + const refreshMs = this.calcRefreshDelay(tokenTTLSec); + const refreshSec = Math.round(refreshMs / 1000); + BotLogger.info(`[TOKEN] 将在 ${refreshSec}s 后自动刷新 Token`); + + this.refreshTimer = setTimeout(async () => { + try { + BotLogger.info('[TOKEN] 开始自动刷新 Token'); + this.cachedToken = null; // 使缓存失效 + const token = await this.getToken(); + this.consecutiveFailures = 0; + // getToken 内部已经调用了 scheduleRefresh,不需要再次调度 + } catch (e) { + this.consecutiveFailures++; + BotLogger.info(`[TOKEN] 自动刷新失败 (${this.consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${e}`); + if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + BotLogger.info('[TOKEN] 连续刷新失败超过上限,停止自动刷新'); + this._stopped = true; + this._refreshStarted = false; + return; + } + // 失败后短间隔重试 + this.scheduleRefresh(RETRY_INTERVAL_SEC); + } + }, refreshMs); + } + + /** + * 计算下次刷新延迟(毫秒) + * + * 策略: + * refreshDelay = TTL - max(TTL * 10%, 60s) - random(0, min(TTL * 5%, 5s)) + * + * 例:TTL=2522s → 提前量=252s, 抖动≤5s → 约 2265~2270s 后刷新 + * 例:TTL=120s → 提前量=60s, 抖动≤5s → 约 55~60s 后刷新 + * 例:TTL=30s → 提前量=60s > TTL, 立即刷新 + */ + private calcRefreshDelay(tokenTTLSec: number): number { + const ttlMs = tokenTTLSec * 1000; + + // 提前量 = max(TTL * 10%, 60s) + const deltaMs = Math.max(tokenTTLSec * EXPIRY_DELTA_RATIO, MIN_EXPIRY_DELTA_SEC) * 1000; + + // 如果 TTL 比提前量还小,立即刷新 + if (ttlMs <= deltaMs) { + return 0; + } + + let refreshMs = ttlMs - deltaMs; + + // 随机抖动 = min(TTL * 5%, 5s) + const jitterMs = Math.min(tokenTTLSec * JITTER_RATIO * 1000, MAX_JITTER_MS); + refreshMs -= Math.floor(Math.random() * jitterMs); + + return Math.max(refreshMs, 0); + } +} diff --git a/src/types/openapi/index.ts b/src/types/openapi/index.ts index f7cbee2..1dfa588 100644 --- a/src/types/openapi/index.ts +++ b/src/types/openapi/index.ts @@ -15,12 +15,14 @@ import { ReactionAPI } from './v1/reaction'; import { InteractionAPI } from './v1/interaction'; import { PinsMessageAPI } from './v1/pins-message'; import { GuildPermissionsAPI } from './v1/guild-permission'; +import { GroupMessageAPI, C2CMessageAPI } from './v1/group-message'; +import { MessageSettingAPI, SettingGuideAPI } from './v1/message-setting'; export type OpenAPIRequest = = any>(options: RequestOptions) => Promise>; export interface Config { appID: string; - token: string; + secret: string; sandbox?: boolean; } @@ -43,12 +45,16 @@ export interface IOpenAPI { reactionApi: ReactionAPI; interactionApi: InteractionAPI; pinsMessageApi: PinsMessageAPI; + groupMessageApi: GroupMessageAPI; + c2cMessageApi: C2CMessageAPI; + messageSettingApi: MessageSettingAPI; + settingGuideApi: SettingGuideAPI; } export type APIVersion = `v${number}`; export interface Token { - appID: number; + appID: string; accessToken: string; type: string; } @@ -74,3 +80,6 @@ export * from './v1/reaction'; export * from './v1/interaction'; export * from './v1/pins-message'; export * from './v1/guild-permission'; +export * from './v1/group-message'; +export * from './v1/message-setting'; +export * from './v1/webhook'; diff --git a/src/types/openapi/v1/announce.ts b/src/types/openapi/v1/announce.ts index cf3118a..cc814c7 100644 --- a/src/types/openapi/v1/announce.ts +++ b/src/types/openapi/v1/announce.ts @@ -9,6 +9,10 @@ export interface AnnounceAPI { postGuildRecommend: (guildID: string, recommendObj: RecommendObj) => Promise>; postChannelAnnounce: (channelID: string, messageID: string) => Promise>; deleteChannelAnnounce: (channelID: string, messageID: string) => Promise>; + /** 清除频道全局公告(不校验 messageID) */ + cleanGuildAnnounce: (guildID: string) => Promise>; + /** 清除子频道公告(不校验 messageID) */ + cleanChannelAnnounce: (channelID: string) => Promise>; } // 公告对象(Announce) diff --git a/src/types/openapi/v1/channel.ts b/src/types/openapi/v1/channel.ts index 3b8c5cb..7661e4e 100644 --- a/src/types/openapi/v1/channel.ts +++ b/src/types/openapi/v1/channel.ts @@ -9,6 +9,12 @@ export interface ChannelAPI { postChannel: (guildID: string, channel: PostChannelObj) => Promise>; patchChannel: (channelID: string, channel: PatchChannelObj) => Promise>; deleteChannel: (channelID: string) => Promise>; + /** 创建私密子频道(便捷封装,自动设置 privateType) */ + createPrivateChannel: ( + guildID: string, + channel: PostChannelObj, + userIDs?: string[], + ) => Promise>; } // 子频道类型 ChannelType diff --git a/src/types/openapi/v1/direct-message.ts b/src/types/openapi/v1/direct-message.ts index 2595073..bb34081 100644 --- a/src/types/openapi/v1/direct-message.ts +++ b/src/types/openapi/v1/direct-message.ts @@ -9,6 +9,8 @@ export interface DirectMessageAPI { createDirectMessage: (dm: DirectMessageToCreate) => Promise>; // PostDirectMessage 在私信频道内发消息 postDirectMessage: (guildID: string, msg: MessageToCreate) => Promise>; + // RetractDMMessage 撤回私信消息 + retractDMMessage: (guildID: string, messageID: string, hideTip?: boolean) => Promise>; } // DirectMessageToCreate 创建私信频道的结构体定义 diff --git a/src/types/openapi/v1/group-message.ts b/src/types/openapi/v1/group-message.ts new file mode 100644 index 0000000..a0e4d90 --- /dev/null +++ b/src/types/openapi/v1/group-message.ts @@ -0,0 +1,272 @@ +import { RestyResponse } from 'resty-client'; +import { IMessage, MessageToCreate } from './message'; + +/** + * ============= GroupMessage 群消息接口 ============= + */ +export interface GroupMessageAPI { + // 发送群消息 + postGroupMessage: (groupID: string, message: GroupMessageToCreate) => Promise>; + // 撤回群消息 + retractGroupMessage: (groupID: string, messageID: string) => Promise>; + // 发送群富媒体消息 + postGroupRichMedia: (groupID: string, message: RichMediaMessage) => Promise>; +} + +/** + * ============= C2CMessage C2C 私聊消息接口 ============= + */ +export interface C2CMessageAPI { + // 发送 C2C 消息 + postC2CMessage: (userID: string, message: C2CMessageToCreate) => Promise>; + // 撤回 C2C 消息 + retractC2CMessage: (userID: string, messageID: string) => Promise>; + // 发送 C2C 富媒体消息 + postC2CRichMedia: (userID: string, message: RichMediaMessage) => Promise>; +} + +// 群消息发送结构体 +export interface GroupMessageToCreate extends MessageToCreate { + msg_type?: MessageType; // 消息类型 + event_id?: string; // 要回复的事件ID + msg_seq?: number; // 消息序号(去重用) +} + +// C2C消息发送结构体 +export interface C2CMessageToCreate extends MessageToCreate { + msg_type?: MessageType; // 消息类型 + event_id?: string; // 要回复的事件ID + msg_seq?: number; // 消息序号(去重用) +} + +// ========= 消息类型枚举 ========= + +/** 消息类型(msg_type) */ +export enum MessageType { + TEXT = 0, // 文字消息 + MARKDOWN = 2, // Markdown 消息 + ARK = 3, // Ark 模板消息 + EMBED = 4, // Embed 消息 + AT = 5, // @消息 + INPUT_NOTIFY = 6, // 输入状态消息 + RICH_MEDIA = 7, // 富媒体消息(图片、视频等) + CARD = 8, // 卡片消息 + PARALLEL = 101, // 并行消息(逐条转发) + CHAT_HISTORY = 102, // 聊天消息(合并转发) + REFERENCE = 103, // 引用消息 +} + +// ========= 富媒体消息 ========= + +/** 富媒体消息(用于上传/发送图片、视频、语音、文件) */ +export interface RichMediaMessage { + file_type: FileType; // 文件类型 + url?: string; // 文件 URL(http/https) + srv_send_msg?: boolean; // true: 直接发送到群/C2C(占用主动消息频率);false: 仅上传 + content?: string; + msg_seq?: number; // 消息序号 + file_name?: string; // 直传文件名 + file_data?: string; // 文件 base64 数据 +} + +/** 文件类型 */ +export enum FileType { + IMAGE = 1, // 图片 + VIDEO = 2, // 视频 + AUDIO = 3, // 语音(仅支持 silk 格式) + FILE = 4, // 文件 +} + +/** 富媒体上传响应 */ +export interface MediaResponse { + file_uuid?: string; + file_info?: string; + ttl?: number; +} + +// ========= Markdown 消息 ========= + +/** Markdown 消息 */ +export interface Markdown { + /** 模版 ID */ + template_id?: number; + /** 自定义模板 ID */ + custom_template_id?: string; + /** 模版参数 */ + params?: MarkdownParams[]; + /** 原生 markdown 内容 */ + content?: string; + /** 样式 */ + style?: MarkdownStyle; + /** 引导消息 */ + process_msg?: string; + /** 申请授权 */ + apply_authorize?: MarkdownApplyAuthorize; +} + +/** Markdown 参数 */ +export interface MarkdownParams { + key: string; + values: string[]; +} + +/** Markdown 样式 */ +export interface MarkdownStyle { + /** 字体大小: small / middle / large */ + main_font_size?: string; + /** 布局: hide_avatar_and_center 隐藏头像并居中 */ + layout?: string; +} + +/** Markdown 授权申请 */ +export interface MarkdownApplyAuthorize { + scope?: string; + callback_data?: string; +} + +// ========= 流式消息 (Stream) ========= + +/** 流式消息 */ +export interface Stream { + /** 状态: 1=正文生成中, 10=正文结束, 11=引导消息生成中, 20=引导消息结束 */ + state?: number; + /** 流式消息 ID(第一条不填,后续填第一片返回的 msgID) */ + id?: string; + /** 流式消息序号(从 0 开始) */ + index?: number; + /** 重新生成标记,reset 时 index 从 0 开始 */ + reset?: boolean; +} + +/** 流式消息状态常量 */ +export enum StreamState { + /** 正文生成中 */ + CONTENT_GENERATING = 1, + /** 正文结束 */ + CONTENT_END = 10, + /** 引导消息生成中 */ + GUIDE_GENERATING = 11, + /** 引导消息结束 */ + GUIDE_END = 20, +} + +// ========= 消息操作按钮 (ActionButton) ========= + +/** 消息操作按钮 */ +export interface ActionButton { + /** 模块 ID(待废弃) */ + template_id?: number; + /** 回调数据(最长 128 字符) */ + callback_data?: string; + /** 反馈按钮(赞踩) */ + feedback?: boolean; + /** TTS 语音播放 */ + tts?: boolean; + /** 重新生成 */ + re_generate?: boolean; + /** 停止生成 */ + stop_generate?: boolean; + /** 分享 */ + share?: boolean; + /** 复制 */ + copy?: boolean; + /** 跳转按钮 */ + jump_btn?: ActionJumpButton; +} + +/** 跳转按钮 */ +export interface ActionJumpButton { + /** 按钮标题 */ + title?: string; + /** 链接(必填) */ + url: string; +} + +// ========= 输入状态通知 (InputNotify) ========= + +/** 输入状态通知 */ +export interface InputNotify { + /** 输入类型: 1=对方正在输入, 2=取消展示 */ + input_type?: number; + /** 状态持续时长(秒) */ + input_second?: number; +} + +// ========= 卡片消息 (CardMessage) ========= + +/** 卡片消息 */ +export interface CardMessage { + /** 卡片类型: "tuwen" 图文消息 */ + type?: string; + /** 卡片内容 */ + content?: Record; +} + +// ========= 并行消息 & 聊天记录 ========= + +/** 并行消息 / 聊天记录(转发消息) */ +export interface ParallelMessage { + msg_nodes?: MessageNode[]; +} + +/** 消息节点 */ +export interface MessageNode { + message_type?: MessageType; + content?: string; + attachments?: Array<{ url: string }>; + ark_data?: ARKData; +} + +/** ARK 数据 */ +export interface ARKData { + template_id?: number; + kv?: Array<{ + key: string; + value?: string; + obj?: Array<{ obj_kv?: Array<{ key: string; value?: string }> }>; + }>; +} + +// ========= 富媒体文件信息 (MediaInfo) ========= + +/** 富媒体文件信息(通过上传接口获得) */ +export interface MediaInfo { + file_info?: string; +} + +// ========= 交互区按钮 (PromptKeyboard) ========= + +/** 交互区按钮(消息底部快捷按钮区域) */ +export interface PromptKeyboard { + keyboard?: import('./message').MessageKeyboard; +} + +// ========= 扩展信息 (ExtInfo) ========= + +/** 扩展信息 */ +export interface ExtInfo { + /** 批量回复的其它 msgid/eventid */ + reply_ids?: string[]; + /** 透传字段 */ + extend?: Record; + /** 需隐藏的 message ids */ + hide_ids?: string[]; +} + +// ========= 消息场景 & 客户端信息 ========= + +/** 消息场景 */ +export interface MessageScene { + /** 来源: realtime_voice / ai_search / 默认 AIO */ + source?: string; + callback_data?: string; + ext?: string[]; +} + +/** 客户端信息 */ +export interface ClientInfo { + /** 客户端版本号 */ + version?: number; + /** 平台: 1=安卓, 2=iOS, 3=Windows, 4=Mac */ + platform?: number; +} diff --git a/src/types/openapi/v1/guild.ts b/src/types/openapi/v1/guild.ts index e74a3c3..204581c 100644 --- a/src/types/openapi/v1/guild.ts +++ b/src/types/openapi/v1/guild.ts @@ -9,8 +9,14 @@ export interface GuildAPI { guild: (guildID: string) => Promise>; guildMember: (guildID: string, userID: string) => Promise>; guildMembers: (guildID: string, pager?: GuildMembersPager) => Promise>; - deleteGuildMember: (guildID: string, userID: string) => Promise>; + deleteGuildMember: (guildID: string, userID: string, opts?: MemberDeleteOpts) => Promise>; guildVoiceMembers: (channelID: string) => Promise>; + /** 获取频道身份组成员列表 */ + guildRoleMembers: ( + guildID: string, + roleID: string, + pager?: GuildRoleMembersPager, + ) => Promise>; } // 频道对象(Guild) @@ -53,3 +59,23 @@ export interface GuildMembersPager { after: string; limit: number; } + +/** 踢出成员选项 */ +export interface MemberDeleteOpts { + /** 同时加入黑名单 */ + add_blacklist?: boolean; + /** 撤回消息天数: 0(不删) / 3 / 7 / 15 / 30 / -1(全部) */ + delete_history_msg_days?: number; +} + +/** 身份组成员分页参数 */ +export interface GuildRoleMembersPager { + start_index?: string; + limit?: string; +} + +/** 身份组成员列表响应 */ +export interface GuildRoleMembersRes { + data: IMember[]; + next: string; +} diff --git a/src/types/openapi/v1/message-setting.ts b/src/types/openapi/v1/message-setting.ts new file mode 100644 index 0000000..be2d4a1 --- /dev/null +++ b/src/types/openapi/v1/message-setting.ts @@ -0,0 +1,43 @@ +import { RestyResponse } from 'resty-client'; +import { IMessage } from './message'; + +/** + * ============= MessageSetting 消息设置接口 ============= + */ +export interface MessageSettingAPI { + /** 获取频道消息频率设置 */ + getMessageSetting: (guildID: string) => Promise>; +} + +/** 消息频率设置 */ +export interface IMessageSetting { + /** 是否禁止创建私信 */ + disable_create_dm?: boolean; + /** 是否禁止推送消息 */ + disable_push_msg?: boolean; + /** 子频道 ID 列表 */ + channel_ids?: string[]; + /** 每个子频道允许主动推送消息最大消息条数 */ + channel_push_max_num?: number; +} + +/** + * ============= SettingGuide 设置引导接口 ============= + */ +export interface SettingGuideAPI { + /** 发送频道设置引导消息 */ + postSettingGuide: (channelID: string, atUserIDs?: string[]) => Promise>; + /** 发送私信设置引导消息 */ + postDMSettingGuide: (guildID: string, jumpGuildID: string) => Promise>; +} + +/** 设置引导消息体 */ +export interface SettingGuideToCreate { + content?: string; + setting_guide?: SettingGuide; +} + +/** 设置引导 */ +export interface SettingGuide { + guild_id: string; +} diff --git a/src/types/openapi/v1/message.ts b/src/types/openapi/v1/message.ts index 000eaa9..1611f76 100644 --- a/src/types/openapi/v1/message.ts +++ b/src/types/openapi/v1/message.ts @@ -1,6 +1,21 @@ import { RestyResponse } from 'resty-client'; import { IMember } from './guild'; import { IUser } from './me'; +import type { + Markdown, + Stream, + ActionButton, + InputNotify, + CardMessage, + MediaInfo, + PromptKeyboard, + ExtInfo, + ParallelMessage, + MessageScene, + ClientInfo, + ARKData, + MessageType, +} from './group-message'; /** * ============= Message 消息接口 ============= @@ -9,6 +24,7 @@ export interface MessageAPI { message: (channelID: string, messageID: string) => Promise>; messages: (channelID: string, pager: MessagesPager) => Promise>; postMessage: (channelID: string, message: MessageToCreate) => Promise>; + patchMessage: (channelID: string, messageID: string, message: MessageToCreate) => Promise>; deleteMessage: (channelID: string, messageID: string, hideTip?: boolean) => Promise>; } @@ -57,11 +73,12 @@ export interface ArkObjKV { value: string; } -// 消息对象(Message) +// 消息对象(Message) - 接收消息结构 export interface IMessage { id: string; // 消息ID channel_id: string; // 子频道ID guild_id: string; // 频道ID + group_id?: string; // 群ID content: string; // 内容 timestamp: string; // 发送时间 edited_timestamp: string; // 消息编辑时间 @@ -74,6 +91,18 @@ export interface IMessage { ark: Ark; // ark 消息 seq?: number; // 用于消息间的排序 seq_in_channel?: string; // 子频道消息 seq + direct_message?: boolean; // 是否私信消息 + src_guild_id?: string; // 来源频道ID + message_reference?: MessageReference; // 引用消息 + // 以下为新增字段(对齐 Go SDK) + message_type?: MessageType; // 消息类型 + file_info?: string; // 富媒体文件信息 + ttl?: number; // 消息有效期 + message_scene?: MessageScene; // 消息场景 + client_info?: ClientInfo; // 客户端信息 + ark_data?: ARKData; // ARK 数据 + parallel_message?: ParallelMessage; // 并行/合并转发消息 + remain_msg_len?: number; // 流式消息剩余长度 } // 接口返回的数据多一层message @@ -91,29 +120,54 @@ export interface MessagesPager { export interface MessageReference { message_id: string; // 需要引用回复的消息 ID - ignore_get_message_error?: boolean; // 是否忽略获取引用消息详情错误,默认否(如不忽略,当获取引用消息详情出错时,消息将不会发出) + ignore_get_message_error?: boolean; // 是否忽略获取引用消息详情错误,默认否 } -// 消息体结构 +// 消息体结构(发送消息) export interface MessageToCreate { content?: string; embed?: Embed; ark?: Ark; message_reference?: MessageReference; image?: string; - msg_id?: string; // 要回复的消息id,不为空则认为是被动消息,公域机器人会异步审核,不为空是被动消息,公域机器人会校验语料 + msg_id?: string; // 要回复的消息id,不为空则认为是被动消息 keyboard?: MessageKeyboard; + // 以下为新增字段(对齐 Go SDK) + msg_type?: MessageType; // 消息类型 + markdown?: Markdown; // Markdown 消息 + event_id?: string; // 回复事件 ID + timestamp?: number; // 时间戳 + msg_seq?: number; // 消息去重序号 + subscribe_id?: string; // 订阅消息 ID + input_notify?: InputNotify; // 输入状态通知 + media?: MediaInfo; // 富媒体信息(通过上传接口获得) + prompt_keyboard?: PromptKeyboard; // 交互区按钮 + action_button?: ActionButton; // 消息操作按钮 + stream?: Stream; // 流式消息 + card?: CardMessage; // 卡片消息 + ext_info?: ExtInfo; // 扩展信息 + is_wakeup?: boolean; // 召回消息 + feature_id?: number; } +// ========= Keyboard 消息按钮组件(增强) ========= + // MessageKeyboard 消息按钮组件 export interface MessageKeyboard { - id?: string; - content?: CustomKeyboard; + id?: string; // 模板 ID + content?: CustomKeyboard; // 自定义内容 } // CustomKeyboard 自定义 Keyboard export interface CustomKeyboard { rows?: Row[]; + style?: KeyboardStyle; +} + +// KeyboardStyle 键盘样式 +export interface KeyboardStyle { + /** 内容与外部的间距(单位px) */ + column_count?: number; } // Row 每行结构 @@ -121,32 +175,82 @@ export interface Row { buttons?: Button[]; } -// Button 单个按纽 +// Button 单个按钮 export interface Button { id?: string; // 按钮 ID render_data?: RenderData; // 渲染展示字段 - action?: Action; // 该按纽操作相关字段 + action?: Action; // 该按钮操作相关字段 + group_id?: string; // 同组互斥(仅 action.type=1 回调互动时有效) } -// RenderData 按纽渲染展示 +// RenderData 按钮渲染展示 export interface RenderData { - label?: string; // 按纽上的文字 - visited_label?: string; // 点击后按纽上文字 - style?: number; // 按钮样式,0:灰色线框,1:蓝色线框 + label?: string; // 按钮上的文字 + visited_label?: string; // 点击后按钮上文字 + style?: number; // 按钮样式:0=灰色线框, 1=蓝色线框, 3=白底红字, 4=蓝底白字 } -// Action 按纽点击操作 +// Action 按钮点击操作 export interface Action { - type?: number; // 操作类型 - permission?: Permission; // 可操作 + type?: ActionType; // 操作类型 + permission?: Permission; // 可操作权限 click_limit?: number; // 可点击的次数, 默认不限 data?: string; // 操作相关数据 + enter?: boolean; // 是否直接发送(AT机器人时) at_bot_show_channel_list?: boolean; // false:当前 true:弹出展示子频道选择器 + subscribe_data?: SubscribeData; // 订阅数据(仅 type=4 时有效) + modal?: Modal; // 二次确认弹窗 } -// Permission 按纽操作权限 +/** 操作类型枚举 */ +export enum ActionType { + /** HTTP/小程序链接 */ + URL = 0, + /** 回调互动 */ + CALLBACK = 1, + /** @机器人 */ + AT_BOT = 2, + /** 客户端 native 跳转(MQQ API) */ + MQQ_API = 3, + /** 订阅按钮 */ + SUBSCRIBE = 4, +} + +// Permission 按钮操作权限 export interface Permission { - type?: number; // PermissionType 按钮的权限类型 - specify_role_ids?: string[]; // SpecifyRoleIDs 身份组 - specify_user_ids?: string[]; // SpecifyUserIDs 指定 UserID + type?: PermissionType; // 按钮的权限类型 + specify_role_ids?: string[]; // 身份组 + specify_user_ids?: string[]; // 指定 UserID +} + +/** 权限类型枚举 */ +export enum PermissionType { + /** 指定用户 */ + SPECIFY_USER_IDS = 0, + /** 管理者 */ + MANAGER = 1, + /** 所有人 */ + ALL = 2, + /** 指定身份组 */ + SPECIFY_ROLE_IDS = 3, +} + +/** 二次确认弹窗 */ +export interface Modal { + /** 确认提示文本(最多 40 字符) */ + content?: string; + /** 确认按钮文字(最多 4 字符) */ + confirm_text?: string; + /** 取消按钮文字(最多 4 字符) */ + cancel_text?: string; +} + +/** 订阅数据 */ +export interface SubscribeData { + template_ids?: TemplateID[]; +} + +/** 模板 ID */ +export interface TemplateID { + id?: string; } diff --git a/src/types/openapi/v1/pins-message.ts b/src/types/openapi/v1/pins-message.ts index 3656675..4c78493 100644 --- a/src/types/openapi/v1/pins-message.ts +++ b/src/types/openapi/v1/pins-message.ts @@ -7,6 +7,8 @@ export interface PinsMessageAPI { pinsMessage: (channelID: string) => Promise>; putPinsMessage: (channelID: string, messageID: string) => Promise>; deletePinsMessage: (channelID: string, messageID: string) => Promise>; + /** 清除全部精华消息 */ + cleanPins: (channelID: string) => Promise>; } export interface IPinsMessage { diff --git a/src/types/openapi/v1/webhook.ts b/src/types/openapi/v1/webhook.ts new file mode 100644 index 0000000..618ee1b --- /dev/null +++ b/src/types/openapi/v1/webhook.ts @@ -0,0 +1,52 @@ +/** + * Webhook 相关类型定义 + * 对齐 Go SDK dto/webhook.go + */ + +/** 机器人回调校验请求数据 */ +export interface WHValidationReq { + /** 回调校验 plainToken */ + plain_token: string; + /** 事件时间戳 */ + event_ts: string; +} + +/** 机器人回调校验响应结果 */ +export interface WHValidationRsp { + /** 原始 plainToken 原样返回 */ + plain_token: string; + /** 签名 hex 字符串 */ + signature: string; +} + +/** Webhook 回调 Payload(HTTP 网关推送) */ +export interface WebhookPayload { + /** OPCode */ + op: number; + /** 序列号 */ + s?: number; + /** 事件类型 */ + t?: string; + /** 事件数据 */ + d?: any; + /** 事件 ID */ + id?: string; +} + +/** Webhook 回调 ACK 响应体 */ +export interface WebhookACK { + op: number; + d: number; +} + +/** + * Webhook 事件处理器类型 + * 接收 payload 和原始消息体,返回是否处理成功 + */ +export type WebhookEventHandler = (payload: WebhookPayload, rawMessage: Buffer) => boolean | Promise; + +/** Webhook 凭证(appID + appSecret) */ +export interface WebhookCredentials { + appID: string; + appSecret: string; +} diff --git a/src/types/websocket-types.ts b/src/types/websocket-types.ts index 9b88470..0084637 100644 --- a/src/types/websocket-types.ts +++ b/src/types/websocket-types.ts @@ -29,7 +29,7 @@ export interface EventTypes { // 请求得到ws地址的参数 export interface GetWsParam { appID: string; - token: string; + secret: string; sandbox?: boolean; shards?: Array; intents?: Array; @@ -69,6 +69,8 @@ export enum OpCode { INVALID_SESSION = 9, // 当identify或resume的时候,如果参数有错,服务端会返回该消息 HELLO = 10, // 当客户端与网关建立ws连接之后,网关下发的第一条消息 HEARTBEAT_ACK = 11, // 当发送心跳成功之后,就会收到该消息 + HTTP_CALLBACK_ACK = 12, // HTTP 回调 ACK + HTTP_CALLBACK_VALIDATION = 13, // HTTP 回调校验 } // 可使用的intents事件类型 @@ -83,6 +85,9 @@ export enum AvailableIntentsEventsEnum { PUBLIC_GUILD_MESSAGES = 'PUBLIC_GUILD_MESSAGES', MESSAGE_AUDIT = 'MESSAGE_AUDIT', INTERACTION = 'INTERACTION', + // 以下为新增(对齐 Go SDK) + GROUP_MESSAGES = 'GROUP_MESSAGES', // 群消息事件 + ENTER_AIO = 'ENTER_AIO', // 进入 AIO 事件 } // OpenAPI传过来的事件类型 @@ -138,6 +143,16 @@ export const WsEventType: { [key: string]: AvailableIntentsEventsEnum } = { // ======= PUBLIC_GUILD_MESSAGES ====== AT_MESSAGE_CREATE: AvailableIntentsEventsEnum.PUBLIC_GUILD_MESSAGES, // 机器人被@时触发 PUBLIC_MESSAGE_DELETE: AvailableIntentsEventsEnum.PUBLIC_GUILD_MESSAGES, // 当频道的消息被删除时 + + // ======= GROUP_MESSAGES (新增) ====== + GROUP_AT_MESSAGE_CREATE: AvailableIntentsEventsEnum.GROUP_MESSAGES, // 群@机器人消息 + C2C_MESSAGE_CREATE: AvailableIntentsEventsEnum.GROUP_MESSAGES, // C2C 单聊消息 + FRIEND_ADD: AvailableIntentsEventsEnum.GROUP_MESSAGES, // 好友添加 + FRIEND_DEL: AvailableIntentsEventsEnum.GROUP_MESSAGES, // 好友删除 + SUBSCRIBE_MESSAGE_STATUS: AvailableIntentsEventsEnum.GROUP_MESSAGES, // 订阅消息状态 + + // ======= ENTER_AIO (新增) ====== + ENTER_AIO: AvailableIntentsEventsEnum.ENTER_AIO, // 进入 AIO }; export const WSCodes = { @@ -233,6 +248,8 @@ export const IntentEvents: IntentEventsMapType = { GUILD_MESSAGES: 1 << 9, GUILD_MESSAGE_REACTIONS: 1 << 10, DIRECT_MESSAGE: 1 << 12, + ENTER_AIO: 1 << 23, // 进入 AIO 事件(新增) + GROUP_MESSAGES: 1 << 25, // 群消息事件(新增) INTERACTION: 1 << 26, MESSAGE_AUDIT: 1 << 27, FORUMS_EVENT: 1 << 28, @@ -282,6 +299,6 @@ export const WsObjRequestOptions = (sandbox: boolean) => ({ 'Accept-Language': 'zh-CN,zh;q=0.8', Connection: 'keep-alive', 'User-Agent': apiVersion, - Authorization: '', + Authorization: '', // 由调用方动态设置 }, }); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 86e86a0..24c2b7f 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,6 +1,28 @@ -import { AxiosRequestHeaders } from 'axios'; import { version } from '../../package.json'; import { BotLogger } from '@src/utils/logger'; +import { HttpsProxyAgent } from 'https-proxy-agent'; + +// 通用 headers 类型,兼容 Axios 的 AxiosRequestHeaders +type HeadersRecord = Record; + +/** + * 获取 HTTPS Agent + * 当环境变量存在 https_proxy / HTTPS_PROXY / http_proxy 时, + * 使用 https-proxy-agent 建立 CONNECT 隧道,让 Whistle/Charles 等抓包工具正常工作。 + * 同时需要禁用 axios 内置的 proxy 逻辑(设置 proxy: false),避免冲突。 + * + * @returns {{ httpsAgent, proxy } | {}} 存在代理时返回配置,否则返回空对象 + */ +export const getProxyConfig = (): Record => { + const proxyUrl = + process.env.https_proxy || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.HTTP_PROXY; + if (!proxyUrl) return {}; + BotLogger.info(`[PROXY] 使用代理: ${proxyUrl}`); + return { + httpsAgent: new HttpsProxyAgent(proxyUrl), + proxy: false, // 禁用 axios 内置的有问题的 proxy 逻辑 + }; +}; // 延迟 export const delayTime = (ms: number) => { @@ -38,14 +60,21 @@ export const has = (o: any, k: any) => Object.prototype.hasOwnProperty.call(o, k export const getTimeStampNumber = () => Number(new Date().getTime().toString().substr(0, 10)); // 添加 User-Agent -export const addUserAgent = (header: AxiosRequestHeaders) => { +export const addUserAgent = (header: HeadersRecord) => { const sdkVersion = version; header['User-Agent'] = `BotNodeSDK/v${sdkVersion}`; }; -// 添加 User-Agent -export const addAuthorization = (header: AxiosRequestHeaders, appID: string, token: string) => { - header['Authorization'] = `Bot ${appID}.${token}`; + +// 添加 X-Union-Appid +export const addAppID = (header: HeadersRecord, appID: string) => { + header['X-Union-Appid'] = appID; +}; + +// 添加 Authorization(QQBot AccessToken 方式) +export const addAuthorization = (header: HeadersRecord, accessToken: string) => { + header['Authorization'] = `QQBot ${accessToken}`; }; + // 组装完整Url export const buildUrl = (path = '', isSandbox?: boolean) => { return `${isSandbox ? 'https://sandbox.api.sgroup.qq.com' : 'https://api.sgroup.qq.com'}${path}`; diff --git a/test/openapi/v1/audio.spec.ts b/test/openapi/v1/audio.spec.ts index 9a228af..fb8f94f 100644 --- a/test/openapi/v1/audio.spec.ts +++ b/test/openapi/v1/audio.spec.ts @@ -12,7 +12,7 @@ describe('audio测试', () => { audioUrl: '', text: '', status: AudioPlayStatus.START, - } + }; const res = await client.audioApi.postAudio(channelID, audioControl); expect(res?.status).toStrictEqual(REQUEST_SUCCESS_CODE); }); @@ -22,7 +22,7 @@ describe('audio测试', () => { audioUrl: '', text: '', status: AudioPlayStatus.PAUSE, - } + }; const res = await client.audioApi.postAudio(channelID, audioControl); expect(res?.status).toStrictEqual(REQUEST_SUCCESS_CODE); }); @@ -32,7 +32,7 @@ describe('audio测试', () => { audioUrl: '', text: '', status: AudioPlayStatus.RESUME, - } + }; const res = await client.audioApi.postAudio(channelID, audioControl); expect(res?.status).toStrictEqual(REQUEST_SUCCESS_CODE); }); @@ -42,7 +42,7 @@ describe('audio测试', () => { audioUrl: '', text: '', status: AudioPlayStatus.STOP, - } + }; const res = await client.audioApi.postAudio(channelID, audioControl); expect(res?.status).toStrictEqual(REQUEST_SUCCESS_CODE); }); diff --git a/test/openapi/v1/guild.spec.ts b/test/openapi/v1/guild.spec.ts index ff550fe..6efe5f4 100644 --- a/test/openapi/v1/guild.spec.ts +++ b/test/openapi/v1/guild.spec.ts @@ -4,7 +4,8 @@ import { channelID, userID, REQUEST_SUCCESS_CODE, - REQUEST_SUCCESS_CODE_WITH_NO_CONTENT } from '../config'; + REQUEST_SUCCESS_CODE_WITH_NO_CONTENT, +} from '../config'; describe('guild测试', () => { test('【 guild方法 】=== 获取频道信息', async () => { diff --git a/test/openapi/v1/reaction.spec.ts b/test/openapi/v1/reaction.spec.ts index 219cb15..6192aff 100644 --- a/test/openapi/v1/reaction.spec.ts +++ b/test/openapi/v1/reaction.spec.ts @@ -1,7 +1,7 @@ import { client, channelID, REQUEST_SUCCESS_CODE_WITH_NO_CONTENT } from '../config'; describe('reaction测试', () => { - const messageID = '1' + const messageID = '1'; const emojiType = 1; const emojiID = '1'; const cookie = ''; @@ -12,8 +12,8 @@ describe('reaction测试', () => { const params = { message_id: messageID, emoji_type: emojiType, - emoji_id: emojiID - } + emoji_id: emojiID, + }; const reactionRes = (await client.reactionApi.postReaction(channelID, params)).data; expect(reactionRes?.status).toStrictEqual(REQUEST_SUCCESS_CODE_WITH_NO_CONTENT); }); @@ -23,8 +23,8 @@ describe('reaction测试', () => { const params = { message_id: messageID, emoji_type: emojiType, - emoji_id: emojiID - } + emoji_id: emojiID, + }; const reactionRes = (await client.reactionApi.deleteReaction(channelID, params)).data; expect(reactionRes?.status).toStrictEqual(REQUEST_SUCCESS_CODE_WITH_NO_CONTENT); }); @@ -34,12 +34,12 @@ describe('reaction测试', () => { const params = { message_id: messageID, emoji_type: emojiType, - emoji_id: emojiID - } + emoji_id: emojiID, + }; const options = { cookie, - limit - } + limit, + }; const reactionRes = (await client.reactionApi.getReactionUserList(channelID, params, options)).data; expect(reactionRes?.status).toStrictEqual(REQUEST_SUCCESS_CODE_WITH_NO_CONTENT); });