@jerryliang122/openclaw-qqbot 1.0.0
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/LICENSE +22 -0
- package/README.md +967 -0
- package/README.zh.md +790 -0
- package/dist/index.cjs +18072 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1222 -0
- package/index.ts +92 -0
- package/openclaw.plugin.json +38 -0
- package/package.json +69 -0
- package/preload.cjs +19 -0
- package/scripts/link-sdk-core.cjs +268 -0
- package/scripts/proactive-api-server.ts +369 -0
- package/scripts/send-proactive.ts +293 -0
- package/scripts/test-sendmedia.ts +116 -0
- package/skills/qqbot-channel/SKILL.md +285 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-remind/SKILL.md +159 -0
- package/skills/qqbot-upgrade/SKILL.md +56 -0
- package/src/adapter/contract.ts +63 -0
- package/src/adapter/lint.ts +144 -0
- package/src/adapter/media.ts +40 -0
- package/src/adapter/pairing.ts +95 -0
- package/src/adapter/resolve.ts +255 -0
- package/src/adapter/setup.ts +13 -0
- package/src/adapter/webhook.ts +248 -0
- package/src/adapter/workspace.ts +21 -0
- package/src/agent-prompt-adapter.ts +26 -0
- package/src/bot-instance.ts +60 -0
- package/src/channel.ts +230 -0
- package/src/commands/bot-approve.ts +143 -0
- package/src/commands/bot-clear-storage.ts +114 -0
- package/src/commands/bot-group-always.ts +62 -0
- package/src/commands/bot-group-info.ts +48 -0
- package/src/commands/bot-help.ts +40 -0
- package/src/commands/bot-logs.ts +248 -0
- package/src/commands/bot-me.ts +18 -0
- package/src/commands/bot-pairing.ts +50 -0
- package/src/commands/bot-ping.ts +33 -0
- package/src/commands/bot-streaming.ts +55 -0
- package/src/commands/bot-upgrade.ts +56 -0
- package/src/commands/bot-version.ts +41 -0
- package/src/commands/config-util.ts +96 -0
- package/src/commands/index.ts +51 -0
- package/src/config.ts +403 -0
- package/src/constants.ts +6 -0
- package/src/dispatch/body-assembler.ts +308 -0
- package/src/dispatch/ctx-builder.ts +127 -0
- package/src/dispatch/dispatch.ts +667 -0
- package/src/dispatch/envelope-builder.ts +112 -0
- package/src/dispatch/index.ts +2 -0
- package/src/features/approval-capability.ts +302 -0
- package/src/features/approval-helpers.ts +271 -0
- package/src/features/approval-utils.ts +21 -0
- package/src/features/command-panel.ts +301 -0
- package/src/features/credential-backup.ts +74 -0
- package/src/features/group-mode-store.ts +79 -0
- package/src/features/history-store.ts +75 -0
- package/src/features/msgid-cache.ts +55 -0
- package/src/features/outbound-echo-store.ts +46 -0
- package/src/features/proactive-budget.ts +57 -0
- package/src/features/proactive.ts +549 -0
- package/src/features/question-helpers.ts +771 -0
- package/src/features/quota-manager.ts +173 -0
- package/src/features/ref-index-store.ts +289 -0
- package/src/features/secret-input-store.ts +118 -0
- package/src/features/secret-store-cli.ts +324 -0
- package/src/features/typing-refresh.ts +51 -0
- package/src/features/update-checker.ts +166 -0
- package/src/gateway/event-handlers.ts +456 -0
- package/src/gateway/index.ts +3 -0
- package/src/gateway/lifecycle.ts +236 -0
- package/src/gateway/middleware-setup.ts +173 -0
- package/src/gateway/qqbot-gateway.ts +458 -0
- package/src/gateway-adapter.ts +44 -0
- package/src/heartbeat-adapter.ts +57 -0
- package/src/message-adapter.ts +40 -0
- package/src/messaging-adapter.ts +78 -0
- package/src/middleware/access-control.ts +125 -0
- package/src/middleware/attachment.ts +373 -0
- package/src/middleware/inbound-guard.ts +102 -0
- package/src/middleware/policy-injector.ts +71 -0
- package/src/middleware/secret-capture.ts +161 -0
- package/src/middleware/typing.ts +110 -0
- package/src/openclaw-plugin-sdk.d.ts +543 -0
- package/src/outbound/chunker.ts +80 -0
- package/src/outbound/debounce.ts +102 -0
- package/src/outbound/deliver-pipeline.ts +235 -0
- package/src/outbound/index.ts +3 -0
- package/src/outbound/local-file-router.ts +145 -0
- package/src/outbound/media-send.ts +408 -0
- package/src/outbound/outbound-service.ts +298 -0
- package/src/outbound/reply-limiter.ts +139 -0
- package/src/outbound/sanitize.ts +32 -0
- package/src/outbound/streaming-controller.ts +332 -0
- package/src/outbound/target.ts +109 -0
- package/src/outbound-adapter.ts +323 -0
- package/src/plugin-base.ts +42 -0
- package/src/request-context.ts +50 -0
- package/src/runtime.ts +42 -0
- package/src/setup/account-key.ts +41 -0
- package/src/setup/finalize.ts +110 -0
- package/src/setup/login.ts +197 -0
- package/src/setup/surface.ts +40 -0
- package/src/status-adapter.ts +56 -0
- package/src/tools/platform.ts +149 -0
- package/src/tools/remind.ts +308 -0
- package/src/tools/secret-input.ts +185 -0
- package/src/types-augment.d.ts +54 -0
- package/src/types-plugin.ts +82 -0
- package/src/types.ts +620 -0
- package/src/typing-lifecycle.ts +182 -0
- package/src/utils/mention.ts +52 -0
- package/src/utils/pkg-version.ts +23 -0
- package/src/utils/platform.ts +459 -0
- package/src/utils/plugin-logger.ts +104 -0
- package/src/utils/ssrf-guard.ts +132 -0
- package/src/utils/stt.ts +150 -0
- package/src/utils/voice-text.ts +61 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +64 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 信封构建器
|
|
3
|
+
*
|
|
4
|
+
* 将 SDK MiddlewareContext 中由各中间件填充的 state 数据
|
|
5
|
+
* 转换为 OpenClaw 框架标准的 InboundMessage 结构。
|
|
6
|
+
*/
|
|
7
|
+
import type { MiddlewareContext, QQBotInboundMessage, HistoryEntry, ResolvedQuote } from '@tencent-connect/qqbot-nodejs';
|
|
8
|
+
import type { ResolvedQQBotAccount } from '../types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* OpenClaw 入站消息结构(传递给 runtime.channel.dispatch)
|
|
12
|
+
*/
|
|
13
|
+
export interface OpenClawInboundMessage {
|
|
14
|
+
channelId: string;
|
|
15
|
+
accountId: string;
|
|
16
|
+
targetId: string;
|
|
17
|
+
chatScope: 'direct' | 'group';
|
|
18
|
+
senderId: string;
|
|
19
|
+
senderName?: string;
|
|
20
|
+
messageId: string;
|
|
21
|
+
content: string;
|
|
22
|
+
history?: Array<{ role: string; content: string; senderId?: string; senderName?: string }>;
|
|
23
|
+
quote?: { content: string; senderId: string; attachments?: unknown[] };
|
|
24
|
+
attachments?: unknown[];
|
|
25
|
+
/** 入站图片 URL 列表(从附件中提取) */
|
|
26
|
+
imageUrls?: string[];
|
|
27
|
+
groupId?: string;
|
|
28
|
+
systemPrompt?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 将 HistoryEntry 转换为 OpenClaw 历史格式
|
|
33
|
+
*/
|
|
34
|
+
function mapHistory(
|
|
35
|
+
entries: HistoryEntry[] | undefined,
|
|
36
|
+
): OpenClawInboundMessage['history'] {
|
|
37
|
+
if (!entries || entries.length === 0) return undefined;
|
|
38
|
+
return entries.map((e) => ({
|
|
39
|
+
role: 'user',
|
|
40
|
+
content: e.content,
|
|
41
|
+
senderId: e.senderId,
|
|
42
|
+
senderName: e.senderName,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 将 ResolvedQuote 转换为 OpenClaw quote 格式
|
|
48
|
+
*/
|
|
49
|
+
function mapQuote(
|
|
50
|
+
quote: ResolvedQuote | undefined,
|
|
51
|
+
): OpenClawInboundMessage['quote'] {
|
|
52
|
+
if (!quote) return undefined;
|
|
53
|
+
return {
|
|
54
|
+
content: quote.text,
|
|
55
|
+
senderId: quote.entry?.senderId ?? '',
|
|
56
|
+
attachments: quote.attachments as unknown[],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 从 SDK 中间件上下文构建 OpenClaw InboundMessage
|
|
62
|
+
*/
|
|
63
|
+
export function buildEnvelope(
|
|
64
|
+
ctx: MiddlewareContext,
|
|
65
|
+
msg: QQBotInboundMessage,
|
|
66
|
+
account: ResolvedQQBotAccount,
|
|
67
|
+
): OpenClawInboundMessage {
|
|
68
|
+
const scope = msg.replyTarget.scope;
|
|
69
|
+
const targetId = scope === 'group'
|
|
70
|
+
? `qqbot:group:${msg.replyTarget.targetId}`
|
|
71
|
+
: `qqbot:c2c:${msg.replyTarget.targetId}`;
|
|
72
|
+
|
|
73
|
+
// envelope 是 string 类型,直接作为 content 使用;回退到原始消息内容
|
|
74
|
+
const envelope = ctx.state.envelope as string | undefined;
|
|
75
|
+
let content = envelope ?? msg.content;
|
|
76
|
+
|
|
77
|
+
// 将语音转录文本和附件信息注入到 content 中
|
|
78
|
+
const processed = ctx.state.processedAttachments as
|
|
79
|
+
| { voiceText?: string; imageUrls?: string[]; otherInfo?: string }
|
|
80
|
+
| undefined;
|
|
81
|
+
|
|
82
|
+
if (processed) {
|
|
83
|
+
const parts: string[] = [];
|
|
84
|
+
if (processed.voiceText) {
|
|
85
|
+
parts.push(processed.voiceText);
|
|
86
|
+
}
|
|
87
|
+
if (content) {
|
|
88
|
+
parts.push(content);
|
|
89
|
+
}
|
|
90
|
+
if (processed.otherInfo) {
|
|
91
|
+
parts.push(processed.otherInfo);
|
|
92
|
+
}
|
|
93
|
+
content = parts.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
channelId: 'qqbot',
|
|
98
|
+
accountId: account.accountId,
|
|
99
|
+
targetId,
|
|
100
|
+
chatScope: scope === 'group' ? 'group' : 'direct',
|
|
101
|
+
senderId: msg.senderId,
|
|
102
|
+
senderName: msg.senderName,
|
|
103
|
+
messageId: msg.messageId,
|
|
104
|
+
content,
|
|
105
|
+
history: mapHistory(ctx.state.history as HistoryEntry[] | undefined),
|
|
106
|
+
quote: mapQuote(ctx.state.quote as ResolvedQuote | undefined),
|
|
107
|
+
attachments: msg.attachments,
|
|
108
|
+
imageUrls: processed?.imageUrls,
|
|
109
|
+
groupId: scope === 'group' ? msg.replyTarget.targetId : undefined,
|
|
110
|
+
systemPrompt: account.systemPrompt,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot Approval Capability — SDK-native approval handling.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the former hand-rolled QQBotApprovalHandler. The framework
|
|
5
|
+
* auto-bootstraps a native approval handler from this capability (registered
|
|
6
|
+
* on qqbotPlugin.approvalCapability + the "approval.native" runtime context
|
|
7
|
+
* declared in gateway/lifecycle.ts), and feeds exec/plugin approval requests
|
|
8
|
+
* back to the availability / presentation / transport callbacks below.
|
|
9
|
+
*
|
|
10
|
+
* Model: QQBot has no separate `execApprovals` approver config — it uses the
|
|
11
|
+
* account `allowFrom` list. With no allowFrom, any participant in the
|
|
12
|
+
* originating conversation may approve (open mode). This is the "fallback"
|
|
13
|
+
* path from the in-tree extension, intentionally simpler than the profile
|
|
14
|
+
* path.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createChannelApprovalCapability } from "openclaw/plugin-sdk/approval-delivery-runtime";
|
|
18
|
+
import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime";
|
|
19
|
+
import {
|
|
20
|
+
createChannelApprovalNativeRuntimeAdapter,
|
|
21
|
+
createLazyChannelApprovalNativeRuntimeAdapter,
|
|
22
|
+
CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
|
|
23
|
+
} from "openclaw/plugin-sdk/approval-handler-runtime";
|
|
24
|
+
import type {
|
|
25
|
+
ChannelApprovalNativeRuntimeAdapter,
|
|
26
|
+
} from "openclaw/plugin-sdk/approval-handler-runtime";
|
|
27
|
+
import type {
|
|
28
|
+
ChannelApprovalCapability,
|
|
29
|
+
} from "openclaw/plugin-sdk/channel-contract";
|
|
30
|
+
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
31
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
32
|
+
import { DEFAULT_ACCOUNT_ID, resolveQQBotAccount } from "../config.js";
|
|
33
|
+
import { tryGetBotForAccount } from "../bot-instance.js";
|
|
34
|
+
import { getQQBotRuntime } from "../runtime.js";
|
|
35
|
+
import { getAdapters } from "../adapter/resolve.js";
|
|
36
|
+
import type { InlineKeyboard, ResolvedQQBotAccount } from "../types.js";
|
|
37
|
+
import {
|
|
38
|
+
buildApprovalKeyboard,
|
|
39
|
+
buildExecApprovalText,
|
|
40
|
+
buildPluginApprovalText,
|
|
41
|
+
buildSystemAgentApprovalText,
|
|
42
|
+
resolveApprovalTarget,
|
|
43
|
+
} from "./approval-helpers.js";
|
|
44
|
+
|
|
45
|
+
export { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY };
|
|
46
|
+
|
|
47
|
+
// ─── helpers ────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function resolveActiveConfig(): OpenClawConfig {
|
|
50
|
+
// Prefer the live runtime config snapshot (reflects config reloads); fall
|
|
51
|
+
// back to an empty object if the runtime is not yet registered.
|
|
52
|
+
const rt = getQQBotRuntime();
|
|
53
|
+
const cfg = getAdapters(rt).getConfig?.();
|
|
54
|
+
return (cfg ?? {}) as OpenClawConfig;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveAccountById(cfg: OpenClawConfig, accountId?: string | null): ResolvedQQBotAccount {
|
|
58
|
+
return resolveQQBotAccount(cfg, accountId && accountId !== DEFAULT_ACCOUNT_ID ? accountId : undefined);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** account is enabled and has usable credentials → can deliver native approvals */
|
|
62
|
+
function isNativeDeliveryEnabled(cfg: OpenClawConfig, accountId?: string | null): boolean {
|
|
63
|
+
const account = resolveAccountById(cfg, accountId);
|
|
64
|
+
return account.enabled && account.secretSource !== "none";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Resolve the QQ delivery target for an approval request. */
|
|
68
|
+
function resolveQQTarget(request: {
|
|
69
|
+
request: { sessionKey?: string | null; turnSourceTo?: string | null };
|
|
70
|
+
}): { type: "c2c" | "group"; id: string } | null {
|
|
71
|
+
const sessionKey = request.request.sessionKey ?? null;
|
|
72
|
+
const turnSourceTo = request.request.turnSourceTo ?? null;
|
|
73
|
+
|
|
74
|
+
const target = resolveApprovalTarget(sessionKey, turnSourceTo);
|
|
75
|
+
if (target) return target;
|
|
76
|
+
|
|
77
|
+
const sessionConversation = resolveApprovalRequestSessionConversation({
|
|
78
|
+
request: request as never,
|
|
79
|
+
channel: "qqbot",
|
|
80
|
+
bundledFallback: true,
|
|
81
|
+
});
|
|
82
|
+
if (sessionConversation?.id) {
|
|
83
|
+
const kind = sessionConversation.kind;
|
|
84
|
+
return { type: kind === "group" ? "group" : "c2c", id: sessionConversation.id };
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Per-account ownership: only the account that originated the turn should
|
|
91
|
+
* deliver (openids are account-scoped — cross-account delivery fails with
|
|
92
|
+
* HTTP 500 on the QQ Bot API).
|
|
93
|
+
*/
|
|
94
|
+
function matchesAccount(
|
|
95
|
+
cfg: OpenClawConfig,
|
|
96
|
+
accountId: string | null | undefined,
|
|
97
|
+
request: { request: { turnSourceAccountId?: string | null } },
|
|
98
|
+
): boolean {
|
|
99
|
+
const reqAccountId = normalizeOptionalString(request.request.turnSourceAccountId);
|
|
100
|
+
if (!reqAccountId) return true; // no account hint → accept (ownership left to caller)
|
|
101
|
+
const normalized = (accountId && accountId !== DEFAULT_ACCOUNT_ID) ? accountId : DEFAULT_ACCOUNT_ID;
|
|
102
|
+
return reqAccountId === normalized;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── authorization (allowFrom) ──────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Decide whether a button-clicker may approve. Mirrors the legacy
|
|
109
|
+
* isApprovalAuthorized rule: empty allowFrom (or ["*"]) → open; otherwise the
|
|
110
|
+
* operator must be in the account's allowFrom list.
|
|
111
|
+
*/
|
|
112
|
+
export function authorizeQQBotApprovalAction(params: {
|
|
113
|
+
cfg: OpenClawConfig;
|
|
114
|
+
accountId?: string | null;
|
|
115
|
+
senderId?: string | null;
|
|
116
|
+
}): { authorized: boolean; reason?: string } {
|
|
117
|
+
const operatorId = normalizeOptionalString(params.senderId);
|
|
118
|
+
if (!operatorId) {
|
|
119
|
+
return { authorized: false, reason: "Could not identify the operator." };
|
|
120
|
+
}
|
|
121
|
+
const account = resolveAccountById(params.cfg, params.accountId);
|
|
122
|
+
const allowFrom = account.config?.allowFrom ?? [];
|
|
123
|
+
if (!allowFrom.length || allowFrom.includes("*")) {
|
|
124
|
+
return { authorized: true };
|
|
125
|
+
}
|
|
126
|
+
if (allowIncludes(allowFrom, operatorId)) {
|
|
127
|
+
return { authorized: true };
|
|
128
|
+
}
|
|
129
|
+
return { authorized: false, reason: "You are not authorized to approve this request." };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function allowIncludes(allowFrom: Array<string | number>, operatorId: string): boolean {
|
|
133
|
+
const normalized = operatorId.toUpperCase().replace(/^qqbot:/i, "");
|
|
134
|
+
return allowFrom.some((entry) => {
|
|
135
|
+
const e = String(entry).trim().toUpperCase().replace(/^qqbot:/i, "");
|
|
136
|
+
return e === normalized;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── native runtime spec (availability / presentation / transport) ──
|
|
141
|
+
|
|
142
|
+
type QQBotPendingPayload = { text: string; keyboard: InlineKeyboard };
|
|
143
|
+
type QQBotPreparedTarget = { type: "c2c" | "group"; id: string };
|
|
144
|
+
type QQBotPendingEntry = { messageId?: string; targetType: "c2c" | "group"; targetId: string };
|
|
145
|
+
|
|
146
|
+
const qqbotApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
|
|
147
|
+
QQBotPendingPayload,
|
|
148
|
+
QQBotPreparedTarget,
|
|
149
|
+
QQBotPendingEntry
|
|
150
|
+
>({
|
|
151
|
+
eventKinds: ["exec", "plugin", "system-agent"],
|
|
152
|
+
|
|
153
|
+
availability: {
|
|
154
|
+
isConfigured: ({ cfg, accountId }) =>
|
|
155
|
+
isNativeDeliveryEnabled(cfg, accountId),
|
|
156
|
+
|
|
157
|
+
shouldHandle: ({ cfg, accountId, request }) => {
|
|
158
|
+
const target = resolveQQTarget(request as never);
|
|
159
|
+
if (!target) return false;
|
|
160
|
+
return matchesAccount(cfg, accountId, request as never);
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
presentation: {
|
|
164
|
+
buildPendingPayload: ({ view, nowMs }) => {
|
|
165
|
+
const text =
|
|
166
|
+
view.approvalKind === "exec"
|
|
167
|
+
? buildExecApprovalText(view, nowMs)
|
|
168
|
+
: view.approvalKind === "system-agent"
|
|
169
|
+
? buildSystemAgentApprovalText(view, nowMs)
|
|
170
|
+
: buildPluginApprovalText(view, nowMs);
|
|
171
|
+
const keyboard = buildApprovalKeyboard(
|
|
172
|
+
view.approvalId,
|
|
173
|
+
view.approvalKind,
|
|
174
|
+
view.actions.map((action) => action.decision),
|
|
175
|
+
);
|
|
176
|
+
return { text, keyboard };
|
|
177
|
+
},
|
|
178
|
+
buildResolvedResult: () => ({ kind: "leave" }),
|
|
179
|
+
buildExpiredResult: () => ({ kind: "leave" }),
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
transport: {
|
|
183
|
+
prepareTarget: ({ request }) => {
|
|
184
|
+
const target = resolveQQTarget(request as never);
|
|
185
|
+
if (!target) return null;
|
|
186
|
+
return { target, dedupeKey: `${target.type}:${target.id}` };
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
deliverPending: async ({ accountId, preparedTarget, pendingPayload }) => {
|
|
190
|
+
// Resolve the bot for this account (gateway must be running — it is, because
|
|
191
|
+
// the "approval.native" runtime context only exists while the account is started).
|
|
192
|
+
const resolvedAccountId =
|
|
193
|
+
accountId && accountId !== DEFAULT_ACCOUNT_ID ? accountId : DEFAULT_ACCOUNT_ID;
|
|
194
|
+
const bot = tryGetBotForAccount(resolvedAccountId);
|
|
195
|
+
if (!bot) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`QQ Bot gateway not running for account "${resolvedAccountId}" — cannot deliver approval`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const replyTarget = {
|
|
202
|
+
scope: preparedTarget.type as "c2c" | "group",
|
|
203
|
+
targetId: preparedTarget.id,
|
|
204
|
+
};
|
|
205
|
+
const result = await bot.sendTextWithKeyboard(
|
|
206
|
+
replyTarget,
|
|
207
|
+
pendingPayload.text,
|
|
208
|
+
pendingPayload.keyboard as never,
|
|
209
|
+
);
|
|
210
|
+
const messageId = (result as { id?: string } | undefined)?.id;
|
|
211
|
+
return {
|
|
212
|
+
messageId,
|
|
213
|
+
targetType: preparedTarget.type,
|
|
214
|
+
targetId: preparedTarget.id,
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ─── capability assembly ────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function createQQBotApprovalCapability(): ChannelApprovalCapability {
|
|
223
|
+
return createChannelApprovalCapability({
|
|
224
|
+
authorizeActorAction: ({ cfg, accountId, senderId }) =>
|
|
225
|
+
authorizeQQBotApprovalAction({ cfg, accountId, senderId }),
|
|
226
|
+
|
|
227
|
+
getActionAvailabilityState: ({ cfg, accountId }) =>
|
|
228
|
+
isNativeDeliveryEnabled(cfg, accountId)
|
|
229
|
+
? { kind: "enabled" as const }
|
|
230
|
+
: { kind: "disabled" as const },
|
|
231
|
+
|
|
232
|
+
getExecInitiatingSurfaceState: ({ cfg, accountId }) =>
|
|
233
|
+
isNativeDeliveryEnabled(cfg, accountId)
|
|
234
|
+
? { kind: "enabled" as const }
|
|
235
|
+
: { kind: "disabled" as const },
|
|
236
|
+
|
|
237
|
+
describeExecApprovalSetup: ({ accountId }: { accountId?: string | null }) => {
|
|
238
|
+
const prefix =
|
|
239
|
+
accountId && accountId !== DEFAULT_ACCOUNT_ID
|
|
240
|
+
? `channels.qqbot.accounts.${accountId}`
|
|
241
|
+
: "channels.qqbot";
|
|
242
|
+
return `QQBot native approvals are enabled by default. To restrict who can approve, configure \`${prefix}.allowFrom\` with QQ user OpenIDs.`;
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
delivery: {
|
|
246
|
+
hasConfiguredDmRoute: () => true,
|
|
247
|
+
shouldSuppressForwardingFallback: (input) => {
|
|
248
|
+
const channel = normalizeOptionalString(input.target?.channel);
|
|
249
|
+
if (channel !== "qqbot") return false;
|
|
250
|
+
const accountId =
|
|
251
|
+
normalizeOptionalString(input.target?.accountId) ??
|
|
252
|
+
normalizeOptionalString(input.request?.request?.turnSourceAccountId);
|
|
253
|
+
return isNativeDeliveryEnabled(resolveActiveConfig(), accountId);
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
native: {
|
|
258
|
+
describeDeliveryCapabilities: ({ cfg, accountId }) => ({
|
|
259
|
+
enabled: isNativeDeliveryEnabled(cfg, accountId),
|
|
260
|
+
preferredSurface: "origin" as const,
|
|
261
|
+
supportsOriginSurface: true,
|
|
262
|
+
supportsApproverDmSurface: false,
|
|
263
|
+
notifyOriginWhenDmOnly: false,
|
|
264
|
+
}),
|
|
265
|
+
resolveOriginTarget: ({ request }) => {
|
|
266
|
+
const sessionKey = request.request.sessionKey ?? null;
|
|
267
|
+
const turnSourceTo = request.request.turnSourceTo ?? null;
|
|
268
|
+
const target = resolveApprovalTarget(sessionKey, turnSourceTo);
|
|
269
|
+
if (target) return { to: `${target.type}:${target.id}` };
|
|
270
|
+
const sessionConversation = resolveApprovalRequestSessionConversation({
|
|
271
|
+
request: request as never,
|
|
272
|
+
channel: "qqbot",
|
|
273
|
+
bundledFallback: true,
|
|
274
|
+
});
|
|
275
|
+
if (sessionConversation?.id) {
|
|
276
|
+
const kind = sessionConversation.kind === "group" ? "group" : "c2c";
|
|
277
|
+
return { to: `${kind}:${sessionConversation.id}` };
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
|
|
284
|
+
eventKinds: ["exec", "plugin", "system-agent"],
|
|
285
|
+
isConfigured: ({ cfg, accountId }) => isNativeDeliveryEnabled(cfg, accountId),
|
|
286
|
+
shouldHandle: ({ cfg, accountId, request }) => {
|
|
287
|
+
const target = resolveQQTarget(request as never);
|
|
288
|
+
if (!target) return false;
|
|
289
|
+
return matchesAccount(cfg, accountId, request as never);
|
|
290
|
+
},
|
|
291
|
+
load: async () =>
|
|
292
|
+
qqbotApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
|
|
293
|
+
}),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let cachedCapability: ChannelApprovalCapability | undefined;
|
|
298
|
+
|
|
299
|
+
export function getQQBotApprovalCapability(): ChannelApprovalCapability {
|
|
300
|
+
cachedCapability ??= createQQBotApprovalCapability();
|
|
301
|
+
return cachedCapability;
|
|
302
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval helpers — pure functions shared by the approval capability and the
|
|
3
|
+
* interaction (button-click) handler.
|
|
4
|
+
*
|
|
5
|
+
* Ported from the in-tree openclaw qqbot extension so the external plugin stays
|
|
6
|
+
* byte-compatible with the framework's approval view model:
|
|
7
|
+
* - buildApprovalKeyboard reads `allowedDecisions` from the SDK view (fixes
|
|
8
|
+
* the hardcoded-3-button bug where skill_workshop approvals rejected
|
|
9
|
+
* `allow-always`).
|
|
10
|
+
* - button_data uses the v2 format `approve:v2:<kind>:<encodedId>:<decision>`
|
|
11
|
+
* so the kind is recoverable at click time without ID-prefix sniffing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
ExecApprovalPendingView,
|
|
16
|
+
PendingApprovalView,
|
|
17
|
+
PluginApprovalPendingView,
|
|
18
|
+
} from "openclaw/plugin-sdk/approval-handler-runtime";
|
|
19
|
+
import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/approval-runtime";
|
|
20
|
+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
21
|
+
import type { InlineKeyboard, KeyboardButton } from "../types.js";
|
|
22
|
+
|
|
23
|
+
// ============ Types ============
|
|
24
|
+
|
|
25
|
+
export type ApprovalKind = "exec" | "plugin" | "system-agent";
|
|
26
|
+
|
|
27
|
+
// The SDK re-exports only the pending-view union, not the system-agent member.
|
|
28
|
+
export type SystemAgentApprovalPendingView = Extract<
|
|
29
|
+
PendingApprovalView,
|
|
30
|
+
{ approvalKind: "system-agent" }
|
|
31
|
+
>;
|
|
32
|
+
export type ApprovalDecision = "allow-once" | "allow-always" | "deny";
|
|
33
|
+
|
|
34
|
+
export interface ApprovalTarget {
|
|
35
|
+
type: "c2c" | "group";
|
|
36
|
+
id: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ParsedApprovalAction {
|
|
40
|
+
approvalId: string;
|
|
41
|
+
approvalKind: ApprovalKind;
|
|
42
|
+
decision: ApprovalDecision;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============ Text Builders ============
|
|
46
|
+
|
|
47
|
+
const COMMAND_PREVIEW_MAX_LENGTH = 300;
|
|
48
|
+
const COMMAND_PREVIEW_GRAPHEMES_PER_LINE = 24;
|
|
49
|
+
const COMMAND_PREVIEW_WRAP_MARKER = "↩";
|
|
50
|
+
const commandPreviewSegmenter =
|
|
51
|
+
typeof Intl !== "undefined" && "Segmenter" in Intl
|
|
52
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
53
|
+
: null;
|
|
54
|
+
|
|
55
|
+
function splitCommandPreviewGraphemes(commandText: string): string[] {
|
|
56
|
+
return commandPreviewSegmenter
|
|
57
|
+
? Array.from(commandPreviewSegmenter.segment(commandText), ({ segment }) => segment)
|
|
58
|
+
: Array.from(commandText);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatCommandPreview(commandText: string): string {
|
|
62
|
+
// QQ Desktop does not wrap fenced blocks. The sanitized view has already escaped real command
|
|
63
|
+
// newlines, so these grapheme-safe line breaks are presentation-only and unambiguous. Limiting
|
|
64
|
+
// each line to 24 graphemes also bounds common double-width text to roughly 48 columns.
|
|
65
|
+
const lines = [""];
|
|
66
|
+
const displayText = commandText.replaceAll(COMMAND_PREVIEW_WRAP_MARKER, "\\u{21A9}");
|
|
67
|
+
let previewLength = 0;
|
|
68
|
+
let lineGraphemes = 0;
|
|
69
|
+
let truncated = false;
|
|
70
|
+
let wrapped = false;
|
|
71
|
+
for (const grapheme of splitCommandPreviewGraphemes(displayText)) {
|
|
72
|
+
if (previewLength + grapheme.length > COMMAND_PREVIEW_MAX_LENGTH) {
|
|
73
|
+
if (previewLength === 0) {
|
|
74
|
+
lines[0] = truncateUtf16Safe(grapheme, COMMAND_PREVIEW_MAX_LENGTH);
|
|
75
|
+
}
|
|
76
|
+
truncated = true;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
previewLength += grapheme.length;
|
|
80
|
+
if (lineGraphemes === COMMAND_PREVIEW_GRAPHEMES_PER_LINE) {
|
|
81
|
+
lines[lines.length - 1] += COMMAND_PREVIEW_WRAP_MARKER;
|
|
82
|
+
lines.push("");
|
|
83
|
+
lineGraphemes = 0;
|
|
84
|
+
wrapped = true;
|
|
85
|
+
}
|
|
86
|
+
lines[lines.length - 1] += grapheme;
|
|
87
|
+
lineGraphemes += 1;
|
|
88
|
+
}
|
|
89
|
+
const preview = `${lines.join("\n")}${truncated ? "\n…[truncated]" : ""}`;
|
|
90
|
+
const longestBacktickRun = Math.max(0, ...(preview.match(/`+/g)?.map((run) => run.length) ?? []));
|
|
91
|
+
const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
|
|
92
|
+
const block = `${fence}\n${preview}\n${fence}`;
|
|
93
|
+
return wrapped
|
|
94
|
+
? `${COMMAND_PREVIEW_WRAP_MARKER} = display wrap only; not command text\n${block}`
|
|
95
|
+
: block;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatApprovalMetadata(value: string): string {
|
|
99
|
+
const sanitized = resolveExecApprovalCommandDisplay({ command: value }).commandText;
|
|
100
|
+
return formatCommandPreview(sanitized);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildExecApprovalText(view: ExecApprovalPendingView, nowMs = Date.now()): string {
|
|
104
|
+
const expiresIn = Math.max(0, Math.round((view.expiresAtMs - nowMs) / 1000));
|
|
105
|
+
const lines: string[] = ["🔐 命令执行审批", ""];
|
|
106
|
+
if (view.commandText) {
|
|
107
|
+
lines.push(formatCommandPreview(view.commandText));
|
|
108
|
+
}
|
|
109
|
+
if (view.cwd) {
|
|
110
|
+
lines.push(`📁 目录:\n${formatApprovalMetadata(view.cwd)}`);
|
|
111
|
+
}
|
|
112
|
+
if (view.agentId) {
|
|
113
|
+
lines.push(`🤖 Agent:\n${formatApprovalMetadata(view.agentId)}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push("", `⏱️ 超时: ${expiresIn} 秒`);
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildPluginApprovalText(
|
|
120
|
+
view: PluginApprovalPendingView,
|
|
121
|
+
nowMs = Date.now(),
|
|
122
|
+
): string {
|
|
123
|
+
const expiresIn = Math.max(0, Math.round((view.expiresAtMs - nowMs) / 1000));
|
|
124
|
+
const severityIcon =
|
|
125
|
+
view.severity === "critical" ? "🔴"
|
|
126
|
+
: view.severity === "info" ? "🔵"
|
|
127
|
+
: "🟡";
|
|
128
|
+
|
|
129
|
+
const lines: string[] = [`${severityIcon} 审批请求`, ""];
|
|
130
|
+
lines.push(`📋 ${view.title}`);
|
|
131
|
+
if (view.description) lines.push(`📝 ${view.description}`);
|
|
132
|
+
if (view.toolName) lines.push(`🔧 工具: ${view.toolName}`);
|
|
133
|
+
if (view.pluginId) lines.push(`🔌 插件: ${view.pluginId}`);
|
|
134
|
+
if (view.agentId) lines.push(`🤖 Agent: ${view.agentId}`);
|
|
135
|
+
lines.push("", `⏱️ 超时: ${expiresIn} 秒`);
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function buildSystemAgentApprovalText(
|
|
140
|
+
view: SystemAgentApprovalPendingView,
|
|
141
|
+
nowMs = Date.now(),
|
|
142
|
+
): string {
|
|
143
|
+
const expiresIn = Math.max(0, Math.round((view.expiresAtMs - nowMs) / 1000));
|
|
144
|
+
|
|
145
|
+
const lines: string[] = ["🛡️ 系统变更审批", ""];
|
|
146
|
+
lines.push(`📋 ${view.title}`);
|
|
147
|
+
if (view.description) lines.push(`📝 ${view.description}`);
|
|
148
|
+
if (view.operationSummary) lines.push(`📊 ${view.operationSummary}`);
|
|
149
|
+
if (view.commandText) {
|
|
150
|
+
lines.push(formatCommandPreview(view.commandText));
|
|
151
|
+
}
|
|
152
|
+
if (view.cwd) {
|
|
153
|
+
lines.push(`📁 目录:\n${formatApprovalMetadata(view.cwd)}`);
|
|
154
|
+
}
|
|
155
|
+
if (view.host) lines.push(`🖥️ 主机: ${view.host}`);
|
|
156
|
+
if (view.agentId) lines.push(`🤖 Agent:\n${formatApprovalMetadata(view.agentId)}`);
|
|
157
|
+
lines.push("", `⏱️ 超时: ${expiresIn} 秒`);
|
|
158
|
+
return lines.join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ============ Keyboard Builder ============
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build the inline keyboard for approval messages.
|
|
165
|
+
*
|
|
166
|
+
* type=1 (Callback): click triggers INTERACTION_CREATE, button_data = data field.
|
|
167
|
+
* group_id "approval": clicking one button grays out the others (mutual exclusion).
|
|
168
|
+
* click_limit=1: each user can only click once.
|
|
169
|
+
* permission.type=2: all users can interact.
|
|
170
|
+
*
|
|
171
|
+
* Only buttons whose decision is in `allowedDecisions` are rendered — the SDK
|
|
172
|
+
* view drives this, so e.g. skill_workshop approvals (which restrict to
|
|
173
|
+
* ["allow-once","deny"]) no longer offer an invalid "allow-always" button.
|
|
174
|
+
*/
|
|
175
|
+
export function buildApprovalKeyboard(
|
|
176
|
+
approvalId: string,
|
|
177
|
+
approvalKind: ApprovalKind,
|
|
178
|
+
allowedDecisions: readonly ApprovalDecision[] = ["allow-once", "allow-always", "deny"],
|
|
179
|
+
): InlineKeyboard {
|
|
180
|
+
const actionPrefix = `approve:v2:${approvalKind}:${encodeURIComponent(approvalId)}`;
|
|
181
|
+
const makeBtn = (
|
|
182
|
+
id: string,
|
|
183
|
+
label: string,
|
|
184
|
+
visitedLabel: string,
|
|
185
|
+
data: string,
|
|
186
|
+
style: 0 | 1,
|
|
187
|
+
): KeyboardButton => ({
|
|
188
|
+
id,
|
|
189
|
+
render_data: { label, visited_label: visitedLabel, style },
|
|
190
|
+
action: {
|
|
191
|
+
type: 1,
|
|
192
|
+
data,
|
|
193
|
+
permission: { type: 2 },
|
|
194
|
+
click_limit: 1,
|
|
195
|
+
},
|
|
196
|
+
group_id: "approval",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const buttons: KeyboardButton[] = [];
|
|
200
|
+
if (allowedDecisions.includes("allow-once")) {
|
|
201
|
+
buttons.push(makeBtn("allow", "✅ 允许一次", "已处理", `${actionPrefix}:allow-once`, 1));
|
|
202
|
+
}
|
|
203
|
+
if (allowedDecisions.includes("allow-always")) {
|
|
204
|
+
buttons.push(makeBtn("always", "⭐ 始终允许", "已处理", `${actionPrefix}:allow-always`, 1));
|
|
205
|
+
}
|
|
206
|
+
if (allowedDecisions.includes("deny")) {
|
|
207
|
+
buttons.push(makeBtn("deny", "❌ 拒绝", "已处理", `${actionPrefix}:deny`, 0));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
content: {
|
|
212
|
+
rows: [{ buttons }],
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ============ Target Resolver ============
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Extract the delivery target from a sessionKey or turnSourceTo string.
|
|
221
|
+
*
|
|
222
|
+
* Expected formats:
|
|
223
|
+
* agent:main:qqbot:c2c:OPENID -> { type: "c2c", id: "OPENID" }
|
|
224
|
+
* agent:main:qqbot:group:GROUPID -> { type: "group", id: "GROUPID" }
|
|
225
|
+
*
|
|
226
|
+
* Returns null if neither field matches the expected pattern.
|
|
227
|
+
*/
|
|
228
|
+
export function resolveApprovalTarget(
|
|
229
|
+
sessionKey: string | null | undefined,
|
|
230
|
+
turnSourceTo: string | null | undefined,
|
|
231
|
+
): ApprovalTarget | null {
|
|
232
|
+
const sk = sessionKey ?? turnSourceTo;
|
|
233
|
+
if (!sk) return null;
|
|
234
|
+
const m = sk.match(/qqbot:(c2c|group):([A-F0-9]+)/i);
|
|
235
|
+
if (!m) return null;
|
|
236
|
+
const scope = m[1];
|
|
237
|
+
const id = m[2];
|
|
238
|
+
if (scope === undefined || id === undefined) return null;
|
|
239
|
+
const type: "c2c" | "group" = scope.toLowerCase() === "group" ? "group" : "c2c";
|
|
240
|
+
return { type, id };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ============ Interaction Parser ============
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Parse the button_data string from an INTERACTION_CREATE event.
|
|
247
|
+
*
|
|
248
|
+
* Expected format: `approve:v2:<approvalKind>:<encodedApprovalId>:<decision>`.
|
|
249
|
+
* The approvalKind is baked into the payload so the resolve path doesn't need
|
|
250
|
+
* to infer it from the ID prefix, and the ID is URL-encoded so colons in
|
|
251
|
+
* `exec:<uuid>` / `plugin:<uuid>` / `system-agent:<uuid>` IDs survive
|
|
252
|
+
* unambiguously.
|
|
253
|
+
*
|
|
254
|
+
* Returns null if the data does not match the approval button format.
|
|
255
|
+
*/
|
|
256
|
+
export function parseApprovalButtonData(buttonData: string): ParsedApprovalAction | null {
|
|
257
|
+
const m = buttonData.match(/^approve:v2:(exec|plugin|system-agent):([^:]+):(allow-once|allow-always|deny)$/);
|
|
258
|
+
if (!m || m[0] !== buttonData) return null;
|
|
259
|
+
const kind = m[1] as ApprovalKind;
|
|
260
|
+
const encodedId = m[2];
|
|
261
|
+
const decision = m[3] as ApprovalDecision;
|
|
262
|
+
if (!encodedId) return null;
|
|
263
|
+
let approvalId: string;
|
|
264
|
+
try {
|
|
265
|
+
approvalId = decodeURIComponent(encodedId);
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
if (!approvalId) return null;
|
|
270
|
+
return { approvalId, approvalKind: kind, decision };
|
|
271
|
+
}
|