@tencent-connect/openclaw-qqbot 1.7.0 → 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 +28 -46
- package/README.zh.md +26 -1
- package/dist/src/api.d.ts +4 -0
- package/dist/src/api.js +17 -1
- 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/gateway.js +45 -2
- package/dist/src/slash-commands.d.ts +7 -2
- package/dist/src/slash-commands.js +235 -0
- package/dist/src/types.d.ts +66 -0
- package/package.json +1 -1
- package/src/api.ts +32 -1
- package/src/approval-handler.ts +505 -0
- package/src/channel.ts +76 -0
- package/src/gateway.ts +48 -4
- package/src/openclaw-plugin-sdk.d.ts +127 -2
- package/src/slash-commands.ts +253 -2
- package/src/types.ts +78 -0
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/gateway.js
CHANGED
|
@@ -8,6 +8,7 @@ import { recordKnownUser, flushKnownUsers } from "./known-users.js";
|
|
|
8
8
|
import { getQQBotRuntime } from "./runtime.js";
|
|
9
9
|
import { isGroupAllowed, resolveGroupName, resolveGroupPrompt, resolveHistoryLimit, resolveGroupPolicy, resolveGroupConfig, resolveIgnoreOtherMentions, resolveMentionPatterns } from "./config.js";
|
|
10
10
|
import { qqbotPlugin, stripMentionText, detectWasMentioned } from "./channel.js";
|
|
11
|
+
import { QQBotApprovalHandler, registerApprovalHandler, unregisterApprovalHandler, getApprovalHandler } from "./approval-handler.js";
|
|
11
12
|
import { recordPendingHistoryEntry, buildPendingHistoryContext, buildMergedMessageContext, clearPendingHistory, formatAttachmentTags, formatMessageContent, toAttachmentSummaries, } from "./group-history.js";
|
|
12
13
|
import { setRefIndex, getRefIndex, formatRefEntryForAgent, formatMessageReferenceForAgent, flushRefIndex } from "./ref-index-store.js";
|
|
13
14
|
import { matchSlashCommand, getFrameworkVersion, parseFrameworkDateVersion } from "./slash-commands.js";
|
|
@@ -135,9 +136,27 @@ async function handleInteractionCreate(params) {
|
|
|
135
136
|
log?.info(`[qqbot:${account.accountId}] Interaction ACK (type=${INTERACTION_TYPE_CONFIG_UPDATE}) sent: ${event.id}, claw_cfg=${JSON.stringify(ackClawCfg)}`);
|
|
136
137
|
}
|
|
137
138
|
else {
|
|
138
|
-
//
|
|
139
|
+
// 普通按钮交互:先 ACK
|
|
139
140
|
await acknowledgeInteraction(token, event.id);
|
|
140
141
|
log?.debug?.(`[qqbot:${account.accountId}] Interaction ACK sent: ${event.id}`);
|
|
142
|
+
// Inline Keyboard 审批按钮(type=1 Callback)
|
|
143
|
+
// button_data 格式:approve:<approvalId>:<decision>
|
|
144
|
+
// approvalId 可能是 "exec:uuid" / "plugin:uuid"(带前缀)或纯 "uuid"(无前缀)
|
|
145
|
+
const buttonData = event.data?.resolved?.button_data ?? "";
|
|
146
|
+
const m = buttonData.match(/^approve:((?:(?:exec|plugin):)?[0-9a-f-]+):(allow-once|allow-always|deny)$/i);
|
|
147
|
+
if (m) {
|
|
148
|
+
const approvalId = m[1];
|
|
149
|
+
const decision = m[2];
|
|
150
|
+
const userId = event.group_member_openid || event.user_openid || event.data?.resolved?.user_id || "unknown";
|
|
151
|
+
log?.info(`[qqbot:${account.accountId}] Approval button clicked: approvalId=${approvalId}, decision=${decision}, user=${userId}, buttonData=${buttonData}`);
|
|
152
|
+
const handler = getApprovalHandler(account.accountId);
|
|
153
|
+
if (handler) {
|
|
154
|
+
void handler.resolveApproval(approvalId, decision);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
log?.error(`[qqbot:${account.accountId}] Approval button: no handler found for accountId=${account.accountId}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
141
160
|
}
|
|
142
161
|
}
|
|
143
162
|
/** 解析 session store 文件路径 */
|
|
@@ -425,6 +444,16 @@ export async function startGateway(ctx) {
|
|
|
425
444
|
lastSeq = savedSession.lastSeq;
|
|
426
445
|
log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}`);
|
|
427
446
|
}
|
|
447
|
+
// ============ 审批 Handler ============
|
|
448
|
+
const approvalHandler = new QQBotApprovalHandler({
|
|
449
|
+
accountId: account.accountId,
|
|
450
|
+
appId: account.appId,
|
|
451
|
+
clientSecret: account.clientSecret,
|
|
452
|
+
cfg: cfg,
|
|
453
|
+
log,
|
|
454
|
+
});
|
|
455
|
+
registerApprovalHandler(account.accountId, approvalHandler);
|
|
456
|
+
void approvalHandler.start();
|
|
428
457
|
// ============ 消息队列(复用 createMessageQueue,内置群消息合并/淘汰策略) ============
|
|
429
458
|
const msgQueue = createMessageQueue({
|
|
430
459
|
accountId: account.accountId,
|
|
@@ -433,7 +462,9 @@ export async function startGateway(ctx) {
|
|
|
433
462
|
});
|
|
434
463
|
// 斜杠指令拦截:在入队前匹配插件级指令,命中则直接回复,不入队
|
|
435
464
|
// 紧急命令列表:这些命令会立即执行,不进入斜杠匹配流程
|
|
436
|
-
|
|
465
|
+
// /stop — 停止当前 agent run,清空队列
|
|
466
|
+
// /approve — 审批决策,必须在 agent 等待审批时立即执行,否则死锁
|
|
467
|
+
const URGENT_COMMANDS = ["/stop", "/approve"];
|
|
437
468
|
const trySlashCommandOrEnqueue = async (msg) => {
|
|
438
469
|
const content = (msg.content ?? "").trim();
|
|
439
470
|
if (!content.startsWith("/")) {
|
|
@@ -478,6 +509,15 @@ export async function startGateway(ctx) {
|
|
|
478
509
|
msgQueue.enqueue(msg);
|
|
479
510
|
return;
|
|
480
511
|
}
|
|
512
|
+
// 委托给 AI 模型:用加工后的 prompt 替换原始消息入队
|
|
513
|
+
const isDelegateResult = typeof reply === "object" && reply !== null && "delegatePrompt" in reply;
|
|
514
|
+
if (isDelegateResult) {
|
|
515
|
+
const delegatePrompt = reply.delegatePrompt;
|
|
516
|
+
log?.info(`[qqbot:${account.accountId}] Slash command delegated to AI: ${content.slice(0, 40)}`);
|
|
517
|
+
msg.content = delegatePrompt;
|
|
518
|
+
msgQueue.enqueue(msg);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
481
521
|
// 命中插件级指令,直接回复
|
|
482
522
|
log?.info(`[qqbot:${account.accountId}] Slash command matched: ${content}, replying directly`);
|
|
483
523
|
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
@@ -537,6 +577,9 @@ export async function startGateway(ctx) {
|
|
|
537
577
|
flushKnownUsers();
|
|
538
578
|
// P1-4: 保存引用索引数据
|
|
539
579
|
flushRefIndex();
|
|
580
|
+
// 停止审批 handler
|
|
581
|
+
void approvalHandler.stop();
|
|
582
|
+
unregisterApprovalHandler(account.accountId);
|
|
540
583
|
});
|
|
541
584
|
const cleanup = () => {
|
|
542
585
|
if (heartbeatInterval) {
|
|
@@ -59,14 +59,19 @@ export interface QueueSnapshot {
|
|
|
59
59
|
/** 当前发送者在队列中的待处理消息数 */
|
|
60
60
|
senderPending: number;
|
|
61
61
|
}
|
|
62
|
-
/**
|
|
63
|
-
export type SlashCommandResult = string | SlashCommandFileResult | null;
|
|
62
|
+
/** 斜杠指令返回值:文本、带文件的结果、委托给模型、或 null(不处理) */
|
|
63
|
+
export type SlashCommandResult = string | SlashCommandFileResult | SlashCommandDelegateResult | null;
|
|
64
64
|
/** 带文件的指令结果(先回复文本,再发送文件) */
|
|
65
65
|
export interface SlashCommandFileResult {
|
|
66
66
|
text: string;
|
|
67
67
|
/** 要发送的本地文件路径 */
|
|
68
68
|
filePath: string;
|
|
69
69
|
}
|
|
70
|
+
/** 委托给 AI 模型处理:用加工后的 prompt 替换原始消息入队 */
|
|
71
|
+
export interface SlashCommandDelegateResult {
|
|
72
|
+
/** 替换原始消息内容的 prompt,交给 AI 模型执行 */
|
|
73
|
+
delegatePrompt: string;
|
|
74
|
+
}
|
|
70
75
|
/**
|
|
71
76
|
* 尝试匹配并执行插件级斜杠指令
|
|
72
77
|
*
|
|
@@ -20,6 +20,7 @@ import { saveCredentialBackup } from "./credential-backup.js";
|
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { getPackageVersion } from "./utils/pkg-version.js";
|
|
22
22
|
import { getQQBotRuntime } from "./runtime.js";
|
|
23
|
+
import { isApprovalFeatureAvailable } from "./approval-handler.js";
|
|
23
24
|
const require = createRequire(import.meta.url);
|
|
24
25
|
let PLUGIN_VERSION = getPackageVersion(import.meta.url);
|
|
25
26
|
// 获取 openclaw 框架版本(不缓存,每次实时获取)
|
|
@@ -1848,6 +1849,240 @@ registerCommand({
|
|
|
1848
1849
|
}
|
|
1849
1850
|
},
|
|
1850
1851
|
});
|
|
1852
|
+
// ============ /bot-approve 审批配置管理 ============
|
|
1853
|
+
/**
|
|
1854
|
+
* /bot-approve — 管理命令执行审批配置
|
|
1855
|
+
*
|
|
1856
|
+
* 修改 openclaw.json 中 tools.exec.security / tools.exec.ask 字段。
|
|
1857
|
+
*
|
|
1858
|
+
* security: deny | allowlist | full
|
|
1859
|
+
* ask: off | on-miss | always
|
|
1860
|
+
*/
|
|
1861
|
+
registerCommand({
|
|
1862
|
+
name: "bot-approve",
|
|
1863
|
+
description: "管理命令执行审批配置",
|
|
1864
|
+
usage: [
|
|
1865
|
+
`/bot-approve 查看操作指引`,
|
|
1866
|
+
`/bot-approve on 开启审批(白名单模式,推荐)`,
|
|
1867
|
+
`/bot-approve off 关闭审批,命令直接执行`,
|
|
1868
|
+
`/bot-approve always 始终审批,每次执行都需审批`,
|
|
1869
|
+
`/bot-approve reset 恢复框架默认值`,
|
|
1870
|
+
`/bot-approve status 查看当前审批配置`,
|
|
1871
|
+
].join("\n"),
|
|
1872
|
+
handler: async (ctx) => {
|
|
1873
|
+
const arg = ctx.args.trim().toLowerCase();
|
|
1874
|
+
// 审批功能需要 openclaw >= 3.22(gateway-runtime 模块)
|
|
1875
|
+
if (!isApprovalFeatureAvailable()) {
|
|
1876
|
+
return `❌ 当前 OpenClaw 版本不支持审批功能,请升级至最新版本后重试。`;
|
|
1877
|
+
}
|
|
1878
|
+
let runtime;
|
|
1879
|
+
try {
|
|
1880
|
+
runtime = getQQBotRuntime();
|
|
1881
|
+
}
|
|
1882
|
+
catch {
|
|
1883
|
+
// runtime 不可用,构造 prompt 委托给 AI 模型通过 CLI 完成
|
|
1884
|
+
const presetMap = {
|
|
1885
|
+
on: { security: "allowlist", ask: "on-miss", desc: "开启审批(白名单模式)" },
|
|
1886
|
+
off: { security: "full", ask: "off", desc: "关闭审批" },
|
|
1887
|
+
always: { security: "allowlist", ask: "always", desc: "严格模式(每次都审批)" },
|
|
1888
|
+
strict: { security: "allowlist", ask: "always", desc: "严格模式(每次都审批)" },
|
|
1889
|
+
};
|
|
1890
|
+
const preset = presetMap[arg];
|
|
1891
|
+
if (preset) {
|
|
1892
|
+
return {
|
|
1893
|
+
delegatePrompt: [
|
|
1894
|
+
`请帮我${preset.desc}。执行以下单条命令(不要拆开):`,
|
|
1895
|
+
`openclaw config set tools.exec.security ${preset.security} && openclaw config set tools.exec.ask ${preset.ask}`,
|
|
1896
|
+
`执行完成后告诉用户审批配置已更新为 security=${preset.security}, ask=${preset.ask}。`,
|
|
1897
|
+
].join("\n"),
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
if (arg === "reset") {
|
|
1901
|
+
return {
|
|
1902
|
+
delegatePrompt: [
|
|
1903
|
+
`请帮我重置审批配置。执行以下单条命令(不要拆开):`,
|
|
1904
|
+
`openclaw config unset tools.exec.security && openclaw config unset tools.exec.ask`,
|
|
1905
|
+
`执行完成后告诉用户审批配置已重置为框架默认值。`,
|
|
1906
|
+
].join("\n"),
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
if (arg === "status") {
|
|
1910
|
+
return {
|
|
1911
|
+
delegatePrompt: [
|
|
1912
|
+
`请帮我查看当前命令执行审批配置。执行以下单条命令(不要拆开):`,
|
|
1913
|
+
`echo "security=$(openclaw config get tools.exec.security)" && echo "ask=$(openclaw config get tools.exec.ask)"`,
|
|
1914
|
+
`然后告诉用户当前 security 和 ask 的值,以及可用的操作选项:`,
|
|
1915
|
+
`- /bot-approve on 开启审批(白名单模式)`,
|
|
1916
|
+
`- /bot-approve off 关闭审批`,
|
|
1917
|
+
`- /bot-approve always 严格模式`,
|
|
1918
|
+
`- /bot-approve reset 恢复默认`,
|
|
1919
|
+
].join("\n"),
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
// 无参数或未知参数:直接返回操作指引
|
|
1923
|
+
return [
|
|
1924
|
+
`🔐 命令执行审批配置`,
|
|
1925
|
+
``,
|
|
1926
|
+
`<qqbot-cmd-input text="/bot-approve on" show="/bot-approve on"/> 开启审批(白名单模式)`,
|
|
1927
|
+
`<qqbot-cmd-input text="/bot-approve off" show="/bot-approve off"/> 关闭审批`,
|
|
1928
|
+
`<qqbot-cmd-input text="/bot-approve always" show="/bot-approve always"/> 严格模式`,
|
|
1929
|
+
`<qqbot-cmd-input text="/bot-approve reset" show="/bot-approve reset"/> 恢复默认`,
|
|
1930
|
+
`<qqbot-cmd-input text="/bot-approve status" show="/bot-approve status"/> 查看当前配置`,
|
|
1931
|
+
].join("\n");
|
|
1932
|
+
}
|
|
1933
|
+
const configApi = runtime.config;
|
|
1934
|
+
const loadExecConfig = () => {
|
|
1935
|
+
const cfg = configApi.loadConfig();
|
|
1936
|
+
const tools = (cfg.tools ?? {});
|
|
1937
|
+
const exec = (tools.exec ?? {});
|
|
1938
|
+
return {
|
|
1939
|
+
security: String(exec.security ?? "deny"),
|
|
1940
|
+
ask: String(exec.ask ?? "on-miss"),
|
|
1941
|
+
};
|
|
1942
|
+
};
|
|
1943
|
+
const writeExecConfig = async (security, ask) => {
|
|
1944
|
+
const cfg = structuredClone(configApi.loadConfig());
|
|
1945
|
+
const tools = (cfg.tools ?? {});
|
|
1946
|
+
const exec = (tools.exec ?? {});
|
|
1947
|
+
exec.security = security;
|
|
1948
|
+
exec.ask = ask;
|
|
1949
|
+
tools.exec = exec;
|
|
1950
|
+
cfg.tools = tools;
|
|
1951
|
+
await configApi.writeConfigFile(cfg);
|
|
1952
|
+
};
|
|
1953
|
+
const formatStatus = (security, ask) => {
|
|
1954
|
+
const secIcon = security === "full" ? "🟢" : security === "allowlist" ? "🟡" : "🔴";
|
|
1955
|
+
const askIcon = ask === "off" ? "🟢" : ask === "always" ? "🔴" : "🟡";
|
|
1956
|
+
return [
|
|
1957
|
+
`🔐 当前审批配置`,
|
|
1958
|
+
``,
|
|
1959
|
+
`${secIcon} 安全模式 (security): **${security}**`,
|
|
1960
|
+
`${askIcon} 审批模式 (ask): **${ask}**`,
|
|
1961
|
+
``,
|
|
1962
|
+
security === "deny" ? `⚠️ 当前为 deny 模式,所有命令执行被拒绝` :
|
|
1963
|
+
security === "full" && ask === "off" ? `✅ 所有命令无需审批直接执行` :
|
|
1964
|
+
security === "allowlist" && ask === "on-miss" ? `🛡️ 白名单命令直接执行,其余需审批` :
|
|
1965
|
+
ask === "always" ? `🔒 每次命令执行都需要人工审批` :
|
|
1966
|
+
`ℹ️ security=${security}, ask=${ask}`,
|
|
1967
|
+
].join("\n");
|
|
1968
|
+
};
|
|
1969
|
+
// 无参数:操作指引
|
|
1970
|
+
if (!arg) {
|
|
1971
|
+
return [
|
|
1972
|
+
`🔐 命令执行审批配置`,
|
|
1973
|
+
``,
|
|
1974
|
+
`<qqbot-cmd-input text="/bot-approve on" show="/bot-approve on"/> 开启审批(白名单模式)`,
|
|
1975
|
+
`<qqbot-cmd-input text="/bot-approve off" show="/bot-approve off"/> 关闭审批`,
|
|
1976
|
+
`<qqbot-cmd-input text="/bot-approve always" show="/bot-approve always"/> 严格模式`,
|
|
1977
|
+
`<qqbot-cmd-input text="/bot-approve reset" show="/bot-approve reset"/> 恢复默认`,
|
|
1978
|
+
`<qqbot-cmd-input text="/bot-approve status" show="/bot-approve status"/> 查看当前配置`,
|
|
1979
|
+
].join("\n");
|
|
1980
|
+
}
|
|
1981
|
+
// status: 查看当前配置
|
|
1982
|
+
if (arg === "status") {
|
|
1983
|
+
const { security, ask } = loadExecConfig();
|
|
1984
|
+
return [
|
|
1985
|
+
formatStatus(security, ask),
|
|
1986
|
+
``,
|
|
1987
|
+
`<qqbot-cmd-input text="/bot-approve on" show="/bot-approve on"/> 开启审批`,
|
|
1988
|
+
`<qqbot-cmd-input text="/bot-approve off" show="/bot-approve off"/> 关闭审批`,
|
|
1989
|
+
`<qqbot-cmd-input text="/bot-approve always" show="/bot-approve always"/> 严格模式`,
|
|
1990
|
+
`<qqbot-cmd-input text="/bot-approve reset" show="/bot-approve reset"/> 恢复默认`,
|
|
1991
|
+
].join("\n");
|
|
1992
|
+
}
|
|
1993
|
+
// on: 开启审批(白名单 + 未命中审批)
|
|
1994
|
+
if (arg === "on") {
|
|
1995
|
+
try {
|
|
1996
|
+
await writeExecConfig("allowlist", "on-miss");
|
|
1997
|
+
return [
|
|
1998
|
+
`✅ 审批已开启`,
|
|
1999
|
+
``,
|
|
2000
|
+
`• security = allowlist(白名单模式)`,
|
|
2001
|
+
`• ask = on-miss(未命中白名单时需审批)`,
|
|
2002
|
+
``,
|
|
2003
|
+
`已批准的命令自动加入白名单,下次直接执行。`,
|
|
2004
|
+
].join("\n");
|
|
2005
|
+
}
|
|
2006
|
+
catch (err) {
|
|
2007
|
+
return `❌ 配置更新失败: ${err}`;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
// off: 关闭审批
|
|
2011
|
+
if (arg === "off") {
|
|
2012
|
+
try {
|
|
2013
|
+
await writeExecConfig("full", "off");
|
|
2014
|
+
return [
|
|
2015
|
+
`✅ 审批已关闭`,
|
|
2016
|
+
``,
|
|
2017
|
+
`• security = full(允许所有命令)`,
|
|
2018
|
+
`• ask = off(不需要审批)`,
|
|
2019
|
+
``,
|
|
2020
|
+
`⚠️ 所有命令将直接执行,不会弹出审批确认。`,
|
|
2021
|
+
].join("\n");
|
|
2022
|
+
}
|
|
2023
|
+
catch (err) {
|
|
2024
|
+
return `❌ 配置更新失败: ${err}`;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
// always: 始终审批(每次都审批)
|
|
2028
|
+
if (arg === "always") {
|
|
2029
|
+
try {
|
|
2030
|
+
await writeExecConfig("allowlist", "always");
|
|
2031
|
+
return [
|
|
2032
|
+
`✅ 已切换为严格审批模式`,
|
|
2033
|
+
``,
|
|
2034
|
+
`• security = allowlist`,
|
|
2035
|
+
`• ask = always(每次执行都需审批)`,
|
|
2036
|
+
``,
|
|
2037
|
+
`每个命令都会弹出审批按钮,需手动确认。`,
|
|
2038
|
+
].join("\n");
|
|
2039
|
+
}
|
|
2040
|
+
catch (err) {
|
|
2041
|
+
return `❌ 配置更新失败: ${err}`;
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
// reset: 删除配置,恢复框架默认值
|
|
2045
|
+
if (arg === "reset") {
|
|
2046
|
+
try {
|
|
2047
|
+
const cfg = structuredClone(configApi.loadConfig());
|
|
2048
|
+
const tools = (cfg.tools ?? {});
|
|
2049
|
+
const exec = (tools.exec ?? {});
|
|
2050
|
+
delete exec.security;
|
|
2051
|
+
delete exec.ask;
|
|
2052
|
+
if (Object.keys(exec).length === 0) {
|
|
2053
|
+
delete tools.exec;
|
|
2054
|
+
}
|
|
2055
|
+
else {
|
|
2056
|
+
tools.exec = exec;
|
|
2057
|
+
}
|
|
2058
|
+
if (Object.keys(tools).length === 0) {
|
|
2059
|
+
delete cfg.tools;
|
|
2060
|
+
}
|
|
2061
|
+
else {
|
|
2062
|
+
cfg.tools = tools;
|
|
2063
|
+
}
|
|
2064
|
+
await configApi.writeConfigFile(cfg);
|
|
2065
|
+
return [
|
|
2066
|
+
`✅ 审批配置已重置`,
|
|
2067
|
+
``,
|
|
2068
|
+
`已移除 tools.exec.security 和 tools.exec.ask`,
|
|
2069
|
+
`框架将使用默认值(security=deny, ask=on-miss)`,
|
|
2070
|
+
``,
|
|
2071
|
+
`如需开启命令执行,请使用 /bot-approve on`,
|
|
2072
|
+
].join("\n");
|
|
2073
|
+
}
|
|
2074
|
+
catch (err) {
|
|
2075
|
+
return `❌ 配置更新失败: ${err}`;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
return [
|
|
2079
|
+
`❌ 未知参数: ${arg}`,
|
|
2080
|
+
``,
|
|
2081
|
+
`可用选项: on | off | always | reset`,
|
|
2082
|
+
`输入 /bot-approve ? 查看详细用法`,
|
|
2083
|
+
].join("\n");
|
|
2084
|
+
},
|
|
2085
|
+
});
|
|
1851
2086
|
// ============ 匹配入口 ============
|
|
1852
2087
|
/**
|
|
1853
2088
|
* 尝试匹配并执行插件级斜杠指令
|
package/dist/src/types.d.ts
CHANGED
|
@@ -323,6 +323,72 @@ export interface InteractionEvent {
|
|
|
323
323
|
};
|
|
324
324
|
};
|
|
325
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* 按钮 Action 类型
|
|
328
|
+
* 0=跳转链接 1=回调型(INTERACTION_CREATE) 2=指令型(直接发文本) 3=mqqapi
|
|
329
|
+
*/
|
|
330
|
+
export type KeyboardActionType = 0 | 1 | 2 | 3;
|
|
331
|
+
/** 按钮权限 */
|
|
332
|
+
export interface KeyboardPermission {
|
|
333
|
+
/** 0=全体 1=管理员 2=按钮指定 3=身份组 */
|
|
334
|
+
type: 0 | 1 | 2 | 3;
|
|
335
|
+
specify_role_ids?: string[];
|
|
336
|
+
specify_user_ids?: string[];
|
|
337
|
+
}
|
|
338
|
+
/** 二次确认弹窗 */
|
|
339
|
+
export interface KeyboardModal {
|
|
340
|
+
content: string;
|
|
341
|
+
confirm_text?: string;
|
|
342
|
+
cancel_text?: string;
|
|
343
|
+
}
|
|
344
|
+
/** 按钮 Action */
|
|
345
|
+
export interface KeyboardAction {
|
|
346
|
+
type: KeyboardActionType;
|
|
347
|
+
data?: string;
|
|
348
|
+
/** true = 点击后直接发出(Enter)*/
|
|
349
|
+
enter?: boolean;
|
|
350
|
+
/** 仅指令型(type=2):是否把指令发到输入框(reply=true)还是静默发出 */
|
|
351
|
+
reply?: boolean;
|
|
352
|
+
permission?: KeyboardPermission;
|
|
353
|
+
click_limit?: number;
|
|
354
|
+
unsupport_tips?: string;
|
|
355
|
+
modal?: KeyboardModal;
|
|
356
|
+
}
|
|
357
|
+
/** 按钮渲染数据 */
|
|
358
|
+
export interface KeyboardRenderData {
|
|
359
|
+
label: string;
|
|
360
|
+
visited_label?: string;
|
|
361
|
+
/** 0=灰色线框 1=蓝色线框 2=推荐回复专用 3=红色字体 4=蓝色背景 */
|
|
362
|
+
style?: 0 | 1 | 2 | 3 | 4;
|
|
363
|
+
}
|
|
364
|
+
/** 单个按钮 */
|
|
365
|
+
export interface KeyboardButton {
|
|
366
|
+
id: string;
|
|
367
|
+
render_data?: KeyboardRenderData;
|
|
368
|
+
action?: KeyboardAction;
|
|
369
|
+
group_id?: string;
|
|
370
|
+
}
|
|
371
|
+
/** 一行按钮 */
|
|
372
|
+
export interface KeyboardRow {
|
|
373
|
+
buttons: KeyboardButton[];
|
|
374
|
+
}
|
|
375
|
+
/** CustomKeyboard(自定义按钮内容) */
|
|
376
|
+
export interface CustomKeyboard {
|
|
377
|
+
rows: KeyboardRow[];
|
|
378
|
+
}
|
|
379
|
+
/** MessageKeyboard(keyboard / prompt_keyboard.keyboard 共用) */
|
|
380
|
+
export interface MessageKeyboard {
|
|
381
|
+
/** 模板 ID(与 content 二选一) */
|
|
382
|
+
id?: string;
|
|
383
|
+
/** 自定义内容 */
|
|
384
|
+
content?: CustomKeyboard;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Inline Keyboard(消息内嵌按钮,需平台审核)
|
|
388
|
+
* 发送字段:keyboard
|
|
389
|
+
* JSON: { "keyboard": { "id": "...", "content": { "rows": [...] } } }
|
|
390
|
+
*/
|
|
391
|
+
export type InlineKeyboard = MessageKeyboard;
|
|
326
392
|
/**
|
|
327
393
|
* WebSocket 事件负载
|
|
328
394
|
*/
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -639,7 +639,8 @@ function buildMessageBody(
|
|
|
639
639
|
content: string,
|
|
640
640
|
msgId: string | undefined,
|
|
641
641
|
msgSeq: number,
|
|
642
|
-
messageReference?: string
|
|
642
|
+
messageReference?: string,
|
|
643
|
+
inlineKeyboard?: import("./types.js").InlineKeyboard
|
|
643
644
|
): Record<string, unknown> {
|
|
644
645
|
const body: Record<string, unknown> = currentMarkdownSupport
|
|
645
646
|
? {
|
|
@@ -659,6 +660,10 @@ function buildMessageBody(
|
|
|
659
660
|
if (messageReference && !currentMarkdownSupport) {
|
|
660
661
|
body.message_reference = { message_id: messageReference };
|
|
661
662
|
}
|
|
663
|
+
// Inline Keyboard(内嵌按钮,需审核):字段名 keyboard,结构 { content: { rows } }
|
|
664
|
+
if (inlineKeyboard) {
|
|
665
|
+
body.keyboard = inlineKeyboard;
|
|
666
|
+
}
|
|
662
667
|
return body;
|
|
663
668
|
}
|
|
664
669
|
|
|
@@ -735,6 +740,32 @@ export async function sendGroupMessage(
|
|
|
735
740
|
return sendAndNotify(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { text: content });
|
|
736
741
|
}
|
|
737
742
|
|
|
743
|
+
/** 发送带 Inline Keyboard 的 C2C 消息(回调型按钮,触发 INTERACTION_CREATE) */
|
|
744
|
+
export async function sendC2CMessageWithInlineKeyboard(
|
|
745
|
+
accessToken: string,
|
|
746
|
+
openid: string,
|
|
747
|
+
content: string,
|
|
748
|
+
inlineKeyboard: import("./types.js").InlineKeyboard,
|
|
749
|
+
msgId?: string,
|
|
750
|
+
): Promise<MessageResponse> {
|
|
751
|
+
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
|
752
|
+
const body = buildMessageBody(content, msgId, msgSeq, undefined, inlineKeyboard);
|
|
753
|
+
return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, body, { text: content });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** 发送带 Inline Keyboard 的 Group 消息(回调型按钮,触发 INTERACTION_CREATE) */
|
|
757
|
+
export async function sendGroupMessageWithInlineKeyboard(
|
|
758
|
+
accessToken: string,
|
|
759
|
+
groupOpenid: string,
|
|
760
|
+
content: string,
|
|
761
|
+
inlineKeyboard: import("./types.js").InlineKeyboard,
|
|
762
|
+
msgId?: string,
|
|
763
|
+
): Promise<MessageResponse> {
|
|
764
|
+
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
|
765
|
+
const body = buildMessageBody(content, msgId, msgSeq, undefined, inlineKeyboard);
|
|
766
|
+
return sendAndNotify(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { text: content });
|
|
767
|
+
}
|
|
768
|
+
|
|
738
769
|
function buildProactiveMessageBody(content: string): Record<string, unknown> {
|
|
739
770
|
if (!content || content.trim().length === 0) {
|
|
740
771
|
throw new Error("主动消息内容不能为空 (markdown.content is empty)");
|