@jerryliang122/openclaw-qqbot 1.0.8 → 1.0.9
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/dist/index.cjs +214 -167
- package/dist/index.cjs.map +1 -1
- package/package.json +1 -1
- package/src/openclaw-plugin-sdk.d.ts +38 -2
- package/src/tools/platform.ts +86 -72
- package/src/tools/remind.ts +53 -42
- package/src/tools/secret-input.ts +112 -80
- package/src/tools/tool-session.ts +74 -0
package/package.json
CHANGED
|
@@ -77,6 +77,32 @@ declare module "openclaw/plugin-sdk" {
|
|
|
77
77
|
execute: (toolCallId: string, params: unknown) => Promise<AgentToolResult> | AgentToolResult;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* 工具工厂上下文(registerTool 工厂形式)
|
|
82
|
+
*
|
|
83
|
+
* 框架在装配当前 run 的工具面(或缓存描述符路径下的每次执行)时调用
|
|
84
|
+
* 工厂,ctx 携带 run 级会话信息——其中 deliveryContext 是框架按当前
|
|
85
|
+
* run 的 sessionCtx 派生的可信投递路由(2026-09-23 修复:延迟/排队
|
|
86
|
+
* turn 中 ALS 请求上下文失效时,工具由此回退解析会话目标)。
|
|
87
|
+
* 仅声明本项目实际使用的字段,其余字段经索引签名透传。
|
|
88
|
+
*/
|
|
89
|
+
export interface PluginToolFactoryContext {
|
|
90
|
+
config?: OpenClawConfig;
|
|
91
|
+
runtimeConfig?: OpenClawConfig;
|
|
92
|
+
agentId?: string;
|
|
93
|
+
sessionKey?: string;
|
|
94
|
+
sessionId?: string;
|
|
95
|
+
messageChannel?: string;
|
|
96
|
+
agentAccountId?: string;
|
|
97
|
+
/** 可信投递路由:{ channel, to, accountId, threadId? } */
|
|
98
|
+
deliveryContext?: { channel?: string; to?: string; accountId?: string; threadId?: string };
|
|
99
|
+
/** 当前入站 turn 的平台原生会话 ID */
|
|
100
|
+
nativeChannelId?: string;
|
|
101
|
+
/** 当前入站 turn 的发送者 ID(运行时提供,非工具参数) */
|
|
102
|
+
requesterSenderId?: string;
|
|
103
|
+
[key: string]: unknown;
|
|
104
|
+
}
|
|
105
|
+
|
|
80
106
|
/**
|
|
81
107
|
* Tool 注册选项
|
|
82
108
|
*/
|
|
@@ -103,8 +129,18 @@ declare module "openclaw/plugin-sdk" {
|
|
|
103
129
|
};
|
|
104
130
|
/** 注册频道 */
|
|
105
131
|
registerChannel<TAccount = unknown>(options: { plugin: ChannelPlugin<TAccount> }): void;
|
|
106
|
-
/**
|
|
107
|
-
|
|
132
|
+
/**
|
|
133
|
+
* 注册工具。支持两种形式:
|
|
134
|
+
* - 静态定义(AnyAgentTool)
|
|
135
|
+
* - 工厂函数:每次工具装配/执行时由框架调用,ctx 携带 run 级
|
|
136
|
+
* deliveryContext 等会话信息(推荐需要感知当前会话的工具使用)
|
|
137
|
+
*/
|
|
138
|
+
registerTool(
|
|
139
|
+
tool:
|
|
140
|
+
| AnyAgentTool
|
|
141
|
+
| ((ctx: PluginToolFactoryContext) => AnyAgentTool | AnyAgentTool[] | null | undefined),
|
|
142
|
+
opts?: ToolRegistrationOptions,
|
|
143
|
+
): void;
|
|
108
144
|
/** 其他 API 方法 */
|
|
109
145
|
[key: string]: unknown;
|
|
110
146
|
}
|
package/src/tools/platform.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
2
2
|
import { listQQBotAccountIds } from '../config.js';
|
|
3
|
-
import {
|
|
4
|
-
import { getRequestAccountId } from '../request-context.js';
|
|
3
|
+
import { resolveGatewayForSend } from '../outbound/outbound-service.js';
|
|
5
4
|
import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
5
|
+
import { resolveToolSessionRoute, type ToolDeliveryContext } from './tool-session.js';
|
|
6
6
|
|
|
7
7
|
// 工具 execute 无 logger 参数;request-context 活跃期间 enrichMeta
|
|
8
8
|
// 自动带上 accountId/openId/messageId
|
|
@@ -71,6 +71,8 @@ function validatePath(path: string): string | null {
|
|
|
71
71
|
* - 频道(Guild/Channel/Member/Announce/Forum/Schedule)→ 见 qqbot-channel skill
|
|
72
72
|
* - 群(Group/Member)→ 见 qqbot-group skill
|
|
73
73
|
*/
|
|
74
|
+
// 工厂注册形式(2026-09-23 修复):turn 被框架延迟/排队执行(ALS 失效)时,
|
|
75
|
+
// 从 deliveryContext 回退解析账户(见 tool-session.ts)。
|
|
74
76
|
export function registerPlatformTool(api: OpenClawPluginApi): void {
|
|
75
77
|
const cfg = api.config;
|
|
76
78
|
if (!cfg) return;
|
|
@@ -79,78 +81,90 @@ export function registerPlatformTool(api: OpenClawPluginApi): void {
|
|
|
79
81
|
if (accountIds.length === 0) return;
|
|
80
82
|
|
|
81
83
|
api.registerTool(
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
'
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
let data: unknown;
|
|
122
|
-
switch (method) {
|
|
123
|
-
case 'GET':
|
|
124
|
-
data = await apiGateway.get(p.path, p.query);
|
|
125
|
-
break;
|
|
126
|
-
case 'POST':
|
|
127
|
-
data = await apiGateway.post(p.path, p.body);
|
|
128
|
-
break;
|
|
129
|
-
case 'PUT':
|
|
130
|
-
data = await apiGateway.put(p.path, p.body);
|
|
131
|
-
break;
|
|
132
|
-
case 'PATCH':
|
|
133
|
-
data = await apiGateway.patch(p.path, p.body);
|
|
134
|
-
break;
|
|
135
|
-
case 'DELETE':
|
|
136
|
-
data = await apiGateway.delete(p.path);
|
|
137
|
-
break;
|
|
84
|
+
(factoryCtx) => {
|
|
85
|
+
const delivery = (factoryCtx as { deliveryContext?: ToolDeliveryContext }).deliveryContext;
|
|
86
|
+
return {
|
|
87
|
+
name: 'qqbot_platform_api',
|
|
88
|
+
label: 'QQBot Platform API Gateway',
|
|
89
|
+
description:
|
|
90
|
+
'QQ 开放平台统一 HTTP API 网关,自动填充鉴权 Token。' +
|
|
91
|
+
'常用接口速查:' +
|
|
92
|
+
'【频道】GET /users/@me/guilds | /guilds/{guild_id}/channels | /channels/{channel_id} | ' +
|
|
93
|
+
'【群】GET /v2/groups/{group_id}/bot_state | /v2/groups/{group_id}/members/{member_id} | /v2/groups/{group_id}/info。' +
|
|
94
|
+
'更多接口和参数详情请阅读 qqbot-channel 和 qqbot-group skill。',
|
|
95
|
+
parameters: PlatformApiSchema,
|
|
96
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
97
|
+
const p = params as {
|
|
98
|
+
method: string;
|
|
99
|
+
path: string;
|
|
100
|
+
body?: Record<string, unknown>;
|
|
101
|
+
query?: Record<string, string>;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (!p.method) return json({ error: 'method 为必填参数' });
|
|
105
|
+
if (!p.path) return json({ error: 'path 为必填参数' });
|
|
106
|
+
|
|
107
|
+
const method = p.method.toUpperCase();
|
|
108
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
109
|
+
return json({ error: `不支持的 HTTP 方法: ${method}` });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const pathError = validatePath(p.path);
|
|
113
|
+
if (pathError) return json({ error: pathError });
|
|
114
|
+
|
|
115
|
+
// 会话路由:ALS 优先,deliveryContext 回退(延迟/排队 turn)
|
|
116
|
+
const route = resolveToolSessionRoute(delivery);
|
|
117
|
+
if (!route) {
|
|
118
|
+
return json({
|
|
119
|
+
error:
|
|
120
|
+
'当前运行没有 QQ 会话来源(可能由定时任务或框架内部触发的 run),' +
|
|
121
|
+
'无法确定调用哪个 QQ Bot 账户。请在 QQ 消息会话中使用本工具。',
|
|
122
|
+
});
|
|
138
123
|
}
|
|
139
124
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
125
|
+
try {
|
|
126
|
+
const resolvedGateway = resolveGatewayForSend(route.accountId ?? 'default');
|
|
127
|
+
if (!resolvedGateway) {
|
|
128
|
+
return json({ error: `账户 ${route.accountId ?? 'default'} 的 gateway 尚未启动` });
|
|
129
|
+
}
|
|
130
|
+
const bot = resolvedGateway.gw.bot;
|
|
131
|
+
const apiGateway = bot.api;
|
|
132
|
+
const startedAt = Date.now();
|
|
133
|
+
|
|
134
|
+
let data: unknown;
|
|
135
|
+
switch (method) {
|
|
136
|
+
case 'GET':
|
|
137
|
+
data = await apiGateway.get(p.path, p.query);
|
|
138
|
+
break;
|
|
139
|
+
case 'POST':
|
|
140
|
+
data = await apiGateway.post(p.path, p.body);
|
|
141
|
+
break;
|
|
142
|
+
case 'PUT':
|
|
143
|
+
data = await apiGateway.put(p.path, p.body);
|
|
144
|
+
break;
|
|
145
|
+
case 'PATCH':
|
|
146
|
+
data = await apiGateway.patch(p.path, p.body);
|
|
147
|
+
break;
|
|
148
|
+
case 'DELETE':
|
|
149
|
+
data = await apiGateway.delete(p.path);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
toolLog.info(`${method} ${p.path} ok (${Date.now() - startedAt}ms)`, { accountId: resolvedGateway.accountId });
|
|
154
|
+
return json(data);
|
|
155
|
+
} catch (err: unknown) {
|
|
156
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
157
|
+
const apiErr = err as { httpStatus?: number; bizCode?: number; path?: string };
|
|
158
|
+
toolLog.warn(`${method} ${p.path} failed status=${apiErr.httpStatus ?? '?'} code=${apiErr.bizCode ?? '?'}: ${errMsg}`, { accountId: route.accountId ?? 'default' });
|
|
159
|
+
return json({
|
|
160
|
+
error: errMsg,
|
|
161
|
+
status: apiErr.httpStatus,
|
|
162
|
+
code: apiErr.bizCode,
|
|
163
|
+
path: p.path,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
};
|
|
154
168
|
},
|
|
155
169
|
{ name: 'qqbot_platform_api' },
|
|
156
170
|
);
|
package/src/tools/remind.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
-
import {
|
|
2
|
+
import { resolveToolSessionRoute, type ToolDeliveryContext } from "./tool-session.js";
|
|
3
3
|
|
|
4
4
|
// ========== 类型定义 ==========
|
|
5
5
|
|
|
@@ -218,52 +218,62 @@ function formatDelay(ms: number): string {
|
|
|
218
218
|
|
|
219
219
|
// ========== 注册入口 ==========
|
|
220
220
|
|
|
221
|
+
// 工厂注册形式(2026-09-23 修复):turn 被框架延迟/排队执行(ALS 失效)时,
|
|
222
|
+
// 从 deliveryContext 回退解析投递目标(见 tool-session.ts)。
|
|
221
223
|
export function registerRemindTool(api: OpenClawPluginApi): void {
|
|
222
224
|
api.registerTool(
|
|
223
|
-
{
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
225
|
+
(factoryCtx) => {
|
|
226
|
+
const delivery = (factoryCtx as { deliveryContext?: ToolDeliveryContext }).deliveryContext;
|
|
227
|
+
return {
|
|
228
|
+
name: "qqbot_remind",
|
|
229
|
+
label: "QQBot 定时提醒",
|
|
230
|
+
description:
|
|
231
|
+
"创建、查询、删除 QQ 定时提醒。" +
|
|
232
|
+
"使用简单参数即可,无需手动构造 cron JSON。\n" +
|
|
233
|
+
"创建提醒:action=add, content=提醒内容, to=目标地址, time=时间\n" +
|
|
234
|
+
"查看提醒:action=list\n" +
|
|
235
|
+
"删除提醒:action=remove, jobId=任务ID(先 list 获取)\n" +
|
|
236
|
+
"时间格式示例:\"5m\"(5分钟后) \"1h\"(1小时后) \"0 8 * * *\"(每天8点)",
|
|
237
|
+
parameters: RemindSchema,
|
|
238
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
239
|
+
const p = params as RemindParams;
|
|
236
240
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
241
|
+
// ===== list =====
|
|
242
|
+
if (p.action === "list") {
|
|
243
|
+
return json({
|
|
244
|
+
_instruction: "请立即使用 cron 工具执行以下参数",
|
|
245
|
+
cronParams: { action: "list" },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
244
248
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
+
// ===== remove =====
|
|
250
|
+
if (p.action === "remove") {
|
|
251
|
+
if (!p.jobId) {
|
|
252
|
+
return json({ error: "action=remove 时 jobId 为必填参数。请先用 action=list 获取 jobId。" });
|
|
253
|
+
}
|
|
254
|
+
return json({
|
|
255
|
+
_instruction: "请立即使用 cron 工具执行以下参数",
|
|
256
|
+
cronParams: { action: "remove", jobId: p.jobId },
|
|
257
|
+
});
|
|
249
258
|
}
|
|
250
|
-
return json({
|
|
251
|
-
_instruction: "请立即使用 cron 工具执行以下参数",
|
|
252
|
-
cronParams: { action: "remove", jobId: p.jobId },
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
259
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
260
|
+
// ===== add =====
|
|
261
|
+
if (!p.content) {
|
|
262
|
+
return json({ error: "action=add 时 content(提醒内容)为必填参数" });
|
|
263
|
+
}
|
|
264
|
+
// 优先使用 AI 传入的 to;否则双层解析当前会话路由:
|
|
265
|
+
// ALS 请求级上下文优先,框架 run 级 deliveryContext 回退
|
|
266
|
+
const route = resolveToolSessionRoute(delivery);
|
|
267
|
+
const resolvedTo = p.to || route?.target;
|
|
268
|
+
if (!resolvedTo) {
|
|
269
|
+
return json({
|
|
270
|
+
error:
|
|
271
|
+
"action=add 时无法确定投递目标地址(当前 run 无 QQ 会话来源)。" +
|
|
272
|
+
'请显式传入 to(格式:qqbot:c2c:user_openid 或 qqbot:group:group_openid)后重试。',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// 账户 ID:会话路由优先,fallback 到 "default"
|
|
276
|
+
const resolvedAccountId = route?.accountId || "default";
|
|
267
277
|
if (!p.time) {
|
|
268
278
|
return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
|
|
269
279
|
}
|
|
@@ -299,7 +309,8 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
|
|
|
299
309
|
cronParams: onceJob,
|
|
300
310
|
summary: `⏰ ${formatDelay(delayMs)}后提醒: "${p.content}"`,
|
|
301
311
|
});
|
|
302
|
-
|
|
312
|
+
},
|
|
313
|
+
};
|
|
303
314
|
},
|
|
304
315
|
{ name: "qqbot_remind" },
|
|
305
316
|
);
|
|
@@ -10,23 +10,31 @@
|
|
|
10
10
|
* 记录里,且 AI 随后要读取使用;secret kind 是「受保护的机密」
|
|
11
11
|
* (write-only,代理不可读),存进去 AI 反而用不了(2026-08-30 用户反馈)。
|
|
12
12
|
*
|
|
13
|
+
* 会话目标解析(2026-09-23 修复):双层回退——请求级 ALS 优先,框架
|
|
14
|
+
* run 级 deliveryContext 兜底。openclaw 把 turn 延迟到入站消息异步子树
|
|
15
|
+
* 之外执行(followup/collect 队列)时 ALS 为空,此前工具直接报
|
|
16
|
+
* 「无法获取当前会话目标」失败;工厂注册形式拿到的 deliveryContext
|
|
17
|
+
* 由框架按当前 run 的 sessionCtx 派生,延迟 turn 下依然正确(见
|
|
18
|
+
* src/tools/tool-session.ts)。
|
|
19
|
+
*
|
|
13
20
|
* 非阻塞:工具发卡后立即返回,AI 应结束本轮;执行与回执完全由插件自治。
|
|
14
21
|
*/
|
|
15
22
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
16
|
-
import {
|
|
23
|
+
import { resolveGatewayForSend } from '../outbound/outbound-service.js';
|
|
17
24
|
import { isValidSecretName } from '../features/secret-store-cli.js';
|
|
18
25
|
import {
|
|
19
26
|
DEFAULT_SECRET_INPUT_TTL_MS,
|
|
20
27
|
cancelPendingSecretInput,
|
|
21
28
|
registerPendingSecretInput,
|
|
22
29
|
} from '../features/secret-input-store.js';
|
|
23
|
-
import {
|
|
24
|
-
import { parseTarget } from '../outbound/target.js';
|
|
30
|
+
import { parseTarget, QQBOT_PREFIX_RE } from '../outbound/target.js';
|
|
25
31
|
import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
32
|
+
import { resolveToolSessionRoute, type ToolDeliveryContext } from './tool-session.js';
|
|
26
33
|
import type { InlineKeyboard, KeyboardButton } from '../types.js';
|
|
27
34
|
|
|
28
|
-
// 工具 execute 无 logger
|
|
29
|
-
//
|
|
35
|
+
// 工具 execute 无 logger 参数可传;入站消息处理链路内 request-context
|
|
36
|
+
// 处于活跃,模块级 logger 的 enrichMeta 自动带上 accountId/openId/messageId
|
|
37
|
+
// (延迟/排队 turn 中 ALS 为空,路由解析回退 deliveryContext)
|
|
30
38
|
const toolLog = createPluginLogger({ prefix: '[tool:secret-input]' });
|
|
31
39
|
|
|
32
40
|
// ========== JSON Schema ==========
|
|
@@ -104,89 +112,113 @@ function json(data: unknown) {
|
|
|
104
112
|
|
|
105
113
|
// ========== 注册入口 ==========
|
|
106
114
|
|
|
115
|
+
// 工厂注册形式(2026-09-23 修复):框架调用工厂时携带当次 run 的
|
|
116
|
+
// deliveryContext——turn 被框架延迟/排队执行(ALS 已失效)时,工具
|
|
117
|
+
// 仍能从 deliveryContext 解析当前会话目标(见 tool-session.ts)。
|
|
107
118
|
export function registerSecretInputTool(api: OpenClawPluginApi): void {
|
|
108
119
|
api.registerTool(
|
|
109
|
-
{
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
'
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return json({
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
120
|
+
(factoryCtx) => {
|
|
121
|
+
const delivery = (factoryCtx as { deliveryContext?: ToolDeliveryContext }).deliveryContext;
|
|
122
|
+
return {
|
|
123
|
+
name: 'qqbot_secret_input',
|
|
124
|
+
label: 'QQBot Secret Input Card',
|
|
125
|
+
description:
|
|
126
|
+
'当用户要交付一个新密钥/凭据(API Key、Token 等)给系统保存时调用:' +
|
|
127
|
+
'向用户私聊发送一张密钥输入卡片,用户把密钥粘贴在输入框发送后,' +
|
|
128
|
+
'插件会自动拦截该消息并执行 openclaw secrets store set 完成保存,' +
|
|
129
|
+
'结果由插件直接回复用户。密钥内容不会进入对话。' +
|
|
130
|
+
'必须由用户提供 name 参数(环境变量名)。',
|
|
131
|
+
parameters: SecretInputSchema,
|
|
132
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
133
|
+
const p = params as { name?: string; description?: string };
|
|
134
|
+
const name = (p.name ?? '').trim();
|
|
135
|
+
if (!name) return json({ error: 'name 为必填参数(环境变量名)' });
|
|
136
|
+
if (!isValidSecretName(name)) {
|
|
137
|
+
return json({
|
|
138
|
+
error:
|
|
139
|
+
'环境变量名不合法:必须以大写字母开头,仅含大写字母/数字/下划线,最长 128 字符' +
|
|
140
|
+
`(收到 "${name}")。示例:OPENWEATHER_API_KEY。请修正后重试。`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// 恒为 env:见文件头注释(卡片值 = 代理可读环境,secret 是 write-only 代理不可读)
|
|
144
|
+
const kind = 'env' as const;
|
|
132
145
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
const route = resolveToolSessionRoute(delivery);
|
|
147
|
+
if (!route) {
|
|
148
|
+
return json({
|
|
149
|
+
error:
|
|
150
|
+
'当前运行没有 QQ 会话来源(可能由定时任务或框架内部触发的 run),' +
|
|
151
|
+
'无法定位要发卡的用户。请引导用户在 QQ 私聊中直接发起密钥输入,' +
|
|
152
|
+
'不要在无会话上下文的任务里调用本工具。',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// scope 必须在 parseTarget 之前显式判定:parseTarget 会把 channel:
|
|
156
|
+
// 归一化为 c2c(target.ts「channel 不被 SDK ChatScope 支持」),
|
|
157
|
+
// 频道会话若不在此拦截就会把频道 ID 当 c2c openid 发卡
|
|
158
|
+
// (Sourcery 复审,2026-09-23)
|
|
159
|
+
const scope = QQBOT_PREFIX_RE.exec(route.target)?.[1]?.toLowerCase();
|
|
160
|
+
const parsed = parseTarget(route.target);
|
|
161
|
+
if (scope !== 'c2c' || !parsed.targetId) {
|
|
162
|
+
return json({
|
|
163
|
+
error:
|
|
164
|
+
`密钥输入卡片仅支持私聊(c2c)会话,当前目标 ${route.target} 不适用` +
|
|
165
|
+
'——密钥不应出现在群聊或频道中。请引导用户在私聊中发起。',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
146
168
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
169
|
+
// 发送账户解析:路由 accountId(缺省 default)→ 精确命中,
|
|
170
|
+
// 未命中且仅单账号运行时回退(与出站发送路径一致,issue #15 约定)。
|
|
171
|
+
// pending 必须登记在**实际发送账号**名下——捕获中间件按其网关的
|
|
172
|
+
// accountId 查询 pending。
|
|
173
|
+
const requestedAccountId = route.accountId ?? 'default';
|
|
174
|
+
const resolvedGateway = resolveGatewayForSend(requestedAccountId);
|
|
175
|
+
if (!resolvedGateway) {
|
|
176
|
+
return json({ error: `账户 ${requestedAccountId} 的 gateway 尚未启动,无法发送密钥输入卡片` });
|
|
177
|
+
}
|
|
178
|
+
const accountId = resolvedGateway.accountId;
|
|
179
|
+
const bot = resolvedGateway.gw.bot;
|
|
151
180
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
});
|
|
162
|
-
try {
|
|
163
|
-
await bot.sendTextWithKeyboard(
|
|
164
|
-
{ scope: 'c2c', targetId: parsed.targetId },
|
|
165
|
-
buildSecretInputCard(name, description, DEFAULT_SECRET_INPUT_TTL_MS / 60_000),
|
|
166
|
-
buildSecretInputKeyboard() as never,
|
|
167
|
-
);
|
|
168
|
-
} catch (err) {
|
|
169
|
-
cancelPendingSecretInput(accountId, parsed.targetId);
|
|
170
|
-
toolLog.error(`card send failed: ${err instanceof Error ? err.message : String(err)}`, { name, accountId });
|
|
171
|
-
return json({
|
|
172
|
-
error: `密钥输入卡片发送失败:${err instanceof Error ? err.message : String(err)}`,
|
|
181
|
+
const description = p.description?.trim() || undefined;
|
|
182
|
+
// 先登记后发卡(避免「先发后登」竞态,同 multi-question 约定)
|
|
183
|
+
registerPendingSecretInput({
|
|
184
|
+
accountId,
|
|
185
|
+
senderOpenid: parsed.targetId,
|
|
186
|
+
name,
|
|
187
|
+
kind,
|
|
188
|
+
description,
|
|
189
|
+
createdAt: Date.now(),
|
|
173
190
|
});
|
|
174
|
-
|
|
191
|
+
try {
|
|
192
|
+
await bot.sendTextWithKeyboard(
|
|
193
|
+
{ scope: 'c2c', targetId: parsed.targetId },
|
|
194
|
+
buildSecretInputCard(name, description, DEFAULT_SECRET_INPUT_TTL_MS / 60_000),
|
|
195
|
+
buildSecretInputKeyboard() as never,
|
|
196
|
+
);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
cancelPendingSecretInput(accountId, parsed.targetId);
|
|
199
|
+
toolLog.error(`card send failed: ${err instanceof Error ? err.message : String(err)}`, { name, accountId });
|
|
200
|
+
return json({
|
|
201
|
+
error: `密钥输入卡片发送失败:${err instanceof Error ? err.message : String(err)}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
175
204
|
|
|
176
|
-
|
|
205
|
+
toolLog.info(
|
|
206
|
+
`card sent, pending registered name=${name} accountId=${accountId} route=${route.source} ttlMinutes=${DEFAULT_SECRET_INPUT_TTL_MS / 60_000}`,
|
|
207
|
+
);
|
|
177
208
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
209
|
+
return json({
|
|
210
|
+
ok: true,
|
|
211
|
+
name,
|
|
212
|
+
kind,
|
|
213
|
+
command: `openclaw secrets store set ${name} --kind env --value <用户输入>`,
|
|
214
|
+
instruction:
|
|
215
|
+
'卡片已发送给用户。用户下一条私聊消息将被插件自动拦截(不会进入对话),' +
|
|
216
|
+
`执行上述命令保存密钥,成功/失败由插件直接回复用户。` +
|
|
217
|
+
'请直接结束本轮(可告知用户按卡片提示发送密钥),不要等待用户输入,' +
|
|
218
|
+
'也不要让用户把密钥内容发到对话里。',
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
};
|
|
190
222
|
},
|
|
191
223
|
{ name: 'qqbot_secret_input' },
|
|
192
224
|
);
|