@jerryliang122/openclaw-qqbot 1.0.8 → 2.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/dist/index.d.cts CHANGED
@@ -203,8 +203,9 @@ interface QQBotAccountConfig {
203
203
  sendMode?: 'stream' | 'static';
204
204
  };
205
205
  /**
206
- * STT (语音转文字) 配置
207
- * 配置后,收到语音消息时会自动调用 STT 服务转录为文字
206
+ * STT (语音转文字) 行为开关
207
+ * 转录凭证统一走框架 tools.media.audio.models;
208
+ * 框架未配置时直接使用 QQ 平台转写(asr_refer_text)
208
209
  */
209
210
  stt?: STTChannelConfig;
210
211
  /**
@@ -289,17 +290,30 @@ interface AudioFormatPolicy {
289
290
  }
290
291
  /**
291
292
  * STT (语音转文字) 配置
293
+ *
294
+ * 2026-10 起转录统一走框架音频理解管线(tools.media.audio.models 凭证),
295
+ * 本块只保留行为开关;旧凭证键已废弃(检测到会打迁移提示日志)。
296
+ * 框架 STT 未配置时,QQ 平台转写(asr_refer_text)直接作为唯一来源。
292
297
  */
293
298
  interface STTChannelConfig {
294
- /** 是否启用 STT(默认 true,配置了 baseUrl+apiKey 即自动启用) */
299
+ /**
300
+ * 是否启用框架 STT 转录(默认 true)。
301
+ * false = 不调用外部 STT,语音只用平台转写(或无转写时占位文本)。
302
+ */
295
303
  enabled?: boolean;
296
- /** STT 服务提供商 ID(对应 models.providers 中的 key,默认 "openai") */
304
+ /**
305
+ * 平台转写(asr_refer_text)参与开关。默认参与:
306
+ * 框架 STT 未配置时直接作为唯一来源,STT 失败/为空时兜底。
307
+ * 设为 false 恢复严格模式——所有场景丢弃平台转写。
308
+ */
309
+ asrFallback?: boolean;
310
+ /** @deprecated 2026-10 起忽略——STT 凭证统一配置在框架 tools.media.audio.models */
297
311
  provider?: string;
298
- /** STT API 地址(如 https://api.openai.com/v1) */
312
+ /** @deprecated 同上 */
299
313
  baseUrl?: string;
300
- /** STT API 密钥 */
314
+ /** @deprecated 同上 */
301
315
  apiKey?: string;
302
- /** STT 模型名称(默认 "whisper-1") */
316
+ /** @deprecated 同上 */
303
317
  model?: string;
304
318
  }
305
319
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jerryliang122/openclaw-qqbot",
3
- "version": "1.0.8",
3
+ "version": "2.0.0",
4
4
  "description": "QQ Bot channel plugin for OpenClaw (independently maintained fork)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -15,7 +15,7 @@ import {
15
15
  isVoiceAttachment,
16
16
  } from '@tencent-connect/qqbot-nodejs/protocol';
17
17
  import type { MessageAttachment } from '../types.js';
18
- import { transcribeAudio, resolveSTTConfig, shouldUsePlatformAsr } from '../utils/stt.js';
18
+ import { shouldUsePlatformAsr, isFrameworkSttConfigured, hasLegacySttCredentials, transcribeAudioViaFramework } from '../utils/stt.js';
19
19
  import { formatVoiceText, formatDuration, type VoiceTranscript, type TranscriptSource } from '../utils/voice-text.js';
20
20
  import { downloadRemoteMedia } from '../adapter/media.js';
21
21
  import { getAdapters } from '../adapter/resolve.js';
@@ -96,8 +96,9 @@ export async function processAttachments(
96
96
  cfg: Record<string, unknown>,
97
97
  log?: Log,
98
98
  ): Promise<ProcessedAttachments> {
99
- const sttCfg = resolveSTTConfig(cfg);
100
99
  const usePlatformAsr = shouldUsePlatformAsr(cfg);
100
+ const sttConfigured = isFrameworkSttConfigured(cfg);
101
+ warnLegacySttCredentials(cfg, log);
101
102
  const audioPolicy = resolveAudioPolicy(cfg);
102
103
 
103
104
  const imageUrls: string[] = [];
@@ -120,7 +121,7 @@ export async function processAttachments(
120
121
  }
121
122
 
122
123
  if (isVoice) {
123
- const transcript = await processVoiceAttachment(att, sttCfg, usePlatformAsr, audioPolicy, log);
124
+ const transcript = await processVoiceAttachment(att, cfg, usePlatformAsr, sttConfigured, audioPolicy, log);
124
125
  return { type: 'voice' as const, transcript };
125
126
  }
126
127
 
@@ -208,29 +209,42 @@ function kindFromContentType(contentType: string | undefined): InboundMediaEntry
208
209
 
209
210
  // ── 语音处理 ──
210
211
 
212
+ /** 废弃插件级 STT 凭证的一次性迁移提示(每进程一条,避免每条语音刷屏) */
213
+ let legacySttWarned = false;
214
+
215
+ function warnLegacySttCredentials(cfg: Record<string, unknown>, log?: Log): void {
216
+ if (!legacySttWarned && hasLegacySttCredentials(cfg)) {
217
+ legacySttWarned = true;
218
+ log?.info(
219
+ 'Voice: channels.qqbot.stt credentials (provider/baseUrl/apiKey/model) are deprecated and ignored; ' +
220
+ 'configure tools.media.audio.models instead — platform asr_refer_text is used when framework STT is absent',
221
+ );
222
+ }
223
+ }
224
+
211
225
  async function processVoiceAttachment(
212
226
  att: MessageAttachment,
213
- sttCfg: ReturnType<typeof resolveSTTConfig>,
227
+ cfg: Record<string, unknown>,
214
228
  usePlatformAsr: boolean,
229
+ sttConfigured: boolean,
215
230
  audioPolicy: AudioPolicyResolved,
216
231
  log?: Log,
217
232
  ): Promise<VoiceTranscript> {
218
- // 平台转写(asr_refer_text)仅在显式 asrFallback: true 时参与;
219
- // 缺省/false 时在所有场景下丢弃——包括 STT 未配置(语音落占位文本)
220
- // 与 STT 失败(不当兜底),三条泄漏路径(转写成功携带 / 转写失败回退 /
221
- // 下载失败回退)一并堵死。
233
+ // 平台转写(asr_refer_text,QQ 平台自动 STT 随事件 JSON 下发):
234
+ // 默认参与——框架 STT 未配置时直接作为唯一来源,STT 失败时兜底;
235
+ // 仅 asrFallback: false(严格模式)时在所有场景丢弃。
222
236
  const rawAsrText = att.asr_refer_text?.trim() || undefined;
223
237
  const asrReferText = usePlatformAsr ? rawAsrText : undefined;
224
238
  // 远端 URL 兜底:优先 wav_url,其次原始 url
225
239
  const remoteUrl = normalizeUrl(att.voice_wav_url) || normalizeUrl(att.url) || undefined;
226
240
 
227
- // STT 未配置:占位文本;显式 asrFallback: true 时退回平台转写
228
- if (!sttCfg) {
241
+ // 框架 STT 未配置 → 平台转写直接作为 transcript(无下载、无外部调用)
242
+ if (!sttConfigured) {
229
243
  if (!usePlatformAsr && rawAsrText) {
230
- log?.info(`Voice: STT not configured; platform asr_refer_text discarded (asrFallback not enabled)`);
244
+ log?.info(`Voice: framework STT not configured; platform asr_refer_text discarded (asrFallback: false)`);
231
245
  }
232
246
  if (asrReferText) {
233
- log?.debug?.(`Voice: using asr_refer_text (STT not configured, asrFallback enabled)`);
247
+ log?.debug?.(`Voice: using platform asr_refer_text (framework STT not configured)`);
234
248
  return { text: asrReferText, source: 'asr', asrReferText, remoteUrl };
235
249
  }
236
250
  return {
@@ -281,17 +295,18 @@ async function processVoiceAttachment(
281
295
 
282
296
  if (localPath) {
283
297
  try {
284
- const transcript = await transcribeAudio(localPath, cfg2stt(sttCfg));
298
+ const transcript = await transcribeAudioViaFramework(localPath, cfg);
285
299
  if (transcript) {
286
- log?.debug?.(`Voice STT: ${transcript.slice(0, 80)}...`);
300
+ log?.debug?.(`Voice STT (framework): ${transcript.slice(0, 80)}...`);
287
301
  return { text: transcript, source: 'stt', duration, localPath, remoteUrl, asrReferText };
288
302
  }
289
303
  } catch (err) {
290
- log?.error(`Voice STT failed: ${err instanceof Error ? err.message : String(err)}`);
304
+ log?.error(`Voice STT (framework) failed: ${err instanceof Error ? err.message : String(err)}`);
291
305
  }
292
306
  }
293
307
 
294
308
  if (asrReferText) {
309
+ log?.debug?.(`Voice: falling back to platform asr_refer_text after framework STT failure`);
295
310
  return { text: asrReferText, source: 'asr', duration, localPath, remoteUrl, asrReferText };
296
311
  }
297
312
 
@@ -334,10 +349,6 @@ function normalizeFormats(formats: string[]): string[] {
334
349
  });
335
350
  }
336
351
 
337
- function cfg2stt(sttCfg: NonNullable<ReturnType<typeof resolveSTTConfig>>): Record<string, unknown> {
338
- return { channels: { qqbot: { stt: sttCfg } } };
339
- }
340
-
341
352
  // ── 文件工具 ──
342
353
 
343
354
  function normalizeUrl(url: string | undefined): string {
@@ -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
- registerTool(tool: AnyAgentTool, opts?: ToolRegistrationOptions): void;
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
  }
@@ -1,8 +1,8 @@
1
1
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
2
  import { listQQBotAccountIds } from '../config.js';
3
- import { getBotForAccount } from '../bot-instance.js';
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
- name: 'qqbot_platform_api',
84
- label: 'QQBot Platform API Gateway',
85
- description:
86
- 'QQ 开放平台统一 HTTP API 网关,自动填充鉴权 Token。' +
87
- '常用接口速查:' +
88
- '【频道】GET /users/@me/guilds | /guilds/{guild_id}/channels | /channels/{channel_id} | ' +
89
- '【群】GET /v2/groups/{group_id}/bot_state | /v2/groups/{group_id}/members/{member_id} | /v2/groups/{group_id}/info。' +
90
- '更多接口和参数详情请阅读 qqbot-channel 和 qqbot-group skill。',
91
- parameters: PlatformApiSchema,
92
- async execute(_toolCallId, params) {
93
- const p = params as {
94
- method: string;
95
- path: string;
96
- body?: Record<string, unknown>;
97
- query?: Record<string, string>;
98
- };
99
-
100
- if (!p.method) return json({ error: 'method 为必填参数' });
101
- if (!p.path) return json({ error: 'path 为必填参数' });
102
-
103
- const method = p.method.toUpperCase();
104
- if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
105
- return json({ error: `不支持的 HTTP 方法: ${method}` });
106
- }
107
-
108
- const pathError = validatePath(p.path);
109
- if (pathError) return json({ error: pathError });
110
-
111
- const accountId = getRequestAccountId();
112
- if (!accountId) {
113
- return json({ error: '无法获取当前请求的账号信息,此工具仅支持在消息会话中使用' });
114
- }
115
-
116
- try {
117
- const bot = getBotForAccount(accountId);
118
- const apiGateway = bot.api;
119
- const startedAt = Date.now();
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
- toolLog.info(`${method} ${p.path} ok (${Date.now() - startedAt}ms)`, { accountId });
141
- return json(data);
142
- } catch (err: unknown) {
143
- const errMsg = err instanceof Error ? err.message : String(err);
144
- const apiErr = err as { httpStatus?: number; bizCode?: number; path?: string };
145
- toolLog.warn(`${method} ${p.path} failed status=${apiErr.httpStatus ?? '?'} code=${apiErr.bizCode ?? '?'}: ${errMsg}`, { accountId });
146
- return json({
147
- error: errMsg,
148
- status: apiErr.httpStatus,
149
- code: apiErr.bizCode,
150
- path: p.path,
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
  );
@@ -1,5 +1,5 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { getRequestTarget, getRequestAccountId } from "../request-context.js";
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
- name: "qqbot_remind",
225
- label: "QQBot 定时提醒",
226
- description:
227
- "创建、查询、删除 QQ 定时提醒。" +
228
- "使用简单参数即可,无需手动构造 cron JSON。\n" +
229
- "创建提醒:action=add, content=提醒内容, to=目标地址, time=时间\n" +
230
- "查看提醒:action=list\n" +
231
- "删除提醒:action=remove, jobId=任务ID(先 list 获取)\n" +
232
- "时间格式示例:\"5m\"(5分钟后) \"1h\"(1小时后) \"0 8 * * *\"(每天8点)",
233
- parameters: RemindSchema,
234
- execute(_toolCallId, params) {
235
- const p = params as RemindParams;
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
- // ===== list =====
238
- if (p.action === "list") {
239
- return json({
240
- _instruction: "请立即使用 cron 工具执行以下参数",
241
- cronParams: { action: "list" },
242
- });
243
- }
241
+ // ===== list =====
242
+ if (p.action === "list") {
243
+ return json({
244
+ _instruction: "请立即使用 cron 工具执行以下参数",
245
+ cronParams: { action: "list" },
246
+ });
247
+ }
244
248
 
245
- // ===== remove =====
246
- if (p.action === "remove") {
247
- if (!p.jobId) {
248
- return json({ error: "action=remove 时 jobId 为必填参数。请先用 action=list 获取 jobId。" });
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
- // ===== add =====
257
- if (!p.content) {
258
- return json({ error: "action=add 时 content(提醒内容)为必填参数" });
259
- }
260
- // 优先使用 AI 传入的 to,否则自动从请求级上下文获取(AsyncLocalStorage)
261
- const resolvedTo = p.to || getRequestTarget();
262
- if (!resolvedTo) {
263
- return json({ error: "action=add 时无法确定投递目标地址。请稍后重试。" });
264
- }
265
- // 从请求上下文获取当前账户 ID(多账户场景),fallback 到 "default"
266
- const resolvedAccountId = getRequestAccountId() || "default";
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
  );