@jerryliang122/openclaw-qqbot 1.0.7 → 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.
@@ -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 { tryGetBotForAccount } from '../bot-instance.js';
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 { getRequestAccountId, getRequestTarget } from '../request-context.js';
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 参数可传;request-context 在工具执行期间处于活跃,
29
- // 模块级 logger 的 enrichMeta 会自动带上 accountId/openId/messageId
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
- name: 'qqbot_secret_input',
111
- label: 'QQBot Secret Input Card',
112
- description:
113
- '当用户要交付一个新密钥/凭据(API Key、Token 等)给系统保存时调用:' +
114
- '向用户私聊发送一张密钥输入卡片,用户把密钥粘贴在输入框发送后,' +
115
- '插件会自动拦截该消息并执行 openclaw secrets store set 完成保存,' +
116
- '结果由插件直接回复用户。密钥内容不会进入对话。' +
117
- '必须由用户提供 name 参数(环境变量名)。',
118
- parameters: SecretInputSchema,
119
- async execute(_toolCallId, params) {
120
- const p = params as { name?: string; description?: string };
121
- const name = (p.name ?? '').trim();
122
- if (!name) return json({ error: 'name 为必填参数(环境变量名)' });
123
- if (!isValidSecretName(name)) {
124
- return json({
125
- error:
126
- '环境变量名不合法:必须以大写字母开头,仅含大写字母/数字/下划线,最长 128 字符' +
127
- `(收到 "${name}")。示例:OPENWEATHER_API_KEY。请修正后重试。`,
128
- });
129
- }
130
- // 恒为 env:见文件头注释(卡片值 = 代理可读环境,secret 是 write-only 代理不可读)
131
- const kind = 'env' as const;
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
- const target = getRequestTarget();
134
- const accountId = getRequestAccountId() ?? 'default';
135
- if (!target) {
136
- return json({ error: '无法获取当前会话目标,此工具仅支持在 QQ Bot 消息会话中使用' });
137
- }
138
- const parsed = parseTarget(target);
139
- if (parsed.scope !== 'c2c' || !parsed.targetId) {
140
- return json({
141
- error:
142
- `密钥输入卡片仅支持私聊(c2c)会话,当前目标 ${target} 不适用` +
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
- const bot = tryGetBotForAccount(accountId);
148
- if (!bot) {
149
- return json({ error: `账户 ${accountId} 的 gateway 尚未启动,无法发送密钥输入卡片` });
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
- const description = p.description?.trim() || undefined;
153
- // 先登记后发卡(避免「先发后登」竞态,同 multi-question 约定)
154
- registerPendingSecretInput({
155
- accountId,
156
- senderOpenid: parsed.targetId,
157
- name,
158
- kind,
159
- description,
160
- createdAt: Date.now(),
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
- toolLog.info(`card sent, pending registered name=${name} ttlMinutes=${DEFAULT_SECRET_INPUT_TTL_MS / 60_000}`);
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
- return json({
179
- ok: true,
180
- name,
181
- kind,
182
- command: `openclaw secrets store set ${name} --kind env --value <用户输入>`,
183
- instruction:
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
  );
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 工具执行时的会话路由解析(双层回退)
3
+ *
4
+ * 背景(2026-09-23 事故):qqbot_secret_input 等工具此前仅靠
5
+ * request-context(AsyncLocalStorage)获取当前会话目标。ALS 只在
6
+ * handleMessage → dispatchToOpenClaw 的异步子树内有效;openclaw 框架
7
+ * 经常把 agent turn 延迟到该子树之外执行——活动 turn 之后排队的
8
+ * followup/collect 批处理、框架自有定时器驱动的队列 drain 等。此时
9
+ * 工具执行处 ALS 为空,工具报「无法获取当前会话目标」直接失败。
10
+ *
11
+ * 修复:优先 ALS(逐消息精确,含 messageId/openId 供日志富化);
12
+ * ALS 缺失时回退到框架在工具工厂上下文里提供的 deliveryContext
13
+ * (openclaw 按当前 run 的 sessionCtx 派生的可信投递路由:
14
+ * { channel, to, accountId },qqbot 通道的 to 即规范目标地址
15
+ * qqbot:c2c:{openid} / qqbot:group:{group_openid})。
16
+ *
17
+ * 工厂注册形式:api.registerTool((ctx) => ({ ...tool }), { name })。
18
+ * 框架在装配工具面(或缓存描述符路径下的每次执行)时调用工厂,
19
+ * ctx 携带当次 run 的 deliveryContext——描述符缓存键包含
20
+ * deliveryContext,不会跨会话复用旧路由。
21
+ */
22
+
23
+ import { getRequestAccountId, getRequestTarget } from '../request-context.js';
24
+
25
+ /** 框架 deliveryContext 中本项目用到的字段(见 openclaw OpenClawPluginToolContext) */
26
+ export interface ToolDeliveryContext {
27
+ channel?: string;
28
+ to?: string;
29
+ accountId?: string;
30
+ threadId?: string;
31
+ }
32
+
33
+ /** 解析出的会话路由 */
34
+ export interface ToolSessionRoute {
35
+ /** 规范目标地址:qqbot:c2c:{openid} / qqbot:group:{group_openid} */
36
+ target: string;
37
+ /** 账户 ID(多账户场景;可能为空,由调用方决定回退策略) */
38
+ accountId?: string;
39
+ /** 路由来源:request-context(ALS)优先,delivery-context(框架 run 级)回退 */
40
+ source: 'request-context' | 'delivery-context';
41
+ }
42
+
43
+ /**
44
+ * 解析当前工具调用所属的会话路由。
45
+ *
46
+ * 返回 undefined 表示当前 run 没有可识别的 QQ 会话来源
47
+ * (如 cron/定时任务、内部 UI 发起的 run)——调用方应返回明确错误。
48
+ */
49
+ export function resolveToolSessionRoute(
50
+ delivery?: ToolDeliveryContext | null,
51
+ ): ToolSessionRoute | undefined {
52
+ // 第一层:请求级 ALS(入站消息处理链路内,逐消息精确)
53
+ const alsTarget = getRequestTarget();
54
+ if (alsTarget) {
55
+ return {
56
+ target: alsTarget,
57
+ accountId: getRequestAccountId(),
58
+ source: 'request-context',
59
+ };
60
+ }
61
+
62
+ // 第二层:框架 run 级 deliveryContext(延迟/排队 turn 的回退)
63
+ const to = typeof delivery?.to === 'string' ? delivery.to.trim() : '';
64
+ if (!to) return undefined;
65
+ const channel = typeof delivery?.channel === 'string' ? delivery.channel.trim() : '';
66
+ // 防御:deliveryContext 属于其他通道(理论上不会发生)时不误认
67
+ if (channel && channel !== 'qqbot') return undefined;
68
+ if (!/^qqbot:(c2c|group|channel):/i.test(to)) return undefined;
69
+ return {
70
+ target: to,
71
+ accountId: delivery?.accountId,
72
+ source: 'delivery-context',
73
+ };
74
+ }
@@ -16,7 +16,7 @@ export function getPackageVersion(): string {
16
16
  }
17
17
 
18
18
  /**
19
- * OpenClaw 框架版本(PluginRuntime.version,peer >=2026.9.2 恒存在)。
19
+ * OpenClaw 框架版本(PluginRuntime.version,peer >=2026.9.1 恒存在)。
20
20
  */
21
21
  export function getOpenclawVersion(runtimeVersion?: string): string {
22
22
  return runtimeVersion && runtimeVersion !== 'unknown' ? runtimeVersion : 'unknown';