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/dist-web/assets/{index-By--XlP3.js → index-BEiPvgk7.js} +237 -235
- package/dist-web/assets/index-qY2UiOW2.css +32 -0
- package/dist-web/index.html +2 -2
- package/package.json +7 -1
- package/src/claude-prompt-detector.js +72 -0
- package/src/codex-hook-installer.js +1 -1
- package/src/codex-prompt-detector.js +104 -13
- package/src/config.js +33 -5
- package/src/db.js +53 -31
- package/src/lark-bot.js +44 -5
- package/src/mcp/tools/openclaw/index.js +1 -1
- package/src/openclaw-bridge.js +176 -28
- package/src/openclaw-hook-installer.js +2 -1
- package/src/openclaw-hook.js +127 -9
- package/src/openclaw-wizard.js +119 -24
- package/src/permission-prompt.js +113 -31
- package/src/pty.js +183 -49
- package/src/routes/ai-terminal.js +75 -26
- package/src/routes/telegram-sync.js +7 -5
- package/src/server.js +90 -12
- package/src/session-input-dispatcher.js +48 -4
- package/src/telegram-bot.js +82 -15
- package/src/telegram-loading-status.js +1 -1
- package/src/templates/claude-hooks/notify.js +1 -1
- package/src/templates/codex-hooks/notify.js +1 -1
- package/dist-web/assets/index-8A0oLLcX.css +0 -32
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
467
|
+
return adaptWizardResponseToLark({ toast: '⚠️ 服务未就绪', action: 'failed' })
|
|
432
468
|
}
|
|
433
469
|
try {
|
|
434
|
-
|
|
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 {
|
|
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.
|
|
318
|
+
const sendResult = await openclaw.broadcastText({
|
|
319
319
|
sessionId,
|
|
320
320
|
message: lines.join('\n'),
|
|
321
321
|
replyMarkup: askUserMarkup,
|
package/src/openclaw-bridge.js
CHANGED
|
@@ -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 →
|
|
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
|
-
|
|
193
|
+
const effectiveChannel = channel || getOpenClawConfig().channel || 'openclaw-weixin'
|
|
194
|
+
const route = {
|
|
174
195
|
targetUserId,
|
|
175
196
|
account: account || null,
|
|
176
|
-
channel:
|
|
177
|
-
threadId: threadId != null ? threadId : null,
|
|
197
|
+
channel: effectiveChannel,
|
|
198
|
+
threadId: threadId != null ? threadId : null,
|
|
178
199
|
rootMessageId: rootMessageId || null,
|
|
179
|
-
topicName: topicName || null,
|
|
180
|
-
triggerMessageId: triggerMessageId != null ? triggerMessageId : null,
|
|
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
|
-
|
|
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
|
|
198
|
-
if (
|
|
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 && !
|
|
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,
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
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,
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (
|
|
487
|
-
|
|
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
|
-
|
|
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: '*',
|