agentquad 0.4.5 → 0.4.7

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/src/config.js CHANGED
@@ -126,6 +126,7 @@ const DEFAULT_LARK_CONFIG = {
126
126
  requireThreadGroup: true,
127
127
  eventSubscribeEnabled: true,
128
128
  autoCreateTopic: true,
129
+ autoCreateTodo: true,
129
130
  defaultPermissionMode: "bypass",
130
131
  notificationCooldownMs: 600_000,
131
132
  };
@@ -313,6 +314,9 @@ function defaultConfig() {
313
314
  // 新建待办时是否默认勾选「创建后自动启动 AI 终端」。
314
315
  // Drawer 上的开关仍可单次覆盖;这里只是默认值。
315
316
  defaultAutoStartAi: false,
317
+ // 新建待办时默认套用的 Prompt 模板 ID 列表(多选)。空数组 = 不预选。
318
+ // 用户在 SettingsDrawer 里维护;创建任务时 TodoManage 读取此值作为表单初值。
319
+ defaultAppliedTemplateIds: [],
316
320
  // 自动启动 / dispatch / 顶栏 ⌘K 等场景下使用的默认 AI 工具。
317
321
  defaultAiTool: "claude",
318
322
  tools: resolveToolsConfig(),
@@ -381,6 +385,9 @@ export function normalizeConfig(cfg = {}) {
381
385
  ...cfgRest,
382
386
  defaultPermissionMode: normalizePermissionMode(cfg.defaultPermissionMode, "default"),
383
387
  defaultAutoStartAi: !!cfg.defaultAutoStartAi,
388
+ defaultAppliedTemplateIds: Array.isArray(cfg.defaultAppliedTemplateIds)
389
+ ? cfg.defaultAppliedTemplateIds.map((x) => String(x).trim()).filter(Boolean)
390
+ : [],
384
391
  defaultAiTool,
385
392
  tools: {
386
393
  ...mergedTools,
@@ -470,15 +477,38 @@ function backupCorruptConfig(file) {
470
477
  }
471
478
  }
472
479
 
480
+ // Atomic write: write to a sibling tmp file then rename over the target.
481
+ // POSIX rename is atomic on the same filesystem — readers always see either
482
+ // the old or the new file, never a half-written one. Eliminates the
483
+ // truncated-write → JSON.parse-fail → reset-to-defaults loop.
484
+ function atomicWriteFile(file, contents) {
485
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
486
+ writeFileSync(tmp, contents);
487
+ renameSync(tmp, file);
488
+ }
489
+
473
490
  function tryWriteConfig(file, cfg) {
474
491
  try {
475
- writeFileSync(file, JSON.stringify(cfg, null, 2));
492
+ atomicWriteFile(file, JSON.stringify(cfg, null, 2));
476
493
  return true;
477
494
  } catch {
478
495
  return false;
479
496
  }
480
497
  }
481
498
 
499
+ // In-process write serialization. Concurrent PUT /api/config calls (and any
500
+ // other server-side read-modify-write sequence) used to interleave their
501
+ // load/save and lose each other's changes. withConfigLock chains operations
502
+ // onto a single Promise queue so reads + writes within `fn` are atomic
503
+ // against other queued operations. Out-of-process writers (CLI, hooks) are
504
+ // NOT protected — see design doc R2/F4 deferred scope.
505
+ let configWriteQueue = Promise.resolve();
506
+ export function withConfigLock(fn) {
507
+ const run = configWriteQueue.then(() => fn(), () => fn());
508
+ configWriteQueue = run.catch(() => {});
509
+ return run;
510
+ }
511
+
482
512
  export function loadConfig({ rootDir = DEFAULT_ROOT_DIR } = {}) {
483
513
  ensureRoot(rootDir);
484
514
  const file = join(rootDir, "config.json");
@@ -488,9 +518,7 @@ export function loadConfig({ rootDir = DEFAULT_ROOT_DIR } = {}) {
488
518
  return cfg;
489
519
  }
490
520
  try {
491
- const cfg = normalizeConfig(JSON.parse(readFileSync(file, "utf8")));
492
- tryWriteConfig(file, cfg);
493
- return cfg;
521
+ return normalizeConfig(JSON.parse(readFileSync(file, "utf8")));
494
522
  } catch {
495
523
  backupCorruptConfig(file);
496
524
  const cfg = normalizeConfig();
@@ -501,7 +529,7 @@ export function loadConfig({ rootDir = DEFAULT_ROOT_DIR } = {}) {
501
529
 
502
530
  export function saveConfig(cfg, { rootDir = DEFAULT_ROOT_DIR } = {}) {
503
531
  ensureRoot(rootDir);
504
- writeFileSync(
532
+ atomicWriteFile(
505
533
  join(rootDir, "config.json"),
506
534
  JSON.stringify(normalizeConfig(cfg), null, 2),
507
535
  );
package/src/db.js CHANGED
@@ -1110,37 +1110,59 @@ export function openDb(file = ':memory:') {
1110
1110
  return r.changes
1111
1111
  }
1112
1112
 
1113
- function seedBuiltinTemplatesIfEmpty() {
1114
- if (ptStmts.countAll.get().n > 0) return
1113
+ // Canonical list of builtin templates. On startup we ensure each one exists
1114
+ // in the DB (matched by exact name + builtin=1). Missing ones get inserted;
1115
+ // user-edited copies are left alone. Adding a new entry here will surface
1116
+ // for existing users on next restart.
1117
+ const BUILTIN_TEMPLATE_SEEDS = [
1118
+ {
1119
+ name: 'Brainstorm(脑爆)',
1120
+ description: '先脑爆方向,不急着动手',
1121
+ content: '请先不要直接动手实现。先针对下面的任务 brainstorm:\n- 列出 2-3 种可选方案,说明优缺点\n- 指出风险点与需要用户拍板的关键决策\n- 明确验收标准\n\n在我确认方案后再进入实现。',
1122
+ },
1123
+ {
1124
+ name: '自动驾驶(Autopilot)',
1125
+ description: '内心脑爆 → 自选最优 → 跑完 → 最后报告',
1126
+ content: '按"自动驾驶"模式处理下面的任务,不要停下来问我。\n\n1. 先在心里 brainstorm 2-3 种实现思路,挑出最合理的一种,但不需要列出来征求我的同意。\n2. 直接执行:理解 → 实现 → 自测(跑相关测试 / 编译 / lint) → 提交。\n3. 遇到不可避免必须我决策的歧义点(例:要不要删数据、要不要对外发版),才停下来问;普通选型不要问。\n4. 跑完后输出一份三段式报告:\n - 变更摘要:改了什么、为什么这么改\n - 验证结果:跑了哪些自测、是否通过、有没有遗留\n - 仍需我确认的事项:列出来 / 没有就写"无"\n5. 我考虑过哪些方案、为什么选这个,请在"变更摘要"里用一两句说明。',
1127
+ },
1128
+ {
1129
+ name: '稳定优先(Stability First)',
1130
+ description: '改动面最小、不引新依赖、不动公共 API',
1131
+ content: '本任务执行时请优先保证"稳定",含义:\n- 改动面尽量小,不顺手重构、不删看似无用的代码\n- 不引入新依赖、不升级现有依赖\n- 不改公共 API / 接口签名 / 数据库 schema(除非任务本身就是改这个)\n- 优先补测试覆盖现有行为;改动 hot path 时尽量保留旧路径作为兜底\n- 选型偏保守:用已经在项目里用过的库 / 写法\n如果"稳定"和任务目标冲突,告诉我,让我决定。',
1132
+ },
1133
+ {
1134
+ name: '未来发展优先(Future First)',
1135
+ description: '允许重构 / 引入抽象 / 留扩展点',
1136
+ content: '本任务执行时请优先考虑"长期可扩展性",含义:\n- 允许并鼓励顺手重构邻近代码,让结构更清晰\n- 可以引入新抽象 / 接口,为可预见的下一步需求留扩展点\n- 公共 API / 类型 / 数据结构允许调整,但要在报告里列出影响面(调用方 / 测试 / 文档)\n- 选型可以挑当前社区主流而非项目已有的旧写法,但要说明替换原因\n- 不要为完全假想的需求过度设计 —— 只服务"已经看到苗头"的下一步\n完成后在报告里说明:哪些是为长期留的扩展点,分别服务什么场景。',
1137
+ },
1138
+ {
1139
+ name: 'Bug 修复',
1140
+ description: '复现 → 定位 → 最小用例 → 修复 → 回归',
1141
+ content: '按 bug 修复流程处理下面的问题:\n1. 先复现(给出复现步骤和实际 vs 预期)\n2. 定位根因(不要过早修改代码)\n3. 写一个能复现该 bug 的最小用例(如果有测试框架)\n4. 修复根因,不是修现象\n5. 回归:跑相关测试;考虑同类 bug 是否还存在',
1142
+ },
1143
+ {
1144
+ name: '重构',
1145
+ description: '先读懂 → 列出影响面 → 小步重构',
1146
+ content: '按照小步重构原则处理下面的任务:\n1. 先通读相关代码,复述你的理解\n2. 列出此次重构的影响面(调用方 / 测试 / 类型)\n3. 每一步只改一件事,保持可运行\n4. 每步后跑一次测试(如果有)\n5. 不要顺手加功能、不要引入新抽象,除非当前任务要求',
1147
+ },
1148
+ {
1149
+ name: '写测试',
1150
+ description: 'TDD:红 → 绿 → 重构',
1151
+ content: '用 TDD 的方式处理下面的任务:\n1. 先列出测试矩阵(输入 × 场景)\n2. 先写一个最简失败用例(红)\n3. 用最小改动让它通过(绿)\n4. 重构(保持绿)\n5. 重复 2-4 直到覆盖矩阵\n不 mock 真实依赖(除非跨网络/支付等)。',
1152
+ },
1153
+ {
1154
+ name: '代码评审',
1155
+ description: '只评审,不改代码',
1156
+ content: '请只做代码评审,不要修改代码。按下面的维度给出具体反馈:\n- 可读性:命名、结构、注释\n- 正确性:边界、错误处理、并发\n- 安全性:注入、鉴权、敏感数据\n- 性能:明显的 N+1 / 无谓复制\n- 简洁性:是否有过度设计 / 可删除的冗余\n每条反馈给出文件:行号 + 建议。',
1157
+ },
1158
+ ]
1159
+ const findBuiltinByName = db.prepare(
1160
+ `SELECT id FROM prompt_templates WHERE builtin = 1 AND name = ? LIMIT 1`,
1161
+ )
1162
+ function ensureBuiltinTemplates() {
1115
1163
  const now = Date.now()
1116
- const seeds = [
1117
- {
1118
- name: 'Brainstorm(脑爆)',
1119
- description: '先脑爆方向,不急着动手',
1120
- content: '请先不要直接动手实现。先针对下面的任务 brainstorm:\n- 列出 2-3 种可选方案,说明优缺点\n- 指出风险点与需要用户拍板的关键决策\n- 明确验收标准\n\n在我确认方案后再进入实现。',
1121
- },
1122
- {
1123
- name: 'Bug 修复',
1124
- description: '复现 → 定位 → 最小用例 → 修复 → 回归',
1125
- content: '按 bug 修复流程处理下面的问题:\n1. 先复现(给出复现步骤和实际 vs 预期)\n2. 定位根因(不要过早修改代码)\n3. 写一个能复现该 bug 的最小用例(如果有测试框架)\n4. 修复根因,不是修现象\n5. 回归:跑相关测试;考虑同类 bug 是否还存在',
1126
- },
1127
- {
1128
- name: '重构',
1129
- description: '先读懂 → 列出影响面 → 小步重构',
1130
- content: '按照小步重构原则处理下面的任务:\n1. 先通读相关代码,复述你的理解\n2. 列出此次重构的影响面(调用方 / 测试 / 类型)\n3. 每一步只改一件事,保持可运行\n4. 每步后跑一次测试(如果有)\n5. 不要顺手加功能、不要引入新抽象,除非当前任务要求',
1131
- },
1132
- {
1133
- name: '写测试',
1134
- description: 'TDD:红 → 绿 → 重构',
1135
- content: '用 TDD 的方式处理下面的任务:\n1. 先列出测试矩阵(输入 × 场景)\n2. 先写一个最简失败用例(红)\n3. 用最小改动让它通过(绿)\n4. 重构(保持绿)\n5. 重复 2-4 直到覆盖矩阵\n不 mock 真实依赖(除非跨网络/支付等)。',
1136
- },
1137
- {
1138
- name: '代码评审',
1139
- description: '只评审,不改代码',
1140
- content: '请只做代码评审,不要修改代码。按下面的维度给出具体反馈:\n- 可读性:命名、结构、注释\n- 正确性:边界、错误处理、并发\n- 安全性:注入、鉴权、敏感数据\n- 性能:明显的 N+1 / 无谓复制\n- 简洁性:是否有过度设计 / 可删除的冗余\n每条反馈给出文件:行号 + 建议。',
1141
- },
1142
- ]
1143
- seeds.forEach((s, i) => {
1164
+ BUILTIN_TEMPLATE_SEEDS.forEach((s, i) => {
1165
+ if (findBuiltinByName.get(s.name)) return
1144
1166
  ptStmts.insert.run({
1145
1167
  id: randomUUID(),
1146
1168
  name: s.name,
@@ -1153,7 +1175,7 @@ export function openDb(file = ':memory:') {
1153
1175
  })
1154
1176
  })
1155
1177
  }
1156
- seedBuiltinTemplatesIfEmpty()
1178
+ ensureBuiltinTemplates()
1157
1179
 
1158
1180
  const wikiStmts = {
1159
1181
  insertRun: db.prepare(`
package/src/lark-bot.js CHANGED
@@ -126,6 +126,38 @@ export function normalizeEvent(raw = {}) {
126
126
  * action.value 是按钮的 value(构卡片时塞的 JSON),约定字段:
127
127
  * { callback_data: 'qt:perm:abcd:allow' } // 跟 telegram 的 callback_data 同字符串格式
128
128
  */
129
+ /**
130
+ * 把 wizard 风格的 callback 返回值({toast: '字符串', chosenLabel, action, editOriginal})
131
+ * 适配成 Lark 卡片回调期望的 schema({toast: {type, content}})。
132
+ *
133
+ * 背景:Lark SDK 的 WSClient.handleEventData 把 handler 的返回值 base64 编码后回写到
134
+ * WebSocket frame;Lark 服务端反序列化时按文档校验 toast 必须是 `{type, content}` 对象,
135
+ * wizard 直传 string 会失败 → 飞书 UI 弹「出错了,请稍后重试 code: 200340」。
136
+ * 文档:https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/feishu-cards/card-callback/card-callback-communication
137
+ *
138
+ * 已经是 Lark 格式的({toast: {type, content}})直接放过;undefined → 不显 toast。
139
+ */
140
+ export function adaptWizardResponseToLark(r) {
141
+ if (!r || typeof r !== 'object') return undefined
142
+
143
+ // 已是 Lark 形态({toast: {type?, content}}) → 透传,保留 caller 想自己控的 type
144
+ if (r.toast && typeof r.toast === 'object' && (typeof r.toast.content === 'string' || r.toast.i18n)) {
145
+ const out = { toast: { ...r.toast } }
146
+ if (!out.toast.type) out.toast.type = 'info'
147
+ return out
148
+ }
149
+
150
+ // wizard 形态(toast: string)→ 按 action 字段推断 type
151
+ const content = typeof r.toast === 'string' ? r.toast.trim() : ''
152
+ if (!content) return undefined // 没有 toast 文本就别 emit,Lark UI 默认无提示
153
+ let type = 'info'
154
+ const action = String(r.action || '')
155
+ if (action.includes('allow') || action.includes('sent')) type = 'success'
156
+ else if (action.includes('stale') || action.includes('invalid')) type = 'warning'
157
+ else if (action.includes('failed') || action.includes('error') || action.includes('unavailable')) type = 'error'
158
+ return { toast: { type, content } }
159
+ }
160
+
129
161
  export function normalizeCardAction(raw = {}) {
130
162
  const event = raw.event || raw
131
163
  const action = event.action || {}
@@ -418,20 +450,24 @@ export function createLarkBot({
418
450
 
419
451
  async function handleCardAction(raw) {
420
452
  const ev = normalizeCardAction(raw)
453
+ logger.info?.(`[lark-bot] card action received: callbackData=${ev.callbackData || 'null'} chatId=${ev.chatId || 'null'} from=${ev.fromUserId || 'null'}`)
421
454
  if (!ev.chatId || !ev.callbackData) {
422
- return { ok: false, reason: 'invalid_card_action' }
455
+ const r = adaptWizardResponseToLark({ toast: '⚠️ 无效的卡片回传', action: 'invalid' })
456
+ logger.info?.(`[lark-bot] card action → Lark resp: ${JSON.stringify(r)}`)
457
+ return r
423
458
  }
424
459
  const configuredChatId = getConfig()?.lark?.chatId
425
460
  if (configuredChatId && ev.chatId !== String(configuredChatId)) {
426
461
  logger.warn?.(`[lark-bot] ignored card_action from other chat: ${ev.chatId}`)
427
- return { ok: true, action: 'ignored_chat' }
462
+ // 跨群点的卡片:返回最小合法响应(带个 info toast),避免 Lark UI 弹 200340 generic 错误
463
+ return { toast: { type: 'info', content: '已忽略(非本群)' } }
428
464
  }
429
465
  if (typeof wizard.handleCallback !== 'function') {
430
466
  logger.warn?.(`[lark-bot] wizard.handleCallback unavailable; dropping lark card action`)
431
- return { ok: false, reason: 'no_handler' }
467
+ return adaptWizardResponseToLark({ toast: '⚠️ 服务未就绪', action: 'failed' })
432
468
  }
433
469
  try {
434
- return await wizard.handleCallback({
470
+ const wizardR = await wizard.handleCallback({
435
471
  channel: 'lark',
436
472
  chatId: ev.chatId,
437
473
  threadId: ev.threadId,
@@ -439,9 +475,12 @@ export function createLarkBot({
439
475
  callbackData: ev.callbackData,
440
476
  fromUserId: ev.fromUserId,
441
477
  })
478
+ const adapted = adaptWizardResponseToLark(wizardR)
479
+ logger.info?.(`[lark-bot] card action → wizard returned ${JSON.stringify(wizardR)} → Lark resp: ${JSON.stringify(adapted)}`)
480
+ return adapted
442
481
  } catch (e) {
443
482
  logger.warn?.(`[lark-bot] card action handler failed: ${e.message}`)
444
- return { ok: false, reason: 'handler_failed', detail: e.message }
483
+ return adaptWizardResponseToLark({ toast: `⚠️ 处理失败:${e.message}`, action: 'failed' })
445
484
  }
446
485
  }
447
486
 
@@ -315,7 +315,7 @@ export function registerOpenClawTools(server, deps) {
315
315
  // 拼按钮失败不阻塞;纯文本兜底(用户照样能数字回复)
316
316
  }
317
317
 
318
- const sendResult = await openclaw.postText({
318
+ const sendResult = await openclaw.broadcastText({
319
319
  sessionId,
320
320
  message: lines.join('\n'),
321
321
  replyMarkup: askUserMarkup,
@@ -132,6 +132,7 @@ export function createOpenClawBridge({
132
132
  telegramSender = sendViaTelegramAPI, // 测试用:可 mock fake fetch
133
133
  telegramBot: initialTelegramBot = null, // 可选:用于 sendDocument 附件
134
134
  larkBot: initialLarkBot = null,
135
+ getRoutesForSession = null, // 注入:读 db 双路由(telegram + lark),broadcastEcho 用
135
136
  } = {}) {
136
137
  let telegramBot = initialTelegramBot
137
138
  let larkBot = initialLarkBot
@@ -140,7 +141,8 @@ export function createOpenClawBridge({
140
141
 
141
142
  // 出站限流环形缓冲:每分钟 ≤ rateLimitPerMin 条
142
143
  const sendTimestamps = []
143
- // sessionId → { targetUserId, account, channel }
144
+ // sessionId → Map<channel, route> —— 一个 session 可同时绑 telegram + lark + weixin
145
+ // Map 保留插入顺序,"最新注册的那条"可以靠遍历最后一个值拿到,用于无 channel hint 的 fallback。
144
146
  const sessionRoutes = new Map()
145
147
  // peerUserId → { sessionId, sentAt } — 最近一次推到该 peer 的 session
146
148
  // 用于 PTY stdin proxy:用户在微信回话时知道往哪个 PTY 写
@@ -168,18 +170,38 @@ export function createOpenClawBridge({
168
170
  sendTimestamps.push(nowMs())
169
171
  }
170
172
 
173
+ function getRoutesInner(sessionId) {
174
+ return sessionRoutes.get(sessionId) || null
175
+ }
176
+
177
+ function ensureRoutesInner(sessionId) {
178
+ let inner = sessionRoutes.get(sessionId)
179
+ if (!inner) {
180
+ inner = new Map()
181
+ sessionRoutes.set(sessionId, inner)
182
+ }
183
+ return inner
184
+ }
185
+
186
+ function allRoutesForSession(sessionId) {
187
+ const inner = getRoutesInner(sessionId)
188
+ return inner ? Array.from(inner.values()) : []
189
+ }
190
+
171
191
  function registerSessionRoute(sessionId, { targetUserId, account, channel, threadId, rootMessageId, topicName, triggerMessageId, messageAppLink } = {}) {
172
192
  if (!sessionId || !targetUserId) return
173
- sessionRoutes.set(sessionId, {
193
+ const effectiveChannel = channel || getOpenClawConfig().channel || 'openclaw-weixin'
194
+ const route = {
174
195
  targetUserId,
175
196
  account: account || null,
176
- channel: channel || getOpenClawConfig().channel || 'openclaw-weixin',
177
- threadId: threadId != null ? threadId : null, // ← Telegram Topic 路由用
197
+ channel: effectiveChannel,
198
+ threadId: threadId != null ? threadId : null,
178
199
  rootMessageId: rootMessageId || null,
179
- topicName: topicName || null, // ← SessionEnd 改名 ✅ 用
180
- triggerMessageId: triggerMessageId != null ? triggerMessageId : null, // D 方案:reaction 加在用户触发消息上
200
+ topicName: topicName || null,
201
+ triggerMessageId: triggerMessageId != null ? triggerMessageId : null,
181
202
  messageAppLink: messageAppLink || null,
182
- })
203
+ }
204
+ ensureRoutesInner(sessionId).set(effectiveChannel, route)
183
205
  }
184
206
 
185
207
  function clearSessionRoute(sessionId, reason = 'unknown') {
@@ -190,12 +212,20 @@ export function createOpenClawBridge({
190
212
  }
191
213
 
192
214
  function hasExplicitRoute(sessionId) {
193
- return Boolean(sessionId && sessionRoutes.has(sessionId))
215
+ if (!sessionId) return false
216
+ const inner = sessionRoutes.get(sessionId)
217
+ return Boolean(inner && inner.size > 0)
194
218
  }
195
219
 
196
- function resolveRoute(sessionId) {
197
- const explicit = sessionRoutes.get(sessionId)
198
- if (explicit) return explicit
220
+ function resolveRoute(sessionId, channel = null) {
221
+ const inner = getRoutesInner(sessionId)
222
+ if (inner && inner.size > 0) {
223
+ if (channel) return inner.get(channel) || null
224
+ // 无 channel hint:返回最新注册的(Map 保留插入顺序,最后一个 value 是 latest)
225
+ let last = null
226
+ for (const r of inner.values()) last = r
227
+ return last
228
+ }
199
229
  const oc = getOpenClawConfig()
200
230
  if (!oc.targetUserId) return null
201
231
  return {
@@ -219,7 +249,7 @@ export function createOpenClawBridge({
219
249
  if (!rateLimitOk()) return { ok: false, reason: 'rate_limited' }
220
250
 
221
251
  const oc = getOpenClawConfig()
222
- const route = sessionId ? resolveRoute(sessionId) : null
252
+ const route = sessionId ? resolveRoute(sessionId, channel || null) : null
223
253
  const effectiveChannel = channel || route?.channel || oc.channel || 'openclaw-weixin'
224
254
  const rawTarget = target || route?.targetUserId || oc.targetUserId
225
255
  const effectiveTarget = normalizeTarget(rawTarget, effectiveChannel)
@@ -269,8 +299,8 @@ export function createOpenClawBridge({
269
299
  const threadIdForSend = route?.threadId || null
270
300
  // 防御兜底:sessionId-routed 但没拿到 thread → fallback 路径,不能静默落 General。
271
301
  // 仅当 caller 传了 sessionId 时启用(无 sessionId 是显式 broadcast,允许直发默认 chat)。
272
- if (!threadIdForSend && sessionId && !sessionRoutes.has(sessionId)) {
273
- logger.warn?.(`[openclaw-bridge] refuse send to telegram general: sid=${sessionId} has no registered route (routesSize=${sessionRoutes.size}); would have leaked to General. msgLen=${message.length}`)
302
+ if (!threadIdForSend && sessionId && !hasExplicitRoute(sessionId)) {
303
+ logger.warn?.(`[openclaw-bridge] refuse send to telegram general: sid=${sessionId} has no registered route (routesSize=${sessionRoutes.size}); would have leaked to General. msgLen=${message.length}`) // sessionRoutes.size = outer Map size (number of sessions)
274
304
  return { ok: false, reason: 'no_thread_id_route_missing' }
275
305
  }
276
306
  logger.info?.(`[openclaw-bridge] telegram send sessionId=${sessionId} chatId=${effectiveTarget} threadId=${threadIdForSend} (route=${route ? JSON.stringify({tid: route.threadId, tn: route.topicName}) : 'null'}) attachment=${attachment ? 'yes' : 'no'} msgLen=${message.length}`)
@@ -445,12 +475,16 @@ export function createOpenClawBridge({
445
475
  function findSessionsByTarget(peer) {
446
476
  if (!peer) return []
447
477
  const out = []
448
- for (const [sid, info] of sessionRoutes) {
449
- // 同一个 peer 可能有多个 session;route 里 targetUserId 可能带或不带后缀
450
- const tgt = info?.targetUserId || ''
451
- if (tgt === peer || tgt.startsWith(peer + '@') || peer.startsWith(tgt + '@')) {
452
- out.push(sid)
478
+ for (const [sid, inner] of sessionRoutes) {
479
+ let matched = false
480
+ for (const info of inner.values()) {
481
+ const tgt = info?.targetUserId || ''
482
+ if (tgt === peer || tgt.startsWith(peer + '@') || peer.startsWith(tgt + '@')) {
483
+ matched = true
484
+ break
485
+ }
453
486
  }
487
+ if (matched) out.push(sid)
454
488
  }
455
489
  return out
456
490
  }
@@ -479,15 +513,17 @@ export function createOpenClawBridge({
479
513
  function findSessionByRoute({ channel = null, chatId, threadId = null, rootMessageId = null } = {}) {
480
514
  if (!chatId) return null
481
515
  const targetStr = String(chatId)
482
- for (const [sid, info] of sessionRoutes) {
483
- if (channel && info?.channel !== channel) continue
484
- if (String(info?.targetUserId || '') !== targetStr) continue
485
- if (rootMessageId) {
486
- if (info?.rootMessageId === rootMessageId) return sid
487
- continue
516
+ for (const [sid, inner] of sessionRoutes) {
517
+ for (const info of inner.values()) {
518
+ if (channel && info?.channel !== channel) continue
519
+ if (String(info?.targetUserId || '') !== targetStr) continue
520
+ if (rootMessageId) {
521
+ if (info?.rootMessageId === rootMessageId) return sid
522
+ continue
523
+ }
524
+ if ((info?.threadId || null) !== (threadId || null)) continue
525
+ return sid
488
526
  }
489
- if ((info?.threadId || null) !== (threadId || null)) continue
490
- return sid
491
527
  }
492
528
  return null
493
529
  }
@@ -535,15 +571,127 @@ export function createOpenClawBridge({
535
571
  }
536
572
  }
537
573
 
574
+ /**
575
+ * 把 user prompt echo 到所有已绑定的 IM thread,排除 origin channel。
576
+ * 路由从注入的 getRoutesForSession 拿(读 db 双路由),不依赖 in-memory sessionRoutes
577
+ * (后者每 session 只有一条 route,无法跨 telegram + lark 同时发)。
578
+ *
579
+ * 失败一律静默 warn —— echo 是辅助路径,不能影响 agent 的 Stop hook 主流程。
580
+ */
581
+ async function broadcastEcho({ sessionId, message, excludeChannel } = {}) {
582
+ if (!sessionId || !message) return { skipped: true, reason: 'missing_args' }
583
+ if (typeof getRoutesForSession !== 'function') return { skipped: true, reason: 'no_routes_fn' }
584
+ if (!rateLimitOk()) return { skipped: true, reason: 'rate_limited' }
585
+
586
+ let routes
587
+ try {
588
+ routes = getRoutesForSession(sessionId) || {}
589
+ } catch (e) {
590
+ logger?.warn?.(`[openclaw-bridge] broadcastEcho getRoutesForSession threw: ${e.message}`)
591
+ return { skipped: true, reason: 'routes_lookup_failed' }
592
+ }
593
+ const { telegram: tg, lark: lk } = routes
594
+ const results = { telegram: null, lark: null }
595
+
596
+ if (excludeChannel === 'telegram') {
597
+ results.telegram = { skipped: true, reason: 'excluded' }
598
+ } else if (!tg?.threadId || !tg?.targetUserId) {
599
+ results.telegram = { skipped: true, reason: 'no_route' }
600
+ } else {
601
+ const token = getTelegramTokenFromConfig(getConfig())
602
+ if (!token) {
603
+ results.telegram = { ok: false, reason: 'no_token' }
604
+ } else {
605
+ try {
606
+ results.telegram = await telegramSender({
607
+ token,
608
+ chatId: String(tg.targetUserId),
609
+ threadId: Number(tg.threadId),
610
+ text: message,
611
+ logger,
612
+ })
613
+ if (results.telegram?.ok) recordSend()
614
+ } catch (e) {
615
+ logger?.warn?.(`[openclaw-bridge] broadcastEcho telegram threw: ${e.message}`)
616
+ results.telegram = { ok: false, reason: 'threw', detail: e.message }
617
+ }
618
+ }
619
+ }
620
+
621
+ if (excludeChannel === 'lark') {
622
+ results.lark = { skipped: true, reason: 'excluded' }
623
+ } else if (!lk?.rootMessageId || !larkBot?.replyInThread) {
624
+ results.lark = { skipped: true, reason: 'no_route' }
625
+ } else {
626
+ try {
627
+ results.lark = await larkBot.replyInThread({
628
+ rootMessageId: String(lk.rootMessageId),
629
+ text: message,
630
+ })
631
+ if (results.lark?.ok) recordSend()
632
+ } catch (e) {
633
+ logger?.warn?.(`[openclaw-bridge] broadcastEcho lark threw: ${e.message}`)
634
+ results.lark = { ok: false, reason: 'threw', detail: e.message }
635
+ }
636
+ }
637
+
638
+ return results
639
+ }
640
+
641
+ /**
642
+ * Fan-out 文本到 sessionId 当前所有绑定的 channel。Stop hook / ask_user / dispatcher 警告类
643
+ * 消息走这里,确保 telegram + lark 都能看到(cross-channel mirror 对齐)。
644
+ *
645
+ * 实现:对每个绑定的 channel 调用一次 postText(显式带 channel),复用现有的限流、
646
+ * fast-path、CLI fallback。返回 byChannel 聚合结果。
647
+ */
648
+ async function broadcastText({ sessionId, message, replyMarkup = null, attachment = null, excludeChannel = null } = {}) {
649
+ if (!sessionId || !message) return { skipped: true, reason: 'missing_args' }
650
+ const routes = allRoutesForSession(sessionId)
651
+ if (!routes.length) {
652
+ // session 没有任何 in-memory 路由 → 退回单 postText(用 config 默认 target)。
653
+ // 行为对齐改造前的 postText({sessionId, message}) 老语义。
654
+ const r = await postText({ sessionId, message, replyMarkup, attachment })
655
+ return { ok: !!r?.ok, byChannel: { default: r }, fanout: false }
656
+ }
657
+ const byChannel = {}
658
+ let anyOk = false
659
+ for (const route of routes) {
660
+ if (route.channel === excludeChannel) {
661
+ byChannel[route.channel] = { skipped: true, reason: 'excluded' }
662
+ continue
663
+ }
664
+ const r = await postText({
665
+ sessionId,
666
+ message,
667
+ channel: route.channel,
668
+ target: route.targetUserId,
669
+ replyMarkup,
670
+ attachment,
671
+ })
672
+ byChannel[route.channel] = r
673
+ if (r?.ok) anyOk = true
674
+ }
675
+ return { ok: anyOk, byChannel, fanout: true }
676
+ }
677
+
538
678
  function setTelegramBot(bot) { telegramBot = bot }
539
679
  function setLarkBot(bot) { larkBot = bot || null }
540
680
  function setTopicGoneHandler(fn) { topicGoneHandler = typeof fn === 'function' ? fn : null }
541
681
  function listSessionRoutes() {
542
- return Array.from(sessionRoutes.entries()).map(([sessionId, info]) => ({ sessionId, ...info }))
682
+ const out = []
683
+ for (const [sid, inner] of sessionRoutes) {
684
+ for (const route of inner.values()) {
685
+ out.push({ sessionId: sid, ...route })
686
+ }
687
+ }
688
+ return out
543
689
  }
544
690
 
545
691
  return {
546
692
  postText,
693
+ broadcastText,
694
+ broadcastEcho,
547
695
  healthCheck,
548
696
  isEnabled,
549
697
  registerSessionRoute,
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url'
23
23
  import { DEFAULT_ROOT_DIR } from './config.js'
24
24
 
25
25
  const QUADTODO_MANAGED_KEY = '_quadtodoManaged'
26
- const HOOK_EVENTS = ['Stop', 'Notification', 'SessionEnd']
26
+ const HOOK_EVENTS = ['Stop', 'Notification', 'SessionEnd', 'UserPromptSubmit']
27
27
  const HOOK_VERSION_RE = /quadtodo-hook-version:\s*(\d+)/
28
28
 
29
29
  function defaultHookScriptPath() {
@@ -52,6 +52,7 @@ function buildHookEntry(event, hookScriptPath) {
52
52
  // Claude Code hook 格式(参考其文档):matchers 数组里每项有 type+command
53
53
  const eventLower = event === 'SessionEnd' ? 'session-end'
54
54
  : event === 'Notification' ? 'notification'
55
+ : event === 'UserPromptSubmit' ? 'user-prompt-submit'
55
56
  : 'stop'
56
57
  return {
57
58
  matcher: '*',