@tencent-connect/openclaw-qqbot 1.7.0 → 1.7.2
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 +216 -49
- package/README.zh.md +216 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/src/api.d.ts +6 -0
- package/dist/src/api.js +33 -4
- package/dist/src/approval-handler.d.ts +47 -0
- package/dist/src/approval-handler.js +372 -0
- package/dist/src/channel.js +72 -0
- package/dist/src/config.d.ts +5 -1
- package/dist/src/config.js +12 -2
- package/dist/src/gateway.js +175 -170
- package/dist/src/slash-commands.d.ts +7 -2
- package/dist/src/slash-commands.js +354 -3
- package/dist/src/tools/channel.js +1 -4
- package/dist/src/tools/remind.js +0 -1
- package/dist/src/transport/index.d.ts +10 -0
- package/dist/src/transport/index.js +9 -0
- package/dist/src/transport/webhook-transport.d.ts +67 -0
- package/dist/src/transport/webhook-transport.js +245 -0
- package/dist/src/transport/webhook-verify.d.ts +48 -0
- package/dist/src/transport/webhook-verify.js +98 -0
- package/dist/src/types.d.ts +85 -0
- package/dist/src/utils/audio-convert.js +37 -9
- package/index.ts +1 -0
- package/package.json +1 -1
- package/scripts/postinstall-link-sdk.js +44 -0
- package/scripts/upgrade-via-npm.sh +358 -62
- package/scripts/upgrade-via-source.sh +122 -85
- package/src/api.ts +50 -5
- package/src/approval-handler.ts +505 -0
- package/src/channel.ts +76 -0
- package/src/config.ts +15 -2
- package/src/gateway.ts +181 -169
- package/src/onboarding.ts +8 -0
- package/src/openclaw-plugin-sdk.d.ts +127 -2
- package/src/slash-commands.ts +390 -5
- package/src/tools/channel.ts +1 -7
- package/src/tools/remind.ts +0 -2
- package/src/transport/index.ts +11 -0
- package/src/transport/webhook-transport.ts +332 -0
- package/src/transport/webhook-verify.ts +119 -0
- package/src/types.ts +100 -1
- package/src/typings/openclaw-webhook-ingress.d.ts +66 -0
- package/src/utils/audio-convert.ts +37 -9
package/dist/src/api.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
|
|
7
7
|
import { sanitizeFileName } from "./utils/platform.js";
|
|
8
|
+
import { resolveUserAgentSuffix } from "./config.js";
|
|
9
|
+
import { getQQBotRuntime } from "./runtime.js";
|
|
8
10
|
/** 默认使用 console,外部可通过 setApiLogger 注入框架 log */
|
|
9
11
|
let log = {
|
|
10
12
|
info: (msg) => console.log(msg),
|
|
@@ -38,8 +40,9 @@ export class ApiError extends Error {
|
|
|
38
40
|
this.name = "ApiError";
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
+
// 支持环境变量覆盖,用于私有化部署/测试环境
|
|
44
|
+
export const API_BASE = (process.env.QQBOT_BASE_URL?.replace(/\/+$/, "") || "https://api.sgroup.qq.com");
|
|
45
|
+
export const TOKEN_URL = `${process.env.QQBOT_TOKEN_BASE_URL?.replace(/\/+$/, "") || "https://bots.qq.com"}/app/getAppAccessToken`;
|
|
43
46
|
// ============ Plugin User-Agent ============
|
|
44
47
|
// 格式: QQBotPlugin/{version} (Node/{nodeVersion}; {os}; OpenClaw/{openclawVersion})
|
|
45
48
|
// 示例: QQBotPlugin/1.6.0 (Node/22.14.0; darwin; OpenClaw/2026.3.31)
|
|
@@ -53,7 +56,17 @@ export function setOpenClawVersion(version) {
|
|
|
53
56
|
_openclawVersion = version;
|
|
54
57
|
}
|
|
55
58
|
export function getPluginUserAgent() {
|
|
56
|
-
|
|
59
|
+
const base = `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()}; OpenClaw/${_openclawVersion})`;
|
|
60
|
+
let suffix = "";
|
|
61
|
+
try {
|
|
62
|
+
const rt = getQQBotRuntime();
|
|
63
|
+
// rt.config 是配置管理器,调用 .current() 获取实际配置数据
|
|
64
|
+
const cfgMgr = rt.config;
|
|
65
|
+
const cfg = typeof cfgMgr.current === "function" ? cfgMgr.current() : cfgMgr;
|
|
66
|
+
suffix = resolveUserAgentSuffix(cfg);
|
|
67
|
+
}
|
|
68
|
+
catch { /* runtime 未初始化时返回无后缀 UA */ }
|
|
69
|
+
return suffix ? `${base} ${suffix}` : base;
|
|
57
70
|
}
|
|
58
71
|
// 运行时配置
|
|
59
72
|
let currentMarkdownSupport = false;
|
|
@@ -495,7 +508,7 @@ async function sendAndNotify(accessToken, method, path, body, meta) {
|
|
|
495
508
|
}
|
|
496
509
|
return result;
|
|
497
510
|
}
|
|
498
|
-
function buildMessageBody(content, msgId, msgSeq, messageReference) {
|
|
511
|
+
function buildMessageBody(content, msgId, msgSeq, messageReference, inlineKeyboard) {
|
|
499
512
|
const body = currentMarkdownSupport
|
|
500
513
|
? {
|
|
501
514
|
markdown: { content },
|
|
@@ -513,6 +526,10 @@ function buildMessageBody(content, msgId, msgSeq, messageReference) {
|
|
|
513
526
|
if (messageReference && !currentMarkdownSupport) {
|
|
514
527
|
body.message_reference = { message_id: messageReference };
|
|
515
528
|
}
|
|
529
|
+
// Inline Keyboard(内嵌按钮,需审核):字段名 keyboard,结构 { content: { rows } }
|
|
530
|
+
if (inlineKeyboard) {
|
|
531
|
+
body.keyboard = inlineKeyboard;
|
|
532
|
+
}
|
|
516
533
|
return body;
|
|
517
534
|
}
|
|
518
535
|
export async function sendC2CMessage(accessToken, openid, content, msgId, messageReference) {
|
|
@@ -556,6 +573,18 @@ export async function sendGroupMessage(accessToken, groupOpenid, content, msgId,
|
|
|
556
573
|
const body = buildMessageBody(content, msgId, msgSeq, messageReference);
|
|
557
574
|
return sendAndNotify(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { text: content });
|
|
558
575
|
}
|
|
576
|
+
/** 发送带 Inline Keyboard 的 C2C 消息(回调型按钮,触发 INTERACTION_CREATE) */
|
|
577
|
+
export async function sendC2CMessageWithInlineKeyboard(accessToken, openid, content, inlineKeyboard, msgId) {
|
|
578
|
+
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
|
579
|
+
const body = buildMessageBody(content, msgId, msgSeq, undefined, inlineKeyboard);
|
|
580
|
+
return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, body, { text: content });
|
|
581
|
+
}
|
|
582
|
+
/** 发送带 Inline Keyboard 的 Group 消息(回调型按钮,触发 INTERACTION_CREATE) */
|
|
583
|
+
export async function sendGroupMessageWithInlineKeyboard(accessToken, groupOpenid, content, inlineKeyboard, msgId) {
|
|
584
|
+
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
|
585
|
+
const body = buildMessageBody(content, msgId, msgSeq, undefined, inlineKeyboard);
|
|
586
|
+
return sendAndNotify(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { text: content });
|
|
587
|
+
}
|
|
559
588
|
function buildProactiveMessageBody(content) {
|
|
560
589
|
if (!content || content.trim().length === 0) {
|
|
561
590
|
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
|
+
}
|
package/dist/src/channel.js
CHANGED
|
@@ -6,6 +6,24 @@ import { qqbotOnboardingAdapter } from "./onboarding.js";
|
|
|
6
6
|
import { getQQBotRuntime } from "./runtime.js";
|
|
7
7
|
import { saveCredentialBackup, loadCredentialBackup } from "./credential-backup.js";
|
|
8
8
|
import { initApiConfig } from "./api.js";
|
|
9
|
+
import { getApprovalHandler } from "./approval-handler.js";
|
|
10
|
+
/** 检查 payload 是否为审批消息(与 getExecApprovalReplyMetadata 等效,内联避免版本兼容问题) */
|
|
11
|
+
function isApprovalPayload(payload) {
|
|
12
|
+
if (!payload || typeof payload !== "object")
|
|
13
|
+
return false;
|
|
14
|
+
const p = payload;
|
|
15
|
+
// channelData.execApproval 存在 → exec/plugin approval pending/resolved
|
|
16
|
+
const cd = p.channelData;
|
|
17
|
+
if (cd && typeof cd === "object" && !Array.isArray(cd)) {
|
|
18
|
+
const execApproval = cd.execApproval;
|
|
19
|
+
if (execApproval && typeof execApproval === "object" && !Array.isArray(execApproval)) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// text 匹配兜底:框架渲染的审批纯文本通知
|
|
24
|
+
const text = typeof p.text === "string" ? p.text : "";
|
|
25
|
+
return /(?:Plugin|Exec) approval (?:required|allowed|denied|expired)/i.test(text);
|
|
26
|
+
}
|
|
9
27
|
/** QQ Bot 单条消息文本长度上限 */
|
|
10
28
|
export const TEXT_CHUNK_LIMIT = 5000;
|
|
11
29
|
/**
|
|
@@ -251,6 +269,9 @@ export const qqbotPlugin = {
|
|
|
251
269
|
chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
252
270
|
chunkerMode: "markdown",
|
|
253
271
|
textChunkLimit: 5000,
|
|
272
|
+
// 3.31+ outbound 路径:dispatch-from-config → shouldSuppressLocalExecApprovalPrompt → outbound.shouldSuppressLocalPayloadPrompt
|
|
273
|
+
shouldSuppressLocalPayloadPrompt: ({ accountId, payload }) => getApprovalHandler(accountId ?? "") != null &&
|
|
274
|
+
isApprovalPayload(payload),
|
|
254
275
|
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
|
255
276
|
console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
|
|
256
277
|
console.log(`[qqbot:channel] sendText text preview: ${text?.slice(0, 100)}${(text?.length ?? 0) > 100 ? "..." : ""}`);
|
|
@@ -412,6 +433,57 @@ export const qqbotPlugin = {
|
|
|
412
433
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
413
434
|
}),
|
|
414
435
|
},
|
|
436
|
+
// QQBot approval-handler 通过独立 WS 连接自行处理 exec + plugin 审批消息投递(带 Inline Keyboard),
|
|
437
|
+
// 完全屏蔽框架 Forwarder 的纯文本通知。
|
|
438
|
+
//
|
|
439
|
+
// ── 3.28 扁平结构 ──
|
|
440
|
+
execApprovals: {
|
|
441
|
+
// 3.28 框架通过此方法判断 channel 是否支持审批
|
|
442
|
+
getInitiatingSurfaceState: ({ accountId }) => {
|
|
443
|
+
return getApprovalHandler(accountId ?? "") != null
|
|
444
|
+
? { kind: "enabled" }
|
|
445
|
+
: { kind: "disabled" };
|
|
446
|
+
},
|
|
447
|
+
shouldSuppressForwardingFallback: (...args) => {
|
|
448
|
+
console.log("[QQBot] shouldSuppressForwardingFallback called", JSON.stringify(args?.[0]?.target ?? null));
|
|
449
|
+
return true;
|
|
450
|
+
},
|
|
451
|
+
shouldSuppressLocalPrompt: ({ accountId, payload }) => getApprovalHandler(accountId ?? "") != null &&
|
|
452
|
+
isApprovalPayload(payload),
|
|
453
|
+
buildPendingPayload: () => null,
|
|
454
|
+
buildResolvedPayload: () => null,
|
|
455
|
+
},
|
|
456
|
+
// ── 3.31+ 嵌套结构 ──
|
|
457
|
+
// auth 和 approvals 是 ChannelPlugin 顶层平级字段
|
|
458
|
+
//
|
|
459
|
+
// QQBot 审批模型:
|
|
460
|
+
// - QQBotApprovalHandler 通过独立 WS 自行投递带 Inline Keyboard 的审批消息
|
|
461
|
+
// - 用户点击按钮 → INTERACTION_CREATE → resolveApproval → gateway RPC
|
|
462
|
+
// - /approve 文本命令作为 URGENT_COMMAND 直接入队交给框架处理
|
|
463
|
+
auth: {
|
|
464
|
+
authorizeActorAction: () => ({ authorized: true }),
|
|
465
|
+
getActionAvailabilityState: ({ accountId }) => {
|
|
466
|
+
return getApprovalHandler(accountId ?? "") != null
|
|
467
|
+
? { kind: "enabled" }
|
|
468
|
+
: { kind: "disabled" };
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
approvals: {
|
|
472
|
+
delivery: {
|
|
473
|
+
hasConfiguredDmRoute: () => true,
|
|
474
|
+
shouldSuppressForwardingFallback: () => true,
|
|
475
|
+
},
|
|
476
|
+
render: {
|
|
477
|
+
exec: {
|
|
478
|
+
buildPendingPayload: () => null,
|
|
479
|
+
buildResolvedPayload: () => null,
|
|
480
|
+
},
|
|
481
|
+
plugin: {
|
|
482
|
+
buildPendingPayload: () => null,
|
|
483
|
+
buildResolvedPayload: () => null,
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
},
|
|
415
487
|
};
|
|
416
488
|
// ============ 独立的 mention 工具函数(供 gateway.ts 等直接调用) ============
|
|
417
489
|
/** 清理 @mention:替换 <@openid> 为 @用户名,去除 @机器人自身 */
|
package/dist/src/config.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export declare function resolveGroupAllowFrom(cfg: OpenClawConfig, accountId?: s
|
|
|
17
17
|
/** 检查指定群是否被允许(使用标准策略引擎) */
|
|
18
18
|
export declare function isGroupAllowed(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): boolean;
|
|
19
19
|
type ResolvedGroupConfig = Omit<Required<GroupConfig>, "prompt"> & Pick<GroupConfig, "prompt">;
|
|
20
|
-
/** 解析指定群配置(具体 groupOpenid > 通配符 "*" >
|
|
20
|
+
/** 解析指定群配置(具体 groupOpenid > 通配符 "*" > 账户级 defaultRequireMention > 硬编码默认值) */
|
|
21
21
|
export declare function resolveGroupConfig(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): ResolvedGroupConfig;
|
|
22
22
|
/** 解析群历史消息缓存条数 */
|
|
23
23
|
export declare function resolveHistoryLimit(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): number;
|
|
@@ -31,6 +31,10 @@ export declare function resolveIgnoreOtherMentions(cfg: OpenClawConfig, groupOpe
|
|
|
31
31
|
export declare function resolveToolPolicy(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): ToolPolicy;
|
|
32
32
|
/** 解析群名称(优先配置,fallback 为 openid 前 8 位) */
|
|
33
33
|
export declare function resolveGroupName(cfg: OpenClawConfig, groupOpenid: string, accountId?: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* 解析 User-Agent 追加后缀(仅通道级:channels.qqbot.userAgentSuffix)
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveUserAgentSuffix(cfg: OpenClawConfig): string;
|
|
34
38
|
/**
|
|
35
39
|
* 列出所有 QQBot 账户 ID
|
|
36
40
|
*/
|
package/dist/src/config.js
CHANGED
|
@@ -81,14 +81,16 @@ export function isGroupAllowed(cfg, groupOpenid, accountId) {
|
|
|
81
81
|
allowlistMatched,
|
|
82
82
|
}).allowed;
|
|
83
83
|
}
|
|
84
|
-
/** 解析指定群配置(具体 groupOpenid > 通配符 "*" >
|
|
84
|
+
/** 解析指定群配置(具体 groupOpenid > 通配符 "*" > 账户级 defaultRequireMention > 硬编码默认值) */
|
|
85
85
|
export function resolveGroupConfig(cfg, groupOpenid, accountId) {
|
|
86
86
|
const account = resolveQQBotAccount(cfg, accountId);
|
|
87
87
|
const groups = account.config?.groups ?? {};
|
|
88
88
|
const wildcardCfg = groups["*"] ?? {};
|
|
89
89
|
const specificCfg = groups[groupOpenid] ?? {};
|
|
90
|
+
// 账户级默认值:defaultRequireMention 配置 > 硬编码默认 true
|
|
91
|
+
const accountDefaultRequireMention = account.config?.defaultRequireMention ?? DEFAULT_GROUP_CONFIG.requireMention;
|
|
90
92
|
return {
|
|
91
|
-
requireMention: specificCfg.requireMention ?? wildcardCfg.requireMention ??
|
|
93
|
+
requireMention: specificCfg.requireMention ?? wildcardCfg.requireMention ?? accountDefaultRequireMention,
|
|
92
94
|
ignoreOtherMentions: specificCfg.ignoreOtherMentions ?? wildcardCfg.ignoreOtherMentions ?? DEFAULT_GROUP_CONFIG.ignoreOtherMentions,
|
|
93
95
|
toolPolicy: specificCfg.toolPolicy ?? wildcardCfg.toolPolicy ?? DEFAULT_GROUP_CONFIG.toolPolicy,
|
|
94
96
|
name: specificCfg.name ?? wildcardCfg.name ?? DEFAULT_GROUP_CONFIG.name,
|
|
@@ -123,6 +125,13 @@ export function resolveGroupName(cfg, groupOpenid, accountId) {
|
|
|
123
125
|
const name = resolveGroupConfig(cfg, groupOpenid, accountId).name;
|
|
124
126
|
return name || groupOpenid.slice(0, 8);
|
|
125
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* 解析 User-Agent 追加后缀(仅通道级:channels.qqbot.userAgentSuffix)
|
|
130
|
+
*/
|
|
131
|
+
export function resolveUserAgentSuffix(cfg) {
|
|
132
|
+
const qqbot = cfg.channels?.qqbot;
|
|
133
|
+
return qqbot?.userAgentSuffix ? String(qqbot.userAgentSuffix).trim() : "";
|
|
134
|
+
}
|
|
126
135
|
function normalizeAppId(raw) {
|
|
127
136
|
if (raw === null || raw === undefined)
|
|
128
137
|
return "";
|
|
@@ -217,6 +226,7 @@ export function resolveQQBotAccount(cfg, accountId) {
|
|
|
217
226
|
systemPrompt: accountConfig.systemPrompt,
|
|
218
227
|
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
|
|
219
228
|
markdownSupport: accountConfig.markdownSupport !== false,
|
|
229
|
+
userAgentSuffix: resolveUserAgentSuffix(cfg),
|
|
220
230
|
config: accountConfig,
|
|
221
231
|
};
|
|
222
232
|
}
|