@tencent-connect/openclaw-qqbot 1.7.0-alpha.1 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  **Connect your AI assistant to QQ — private chat, group chat, and rich media, all in one plugin.**
12
12
 
13
- ### 🚀 Current Version: `v1.7.0`
13
+ ### 🚀 Current Version: `v1.7.1`
14
14
 
15
15
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
16
16
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](https://bot.q.qq.com/wiki/)
@@ -47,45 +47,7 @@ Scan to join the QQ group chat
47
47
  | 🛠️ **Commands** | Native OpenClaw command integration |
48
48
  | 💬 **Quoted Context** | Parses the original message a user is replying to and injects it into AI context, so the model always knows exactly which message is being referenced |
49
49
  | 📦 **Large File Support** | Auto chunked upload for large files (parallel upload with retry), up to 100 MB |
50
-
51
- ---
52
-
53
- ## 🆚 Standalone Plugin vs OpenClaw Built-in: Which to Choose?
54
-
55
- Starting from **OpenClaw 2026.3.31**, a QQBot plugin is bundled with OpenClaw. The two plugins **cannot run at the same time** — choose one based on your needs.
56
-
57
- | Feature | This Plugin (Standalone) | OpenClaw Built-in |
58
- |---------|:------------------------:|:-----------------:|
59
- | 🔒 Multi-scene (C2C / Group) | ✅ | ✅ |
60
- | 🖼️ Rich media (image / voice / video / file) | ✅ | ✅ |
61
- | 🎙️ Voice STT / TTS | ✅ | ✅ |
62
- | 🔥 One-click hot upgrade (`/bot-upgrade`) | ✅ | ❌ |
63
- | ⏰ Scheduled push (proactive messages) | ✅ | ✅ |
64
- | 🔗 URL support | ✅ | ✅ |
65
- | ⌨️ Typing indicator | ✅ | ✅ |
66
- | 📝 Markdown | ✅ | ✅ |
67
- | 🛠️ Slash commands / native commands | ✅ | ✅ |
68
- | 💬 Quoted context (injected into AI) | ✅ | ✅ |
69
- | 📦 Large file support (up to 100MB) | ✅ | ❌ |
70
- | Installation | Requires separate install | Bundled, zero setup |
71
- | Update cadence | Independent releases, faster iteration | Ships with OpenClaw |
72
-
73
- ### Which should I pick?
74
-
75
- **Choose the OpenClaw built-in plugin if you:**
76
-
77
- - Want zero-setup out of the box
78
- - Are just getting started and want to try QQ Bot quickly
79
-
80
- **Choose this plugin (standalone) if you:**
81
-
82
- - Want faster feature iteration and more capabilities
83
-
84
- > ⚠️ Both plugins cannot run simultaneously. If you've upgraded to OpenClaw 2026.3.31+, run the following command to install this plugin — the built-in version will be disabled automatically:
85
- > ```bash
86
- > curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh | bash
87
- > ```
88
- > After upgrading, you'll unlock large file transfers, message reference context, and all other advanced features.
50
+ | 🔐 **Command Execution Approval** | AI requests approval via Inline Keyboard buttons before executing commands — tap to allow or deny |
89
51
 
90
52
  ---
91
53
 
@@ -93,13 +55,9 @@ Starting from **OpenClaw 2026.3.31**, a QQBot plugin is bundled with OpenClaw. T
93
55
 
94
56
  > **Note:** This plugin serves as a **message channel** only — it relays messages between QQ and OpenClaw. Capabilities like image understanding, voice transcription, drawing, etc. depend on the **AI model** you configure and the **skills** installed in OpenClaw, not on this plugin itself.
95
57
 
96
- ### 💬 Quoted Message Context (REFIDX)
97
-
98
- QQ quote events carry index keys (e.g. `REFIDX_xxx`) instead of full original message body. The plugin now resolves these indices from a local persistent store and injects quote context into AI input, so replies better understand “which message is being quoted”.
58
+ ### 💬 Quoted Message Context
99
59
 
100
- - Inbound and outbound messages with `ref_idx` are automatically indexed.
101
- - Store path: `~/.openclaw/qqbot/data/ref-index.jsonl` (survives gateway restart).
102
- - Quote body may include text + media summary (image/voice/video/file).
60
+ When a user quotes a message in QQ, the plugin automatically parses the quoted message content and injects it into the AI context, so the model clearly knows "which message the user is replying to" and gives more accurate responses. Supports text and media messages (image/voice/video/file), and works across devices.
103
61
 
104
62
  <img width="360" src="docs/images/ref-msg.png" alt="Quoted Message Context Demo" />
105
63
 
@@ -177,6 +135,14 @@ Since v1.6.6, large file transfer is supported: images up to 20MB, videos up to
177
135
 
178
136
  <img width="360" src="docs/images/large-file-transfer.jpg" alt="Large File Transfer Demo" />
179
137
 
138
+ ### 🔐 Command Execution Approval
139
+
140
+ When the AI needs to execute a command, the plugin sends an approval request via QQ message with interactive buttons — tap **✅ Allow Once**, **⭐ Always Allow**, or **❌ Deny** to control whether the command runs.
141
+
142
+ Use the `/bot-approve` command to manage the approval mode (allowlist / off / strict).
143
+
144
+ <img width="360" src="docs/images/approve.png" alt="Command Execution Approval Demo" />
145
+
180
146
  ### 🎬 Video Sending
181
147
 
182
148
  > **You**: Send me a demo video
@@ -256,6 +222,22 @@ All commands support a `?` suffix to show usage:
256
222
  >
257
223
  > **QQBot**: 📖 /bot-upgrade usage: …
258
224
 
225
+ #### `/bot-approve` — Approval Configuration
226
+
227
+ > **You**: `/bot-approve`
228
+ >
229
+ > **QQBot**: 🔐 Command Execution Approval — Enable / Disable / Strict mode / Reset / View current config
230
+
231
+ Manage the AI command execution approval policy. Supported subcommands:
232
+
233
+ | Subcommand | Description |
234
+ |------------|-------------|
235
+ | `/bot-approve on` | Enable approval (allowlist mode, recommended) |
236
+ | `/bot-approve off` | Disable approval — commands execute directly |
237
+ | `/bot-approve always` | Strict mode — every execution requires approval |
238
+ | `/bot-approve reset` | Restore framework defaults |
239
+ | `/bot-approve status` | View current approval config |
240
+
259
241
  #### `/bot-clear-storage` — Clear files generated through QQBot conversations and downloaded resources (stored on the host running OpenClaw)
260
242
 
261
243
  `/bot-clear-storage` lists files generated by the conversation and files in the downloaded resources directory. Use `/bot-clear-storage --force` to confirm deletion.
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  **让你的 AI 助手接入 QQ — 私聊、群聊、富媒体,一个插件全搞定。**
11
11
 
12
- ### 🚀 当前版本: `v1.7.0`
12
+ ### 🚀 当前版本: `v1.7.1`
13
13
 
14
14
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
15
15
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](https://bot.q.qq.com/wiki/)
@@ -42,6 +42,7 @@
42
42
  | 🛠️ **原生命令** | 支持 OpenClaw 原生命令 |
43
43
  | 💬 **引用上下文** | 解析用户回复的原始消息内容,注入 AI 上下文,让模型准确理解"在回复哪条消息" |
44
44
  | 📦 **大文件支持** | 大文件自动分片并行上传,最大支持 100 MB |
45
+ | 🔐 **命令执行审批** | AI 执行命令前通过按钮消息请求审批,点击即可允许或拒绝 |
45
46
 
46
47
  ---
47
48
 
@@ -129,6 +130,14 @@ v1.6.6 起支持大文件传输:图片最大 20MB,视频最大 30MB,附件
129
130
 
130
131
  <img width="360" src="docs/images/large-file-transfer.jpg" alt="大文件传输演示" />
131
132
 
133
+ ### 🔐 命令执行审批
134
+
135
+ 当 AI 需要执行命令时,插件会通过 QQ 消息发送带按钮的审批请求,你可以点击 **✅ 允许一次**、**⭐ 始终允许** 或 **❌ 拒绝** 来控制命令是否执行。
136
+
137
+ 通过 `/bot-approve` 指令可以管理审批模式(白名单 / 关闭 / 严格模式)。
138
+
139
+ <img width="360" src="docs/images/approve.png" alt="命令执行审批演示" />
140
+
132
141
  ### 🎬 视频发送
133
142
 
134
143
  > **你**:发一个演示视频给我
@@ -208,6 +217,22 @@ AI 可直接发送视频,支持本地文件和公网 URL。
208
217
  >
209
218
  > **QQBot**:📖 /bot-upgrade 用法:…
210
219
 
220
+ #### `/bot-approve` — 审批配置管理
221
+
222
+ > **你**:`/bot-approve`
223
+ >
224
+ > **QQBot**:🔐 命令执行审批配置 — 开启审批 / 关闭审批 / 严格模式 / 恢复默认 / 查看当前配置
225
+
226
+ 管理 AI 命令执行审批策略,支持以下子命令:
227
+
228
+ | 子命令 | 说明 |
229
+ |--------|------|
230
+ | `/bot-approve on` | 开启审批(白名单模式,推荐) |
231
+ | `/bot-approve off` | 关闭审批,命令直接执行 |
232
+ | `/bot-approve always` | 严格模式,每次执行都需审批 |
233
+ | `/bot-approve reset` | 恢复框架默认值 |
234
+ | `/bot-approve status` | 查看当前审批配置 |
235
+
211
236
  #### `/bot-clear-storage` — 清理通过 QQBot 对话产生的文件以及下载的资源(保存在 OpenClaw 运行环境的主机上)
212
237
 
213
238
  `/bot-clear-storage` 列出对话产生的文件以及下载的资源目录里的文件,使用`/bot-clear-storage -- force`确定删除。
package/dist/src/api.d.ts CHANGED
@@ -132,6 +132,10 @@ export declare function sendDmMessage(accessToken: string, guildId: string, cont
132
132
  timestamp: string;
133
133
  }>;
134
134
  export declare function sendGroupMessage(accessToken: string, groupOpenid: string, content: string, msgId?: string, messageReference?: string): Promise<MessageResponse>;
135
+ /** 发送带 Inline Keyboard 的 C2C 消息(回调型按钮,触发 INTERACTION_CREATE) */
136
+ export declare function sendC2CMessageWithInlineKeyboard(accessToken: string, openid: string, content: string, inlineKeyboard: import("./types.js").InlineKeyboard, msgId?: string): Promise<MessageResponse>;
137
+ /** 发送带 Inline Keyboard 的 Group 消息(回调型按钮,触发 INTERACTION_CREATE) */
138
+ export declare function sendGroupMessageWithInlineKeyboard(accessToken: string, groupOpenid: string, content: string, inlineKeyboard: import("./types.js").InlineKeyboard, msgId?: string): Promise<MessageResponse>;
135
139
  export declare function sendProactiveC2CMessage(accessToken: string, openid: string, content: string): Promise<MessageResponse>;
136
140
  export declare function sendProactiveGroupMessage(accessToken: string, groupOpenid: string, content: string): Promise<{
137
141
  id: string;
package/dist/src/api.js CHANGED
@@ -495,7 +495,7 @@ async function sendAndNotify(accessToken, method, path, body, meta) {
495
495
  }
496
496
  return result;
497
497
  }
498
- function buildMessageBody(content, msgId, msgSeq, messageReference) {
498
+ function buildMessageBody(content, msgId, msgSeq, messageReference, inlineKeyboard) {
499
499
  const body = currentMarkdownSupport
500
500
  ? {
501
501
  markdown: { content },
@@ -513,6 +513,10 @@ function buildMessageBody(content, msgId, msgSeq, messageReference) {
513
513
  if (messageReference && !currentMarkdownSupport) {
514
514
  body.message_reference = { message_id: messageReference };
515
515
  }
516
+ // Inline Keyboard(内嵌按钮,需审核):字段名 keyboard,结构 { content: { rows } }
517
+ if (inlineKeyboard) {
518
+ body.keyboard = inlineKeyboard;
519
+ }
516
520
  return body;
517
521
  }
518
522
  export async function sendC2CMessage(accessToken, openid, content, msgId, messageReference) {
@@ -556,6 +560,18 @@ export async function sendGroupMessage(accessToken, groupOpenid, content, msgId,
556
560
  const body = buildMessageBody(content, msgId, msgSeq, messageReference);
557
561
  return sendAndNotify(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { text: content });
558
562
  }
563
+ /** 发送带 Inline Keyboard 的 C2C 消息(回调型按钮,触发 INTERACTION_CREATE) */
564
+ export async function sendC2CMessageWithInlineKeyboard(accessToken, openid, content, inlineKeyboard, msgId) {
565
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
566
+ const body = buildMessageBody(content, msgId, msgSeq, undefined, inlineKeyboard);
567
+ return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, body, { text: content });
568
+ }
569
+ /** 发送带 Inline Keyboard 的 Group 消息(回调型按钮,触发 INTERACTION_CREATE) */
570
+ export async function sendGroupMessageWithInlineKeyboard(accessToken, groupOpenid, content, inlineKeyboard, msgId) {
571
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
572
+ const body = buildMessageBody(content, msgId, msgSeq, undefined, inlineKeyboard);
573
+ return sendAndNotify(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { text: content });
574
+ }
559
575
  function buildProactiveMessageBody(content) {
560
576
  if (!content || content.trim().length === 0) {
561
577
  throw new Error("主动消息内容不能为空 (markdown.content is empty)");
@@ -0,0 +1,47 @@
1
+ /**
2
+ * QQBot Approval Handler
3
+ *
4
+ * 监听 Gateway 的 exec/plugin approval 事件,
5
+ * 直接调用 QQ API 发送带 Inline Keyboard 的审批消息。
6
+ * 参考 DiscordExecApprovalHandler 的实现模式。
7
+ *
8
+ * 兼容性:gateway-runtime / approval-runtime 模块在 openclaw < 3.22 上不存在,
9
+ * 使用动态 import 避免插件整体加载失败,旧版框架上审批功能自动降级(不可用)。
10
+ */
11
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
12
+ export interface QQBotApprovalHandlerOpts {
13
+ accountId: string;
14
+ appId: string;
15
+ clientSecret: string;
16
+ cfg: OpenClawConfig;
17
+ gatewayUrl?: string;
18
+ log?: {
19
+ info: (msg: string) => void;
20
+ error: (msg: string) => void;
21
+ debug?: (msg: string) => void;
22
+ };
23
+ }
24
+ export declare class QQBotApprovalHandler {
25
+ private gatewayClient;
26
+ private pending;
27
+ private requestCache;
28
+ private opts;
29
+ private started;
30
+ constructor(opts: QQBotApprovalHandlerOpts);
31
+ start(): Promise<void>;
32
+ stop(): Promise<void>;
33
+ /** 检查是否有指定 shortId 对应的 pending 审批 */
34
+ hasShortId(shortId: string): boolean;
35
+ /** 解析审批请求(供 Interaction 回调或 /approve 命令调用) */
36
+ resolveApproval(approvalId: string, decision: "allow-once" | "allow-always" | "deny"): Promise<boolean>;
37
+ private handleGatewayEvent;
38
+ private handleRequested;
39
+ private handleResolved;
40
+ private handleTimeout;
41
+ }
42
+ export declare function isApprovalFeatureAvailable(): boolean;
43
+ export declare function setApprovalFeatureAvailable(available: boolean): void;
44
+ export declare function registerApprovalHandler(accountId: string, handler: QQBotApprovalHandler): void;
45
+ export declare function unregisterApprovalHandler(accountId: string): void;
46
+ export declare function getApprovalHandler(accountId: string): QQBotApprovalHandler | undefined;
47
+ export declare function findApprovalHandlerForShortId(shortId: string): QQBotApprovalHandler | undefined;
@@ -0,0 +1,372 @@
1
+ /**
2
+ * QQBot Approval Handler
3
+ *
4
+ * 监听 Gateway 的 exec/plugin approval 事件,
5
+ * 直接调用 QQ API 发送带 Inline Keyboard 的审批消息。
6
+ * 参考 DiscordExecApprovalHandler 的实现模式。
7
+ *
8
+ * 兼容性:gateway-runtime / approval-runtime 模块在 openclaw < 3.22 上不存在,
9
+ * 使用动态 import 避免插件整体加载失败,旧版框架上审批功能自动降级(不可用)。
10
+ */
11
+ import { createRequire } from "node:module";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { getAccessToken, sendC2CMessageWithInlineKeyboard, sendGroupMessageWithInlineKeyboard, } from "./api.js";
15
+ // ─── 动态加载 gateway-runtime(兼容不同安装环境) ────────
16
+ function loadGatewayRuntime() {
17
+ const req = createRequire(import.meta.url);
18
+ const currentFile = fileURLToPath(import.meta.url);
19
+ const pluginRoot = path.resolve(path.dirname(currentFile), "..", "..");
20
+ const fs = req("node:fs");
21
+ // 尝试从找到的 openclaw 根目录加载 gateway-runtime.js
22
+ const tryLoadFromRoot = (root) => {
23
+ for (const rel of ["dist/plugin-sdk/gateway-runtime.js", "plugin-sdk/gateway-runtime.js"]) {
24
+ const p = path.join(root, rel);
25
+ try {
26
+ if (fs.existsSync(p))
27
+ return req(p);
28
+ }
29
+ catch { /* try next */ }
30
+ }
31
+ return null;
32
+ };
33
+ // 策略 1: link-sdk-core.cjs findOpenclawRoot
34
+ try {
35
+ const { findOpenclawRoot } = req(path.join(pluginRoot, "scripts", "link-sdk-core.cjs"));
36
+ const root = findOpenclawRoot(pluginRoot);
37
+ if (root) {
38
+ const mod = tryLoadFromRoot(root);
39
+ if (mod)
40
+ return mod;
41
+ }
42
+ }
43
+ catch { /* fallback */ }
44
+ // 策略 2: process.argv[1] 反推(当前进程就是 openclaw)
45
+ try {
46
+ const entry = process.argv[1];
47
+ if (entry) {
48
+ const realEntry = fs.realpathSync(entry);
49
+ let dir = path.dirname(realEntry);
50
+ for (let i = 0; i < 6; i++) {
51
+ const mod = tryLoadFromRoot(dir);
52
+ if (mod)
53
+ return mod;
54
+ const parent = path.dirname(dir);
55
+ if (parent === dir)
56
+ break;
57
+ dir = parent;
58
+ }
59
+ }
60
+ }
61
+ catch { /* fallback */ }
62
+ throw new Error("Cannot find openclaw/plugin-sdk/gateway-runtime (all strategies failed)");
63
+ }
64
+ // ─── 辅助函数 ───────────────────────────────────────────────
65
+ function toShortId(approvalId) {
66
+ return approvalId.replace(/^(exec|plugin):/, "").slice(0, 8);
67
+ }
68
+ function resolveApprovalKind(approvalId) {
69
+ return approvalId.startsWith("plugin:") ? "plugin" : "exec";
70
+ }
71
+ function buildExecApprovalText(request) {
72
+ const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000));
73
+ const lines = ["🔐 命令执行审批", ""];
74
+ const cmd = request.request.commandPreview ?? request.request.command ?? "";
75
+ if (cmd)
76
+ lines.push(`\`\`\`\n${cmd.slice(0, 300)}\n\`\`\``);
77
+ if (request.request.cwd)
78
+ lines.push(`📁 目录: ${request.request.cwd}`);
79
+ if (request.request.agentId)
80
+ lines.push(`🤖 Agent: ${request.request.agentId}`);
81
+ lines.push("", `⏱️ 超时: ${expiresIn} 秒`);
82
+ return lines.join("\n");
83
+ }
84
+ function buildPluginApprovalText(request) {
85
+ const timeoutSec = Math.round((request.request.timeoutMs ?? 120_000) / 1000);
86
+ const severityIcon = request.request.severity === "critical" ? "🔴"
87
+ : request.request.severity === "info" ? "🔵"
88
+ : "🟡";
89
+ const lines = [`${severityIcon} 审批请求`, ""];
90
+ lines.push(`📋 ${request.request.title}`);
91
+ if (request.request.description)
92
+ lines.push(`📝 ${request.request.description}`);
93
+ if (request.request.toolName)
94
+ lines.push(`🔧 工具: ${request.request.toolName}`);
95
+ if (request.request.pluginId)
96
+ lines.push(`🔌 插件: ${request.request.pluginId}`);
97
+ if (request.request.agentId)
98
+ lines.push(`🤖 Agent: ${request.request.agentId}`);
99
+ lines.push("", `⏱️ 超时: ${timeoutSec} 秒`);
100
+ return lines.join("\n");
101
+ }
102
+ /**
103
+ * Inline Keyboard(内嵌回调型按钮)
104
+ * type=1(Callback):点击触发 INTERACTION_CREATE,button_data = data 字段
105
+ * group_id 相同 → 点一个后其余变灰(三选一语义)
106
+ * click_limit=1 → 每人只能点一次
107
+ * permission.type=2 → 所有人可操作
108
+ */
109
+ function buildApprovalKeyboard(approvalId) {
110
+ const makeBtn = (id, label, visitedLabel, data, style) => ({
111
+ id,
112
+ render_data: { label, visited_label: visitedLabel, style },
113
+ action: {
114
+ type: 1,
115
+ data,
116
+ permission: { type: 2 },
117
+ click_limit: 1,
118
+ },
119
+ group_id: "approval",
120
+ });
121
+ return {
122
+ content: {
123
+ rows: [
124
+ {
125
+ buttons: [
126
+ makeBtn("allow", "✅ 允许一次", "已允许", `approve:${approvalId}:allow-once`, 1),
127
+ makeBtn("always", "⭐ 始终允许", "已始终允许", `approve:${approvalId}:allow-always`, 1),
128
+ makeBtn("deny", "❌ 拒绝", "已拒绝", `approve:${approvalId}:deny`, 0),
129
+ ],
130
+ },
131
+ ],
132
+ },
133
+ };
134
+ }
135
+ /** 从 sessionKey 或 turnSourceTo 提取投递目标 */
136
+ function resolveTarget(sessionKey, turnSourceTo) {
137
+ // 优先从 sessionKey 解析(如 agent:main:qqbot:direct:OPENID)
138
+ const sk = sessionKey ?? turnSourceTo;
139
+ if (!sk)
140
+ return null;
141
+ const m = sk.match(/qqbot:(c2c|direct|group):([A-F0-9]+)/i);
142
+ if (!m)
143
+ return null;
144
+ const type = m[1].toLowerCase() === "group" ? "group" : "c2c";
145
+ return { type, id: m[2] };
146
+ }
147
+ // ─── Handler 类 ──────────────────────────────────────────────
148
+ export class QQBotApprovalHandler {
149
+ gatewayClient = null;
150
+ pending = new Map();
151
+ requestCache = new Map();
152
+ opts;
153
+ started = false;
154
+ constructor(opts) {
155
+ this.opts = opts;
156
+ }
157
+ async start() {
158
+ if (this.started)
159
+ return;
160
+ this.started = true;
161
+ const { log } = this.opts;
162
+ log?.info(`[qqbot:${this.opts.accountId}] approval-handler: starting`);
163
+ // 动态加载 gateway-runtime(兼容旧版框架 / pnpm 环境)
164
+ let gatewayRuntime;
165
+ try {
166
+ gatewayRuntime = loadGatewayRuntime();
167
+ }
168
+ catch (err) {
169
+ log?.error(`[qqbot:${this.opts.accountId}] approval-handler: gateway-runtime module not available, approval feature disabled. Error: ${err}`);
170
+ this.started = false;
171
+ return;
172
+ }
173
+ try {
174
+ this.gatewayClient = await gatewayRuntime.createOperatorApprovalsGatewayClient({
175
+ config: this.opts.cfg,
176
+ gatewayUrl: this.opts.gatewayUrl,
177
+ clientDisplayName: "QQBot Approval Handler",
178
+ onEvent: (evt) => this.handleGatewayEvent(evt),
179
+ onHelloOk: () => log?.info(`[qqbot:${this.opts.accountId}] approval-handler: connected to gateway`),
180
+ onConnectError: (err) => log?.error(`[qqbot:${this.opts.accountId}] approval-handler: connect error: ${err.message}`),
181
+ onClose: (code, reason) => log?.debug?.(`[qqbot:${this.opts.accountId}] approval-handler: gateway closed: ${code} ${reason}`),
182
+ });
183
+ this.gatewayClient.start();
184
+ setApprovalFeatureAvailable(true);
185
+ }
186
+ catch (err) {
187
+ log?.error(`[qqbot:${this.opts.accountId}] approval-handler: failed to create gateway client: ${err}`);
188
+ this.started = false;
189
+ }
190
+ }
191
+ async stop() {
192
+ if (!this.started)
193
+ return;
194
+ this.started = false;
195
+ for (const entry of this.pending.values())
196
+ clearTimeout(entry.timeoutId);
197
+ this.pending.clear();
198
+ this.requestCache.clear();
199
+ this.gatewayClient?.stop();
200
+ this.gatewayClient = null;
201
+ this.opts.log?.info(`[qqbot:${this.opts.accountId}] approval-handler: stopped`);
202
+ }
203
+ /** 检查是否有指定 shortId 对应的 pending 审批 */
204
+ hasShortId(shortId) {
205
+ for (const id of this.pending.keys()) {
206
+ if (toShortId(id) === shortId)
207
+ return true;
208
+ }
209
+ return false;
210
+ }
211
+ /** 解析审批请求(供 Interaction 回调或 /approve 命令调用) */
212
+ async resolveApproval(approvalId, decision) {
213
+ if (!this.gatewayClient)
214
+ return false;
215
+ // 查找完整 ID:支持完整 ID(exec:uuid / plugin:uuid)、纯 UUID、或 shortId(8位)
216
+ let fullId = approvalId;
217
+ if (this.pending.has(approvalId)) {
218
+ fullId = approvalId;
219
+ }
220
+ else {
221
+ // 尝试在 pending keys 中匹配:纯 UUID 可能对应 exec:uuid 或 plugin:uuid
222
+ for (const id of this.pending.keys()) {
223
+ if (id === approvalId) {
224
+ fullId = id;
225
+ break;
226
+ }
227
+ // 纯 UUID 匹配:pending key 的 uuid 部分等于传入值
228
+ if (id.replace(/^(exec|plugin):/, "") === approvalId) {
229
+ fullId = id;
230
+ break;
231
+ }
232
+ // shortId 匹配
233
+ if (toShortId(id) === approvalId) {
234
+ fullId = id;
235
+ break;
236
+ }
237
+ }
238
+ // 也在 requestCache 中查找(handleResolved 可能已清除 pending)
239
+ if (fullId === approvalId && !this.requestCache.has(approvalId)) {
240
+ for (const id of this.requestCache.keys()) {
241
+ if (id.replace(/^(exec|plugin):/, "") === approvalId) {
242
+ fullId = id;
243
+ break;
244
+ }
245
+ }
246
+ }
247
+ }
248
+ const kind = resolveApprovalKind(fullId);
249
+ const method = kind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve";
250
+ const isPending = this.pending.has(fullId);
251
+ const isCached = this.requestCache.has(fullId);
252
+ this.opts.log?.info(`[qqbot:${this.opts.accountId}] approval-handler: resolving ${fullId} (input=${approvalId}) kind=${kind} → ${decision}, pending=${isPending}, cached=${isCached}`);
253
+ try {
254
+ await this.gatewayClient.request(method, { id: fullId, decision });
255
+ this.opts.log?.info(`[qqbot:${this.opts.accountId}] approval-handler: RPC success ${toShortId(fullId)} → ${decision} (method=${method})`);
256
+ return true;
257
+ }
258
+ catch (err) {
259
+ this.opts.log?.error(`[qqbot:${this.opts.accountId}] approval-handler: resolve failed: ${err}`);
260
+ return false;
261
+ }
262
+ }
263
+ handleGatewayEvent(evt) {
264
+ if (evt.event === "exec.approval.requested") {
265
+ void this.handleRequested(evt.payload, "exec");
266
+ }
267
+ else if (evt.event === "plugin.approval.requested") {
268
+ void this.handleRequested(evt.payload, "plugin");
269
+ }
270
+ else if (evt.event === "exec.approval.resolved") {
271
+ void this.handleResolved(evt.payload);
272
+ }
273
+ else if (evt.event === "plugin.approval.resolved") {
274
+ void this.handleResolved(evt.payload);
275
+ }
276
+ }
277
+ async handleRequested(request, kind) {
278
+ const { log, appId, clientSecret, accountId } = this.opts;
279
+ const shortId = toShortId(request.id);
280
+ // 只处理本账号的请求
281
+ const reqAccountId = request.request.turnSourceAccountId?.trim();
282
+ if (reqAccountId && reqAccountId !== accountId)
283
+ return;
284
+ // 解析投递目标
285
+ const sessionKey = request.request.sessionKey;
286
+ const turnSourceTo = request.request.turnSourceTo;
287
+ const target = resolveTarget(sessionKey, turnSourceTo);
288
+ if (!target) {
289
+ log?.info(`[qqbot:${accountId}] approval-handler: no QQ target for ${shortId} (session=${sessionKey})`);
290
+ return;
291
+ }
292
+ // 缓存请求
293
+ this.requestCache.set(request.id, kind === "plugin"
294
+ ? { kind: "plugin", request: request }
295
+ : { kind: "exec", request: request });
296
+ log?.info(`[qqbot:${accountId}] approval-handler: sending ${kind} approval ${shortId} to ${target.type}:${target.id}`);
297
+ const text = kind === "plugin"
298
+ ? buildPluginApprovalText(request)
299
+ : buildExecApprovalText(request);
300
+ const keyboard = buildApprovalKeyboard(request.id);
301
+ const timeoutMs = kind === "plugin"
302
+ ? (request.request.timeoutMs ?? 120_000)
303
+ : Math.max(0, request.expiresAtMs - Date.now());
304
+ // 短暂延迟,确保框架侧 waitDecision 已就绪,避免时序竞争
305
+ await new Promise((r) => setTimeout(r, 2000));
306
+ try {
307
+ const token = await getAccessToken(appId, clientSecret);
308
+ if (target.type === "c2c") {
309
+ await sendC2CMessageWithInlineKeyboard(token, target.id, text, keyboard);
310
+ }
311
+ else {
312
+ await sendGroupMessageWithInlineKeyboard(token, target.id, text, keyboard);
313
+ }
314
+ log?.info(`[qqbot:${accountId}] approval-handler: sent ${kind} approval ${shortId}`);
315
+ const timeoutId = setTimeout(() => {
316
+ this.handleTimeout(request.id, target);
317
+ }, timeoutMs + 2_000);
318
+ this.pending.set(request.id, { targets: [target], timeoutId });
319
+ }
320
+ catch (err) {
321
+ this.requestCache.delete(request.id);
322
+ log?.error(`[qqbot:${accountId}] approval-handler: failed to send approval ${shortId}: ${err}`);
323
+ }
324
+ }
325
+ async handleResolved(resolved) {
326
+ const entry = this.pending.get(resolved.id);
327
+ const resolvedBy = resolved.resolvedBy ?? "unknown";
328
+ const kind = resolveApprovalKind(resolved.id);
329
+ this.opts.log?.info(`[qqbot:${this.opts.accountId}] approval-handler: gateway confirmed ${toShortId(resolved.id)} → ${resolved.decision} (kind=${kind}, resolvedBy=${resolvedBy}, wasPending=${!!entry})`);
330
+ if (!entry)
331
+ return;
332
+ clearTimeout(entry.timeoutId);
333
+ this.pending.delete(resolved.id);
334
+ this.requestCache.delete(resolved.id);
335
+ // 框架 Forwarder 负责发送 resolved 通知(已通过 buildResolvedPayload=null 抑制),此处不重复发送
336
+ }
337
+ async handleTimeout(approvalId, target) {
338
+ const { log, accountId } = this.opts;
339
+ if (!this.pending.has(approvalId))
340
+ return;
341
+ this.pending.delete(approvalId);
342
+ this.requestCache.delete(approvalId);
343
+ log?.info(`[qqbot:${accountId}] approval-handler: timeout ${toShortId(approvalId)}`);
344
+ // 超时由框架处理,此处仅清理状态,不重复发消息
345
+ }
346
+ }
347
+ // ─── 模块级 handler 注册 ────────────────────────────────────
348
+ const _handlers = new Map();
349
+ /** 审批功能是否可用(gateway-runtime 模块加载成功则为 true) */
350
+ let _approvalFeatureAvailable = false;
351
+ export function isApprovalFeatureAvailable() {
352
+ return _approvalFeatureAvailable;
353
+ }
354
+ export function setApprovalFeatureAvailable(available) {
355
+ _approvalFeatureAvailable = available;
356
+ }
357
+ export function registerApprovalHandler(accountId, handler) {
358
+ _handlers.set(accountId, handler);
359
+ }
360
+ export function unregisterApprovalHandler(accountId) {
361
+ _handlers.delete(accountId);
362
+ }
363
+ export function getApprovalHandler(accountId) {
364
+ return _handlers.get(accountId);
365
+ }
366
+ export function findApprovalHandlerForShortId(shortId) {
367
+ for (const handler of _handlers.values()) {
368
+ if (handler.hasShortId(shortId))
369
+ return handler;
370
+ }
371
+ return undefined;
372
+ }