agentquad 0.3.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/LICENSE +21 -0
- package/README.md +318 -0
- package/dist-web/assets/index-CMaXwixo.js +1234 -0
- package/dist-web/assets/index-DBHApzV1.css +32 -0
- package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
- package/dist-web/assets/logo-D4DDtU-r.png +0 -0
- package/dist-web/favicon.png +0 -0
- package/dist-web/index.html +14 -0
- package/package.json +88 -0
- package/src/ask-user-buttons.js +142 -0
- package/src/claude-transcript.js +203 -0
- package/src/cli.js +1040 -0
- package/src/codex-event-emitter.js +111 -0
- package/src/codex-prompt-detector.js +53 -0
- package/src/codex-sidecar.js +52 -0
- package/src/codex-transcript.js +74 -0
- package/src/config.js +692 -0
- package/src/data/claude-code-commands.json +52 -0
- package/src/db.js +1503 -0
- package/src/dispatch.js +13 -0
- package/src/export/todoMarkdown.js +246 -0
- package/src/first-run-wizard.js +82 -0
- package/src/git/gitStatus.js +139 -0
- package/src/lark-api-client.js +205 -0
- package/src/lark-bot.js +510 -0
- package/src/lark-card.js +88 -0
- package/src/lark-config-service.js +16 -0
- package/src/lark-event-client.js +107 -0
- package/src/lark-image.js +99 -0
- package/src/lark-markdown.js +51 -0
- package/src/lark-video.js +163 -0
- package/src/mcp/audit.js +34 -0
- package/src/mcp/server.js +83 -0
- package/src/mcp/tools/destructive/index.js +252 -0
- package/src/mcp/tools/openclaw/index.js +405 -0
- package/src/mcp/tools/read/index.js +269 -0
- package/src/mcp/tools/write/index.js +157 -0
- package/src/openclaw-bridge.js +566 -0
- package/src/openclaw-hook-installer.js +338 -0
- package/src/openclaw-hook.js +908 -0
- package/src/openclaw-wizard.js +2442 -0
- package/src/pending-questions.js +297 -0
- package/src/pricing.js +45 -0
- package/src/prompt-render.js +36 -0
- package/src/pty.js +992 -0
- package/src/routes/ai-terminal.js +1228 -0
- package/src/routes/git.js +89 -0
- package/src/routes/openclaw-hook.js +67 -0
- package/src/routes/openclaw-inbound.js +36 -0
- package/src/routes/recurringRules.js +80 -0
- package/src/routes/reports.js +50 -0
- package/src/routes/search.js +46 -0
- package/src/routes/stats.js +31 -0
- package/src/routes/telegram-config.js +152 -0
- package/src/routes/telegram-sync.js +221 -0
- package/src/routes/templates.js +63 -0
- package/src/routes/todos.js +649 -0
- package/src/routes/transcripts.js +75 -0
- package/src/routes/uploads.js +107 -0
- package/src/routes/wiki.js +142 -0
- package/src/search/fts.js +209 -0
- package/src/search/index.js +199 -0
- package/src/search/transcripts.js +148 -0
- package/src/server.js +1791 -0
- package/src/session-input-dispatcher.js +256 -0
- package/src/stats/markdown.js +42 -0
- package/src/stats/report.js +207 -0
- package/src/summarize.js +84 -0
- package/src/system-rules.js +52 -0
- package/src/telegram-bot.js +875 -0
- package/src/telegram-commands.js +149 -0
- package/src/telegram-config-service.js +84 -0
- package/src/telegram-image.js +95 -0
- package/src/telegram-loading-status.js +112 -0
- package/src/telegram-markdown.js +82 -0
- package/src/telegram-reaction-tracker.js +69 -0
- package/src/telegram-video.js +75 -0
- package/src/templates/claude-hooks/notify.js +103 -0
- package/src/transcript.js +305 -0
- package/src/transcripts/blocks.js +56 -0
- package/src/transcripts/index.js +222 -0
- package/src/transcripts/indexer.js +34 -0
- package/src/transcripts/matcher.js +70 -0
- package/src/transcripts/scanner.js +259 -0
- package/src/usage-footer.js +170 -0
- package/src/usage-parser.js +132 -0
- package/src/wiki/guide.js +44 -0
- package/src/wiki/index.js +232 -0
- package/src/wiki/redact.js +34 -0
- package/src/wiki/sources.js +122 -0
|
@@ -0,0 +1,2442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw 向导状态机:把"在微信里多轮创建 AgentQuad 任务"的所有决策从
|
|
3
|
+
* OpenClaw agent 搬到 AgentQuad 内部,OpenClaw 只做消息转发。
|
|
4
|
+
*
|
|
5
|
+
* 设计目标:
|
|
6
|
+
* - 一条 inbound 消息 → 一个完整的判断 → 一个 reply 字符串
|
|
7
|
+
* - 状态机存内存(重启丢失也无所谓,向导本来就是短生命周期)
|
|
8
|
+
* - 一句话直说能跳过任意向导步骤("目录 X" / "象限 N" / "模板 Y")
|
|
9
|
+
* - 与 ask_user pending 共存:wizard 优先,wizard 完成后再吃 ask_user 答复
|
|
10
|
+
*
|
|
11
|
+
* 路由优先级(handleInbound 内):
|
|
12
|
+
* 1. 取消语 ("取消" / "cancel") + 进行中 wizard → 中止向导
|
|
13
|
+
* 2. 当前 peer 有进行中 wizard → 推进向导(消费数字 / 文本)
|
|
14
|
+
* 3. 当前 peer 有 pending ask_user → 调 pending.submitReply
|
|
15
|
+
* 4. text 看起来像新任务 → 启动新向导
|
|
16
|
+
* 5. 其它 → 友好 fallback
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const NEW_TASK_TRIGGERS = [
|
|
20
|
+
/^(在\s*(?:agentquad|quadtodo)\s*[里中])?\s*(新建|开个|开一?个|创建)\s*[任务todo]/i,
|
|
21
|
+
/^(帮我|帮忙)?\s*(做|搞|修|搞定|实现|写一?个|做一?个|修复|重构|调试|debug|加|开发)/i,
|
|
22
|
+
/^新?任务[::]/,
|
|
23
|
+
]
|
|
24
|
+
const CANCEL_TRIGGERS = [/^取消$/, /^算了$/, /^不做了$/, /^cancel$/i, /^abort$/i]
|
|
25
|
+
// 退出 PTY 直连模式:清掉这个 peer 的 lastPushedSession,下次发的话不再被路由到 PTY
|
|
26
|
+
const DETACH_TRIGGERS = [/^退出$/, /^离开$/, /^断开$/, /^detach$/i, /^exit$/i, /^quit$/i, /^bye$/i]
|
|
27
|
+
// Claude Code 的 interactive modal 命令 —— Telegram 没法发 Esc 键退出 modal,
|
|
28
|
+
// 直接转发会让用户卡在 modal 里。命中 → 拦截不转发,引导用户去 web 终端。
|
|
29
|
+
const INTERACTIVE_SLASH_COMMANDS = new Set([
|
|
30
|
+
'usage', 'status', 'config', 'agents', 'skills',
|
|
31
|
+
'permissions', 'mcp', 'hooks', 'model', 'effort',
|
|
32
|
+
])
|
|
33
|
+
// ESC 触发器:写 `\x1b` 到 PTY stdin,相当于按 Esc 键退出 modal
|
|
34
|
+
const ESC_TRIGGERS = [/^esc$/i, /^退出菜单$/, /^cancel[-\s]?modal$/i]
|
|
35
|
+
// 中断触发器:写 `\x03`(Ctrl+C 的 ASCII)到 PTY,触发 SIGINT 打断当前 turn / 工具执行
|
|
36
|
+
const INTERRUPT_TRIGGERS = [
|
|
37
|
+
/^中断$/, /^打断$/, /^停一下$/,
|
|
38
|
+
/^\^c$/i, // ^C
|
|
39
|
+
/^ctrl[\s\-+]?c$/i, // ctrl+c, ctrl-c, ctrlc
|
|
40
|
+
/^interrupt$/i,
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
44
|
+
import { join, basename } from 'node:path'
|
|
45
|
+
import { parseCallbackData, buildAnswerReplyText, buildExtendedReplyText, CB_KIND_ANSWER, CB_KIND_EXTEND } from './ask-user-buttons.js'
|
|
46
|
+
import { applySystemRules } from './system-rules.js'
|
|
47
|
+
import { resolveTool } from './dispatch.js'
|
|
48
|
+
|
|
49
|
+
const WIZARD_TIMEOUT_MS = 10 * 60 * 1000
|
|
50
|
+
|
|
51
|
+
const STEP_WORKDIR = 'workdir'
|
|
52
|
+
const STEP_QUADRANT = 'quadrant'
|
|
53
|
+
const STEP_TEMPLATE = 'template'
|
|
54
|
+
const STEP_DONE = 'done'
|
|
55
|
+
|
|
56
|
+
const QUADRANTS = [
|
|
57
|
+
{ id: 1, label: '重要紧急' },
|
|
58
|
+
{ id: 2, label: '重要不紧急' },
|
|
59
|
+
{ id: 3, label: '紧急不重要' },
|
|
60
|
+
{ id: 4, label: '不重要不紧急' },
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
function telegramPermissionMode(cfg = {}) {
|
|
64
|
+
const mode = cfg.telegram?.defaultPermissionMode
|
|
65
|
+
return ['default', 'acceptEdits', 'bypass'].includes(mode) ? mode : 'bypass'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function larkPermissionMode(cfg = {}) {
|
|
69
|
+
const mode = cfg.lark?.defaultPermissionMode
|
|
70
|
+
return ['default', 'acceptEdits', 'bypass'].includes(mode) ? mode : 'bypass'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 按 channel 选权限模式:lark 用自己的配置,telegram/openclaw/其他都走 telegram 配置(保持旧行为)。
|
|
74
|
+
function permissionModeForChannel(channel, cfg = {}) {
|
|
75
|
+
if (channel === 'lark') return larkPermissionMode(cfg)
|
|
76
|
+
return telegramPermissionMode(cfg)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defaultRecentWorkDirs(db, limit = 5) {
|
|
80
|
+
try {
|
|
81
|
+
const rows = db.raw.prepare(`
|
|
82
|
+
SELECT work_dir, COUNT(*) AS n, MAX(updated_at) AS last_used
|
|
83
|
+
FROM todos
|
|
84
|
+
WHERE work_dir IS NOT NULL AND work_dir != ''
|
|
85
|
+
GROUP BY work_dir
|
|
86
|
+
ORDER BY n DESC, last_used DESC
|
|
87
|
+
LIMIT ?
|
|
88
|
+
`).all(limit)
|
|
89
|
+
return rows.map((r) => ({ path: r.work_dir, count: r.n, lastUsedAt: r.last_used }))
|
|
90
|
+
} catch {
|
|
91
|
+
return []
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 解析"目录 X" / "workdir X" 之类后缀;返回 null 或 path. */
|
|
96
|
+
function tryExtractWorkdir(text) {
|
|
97
|
+
const m = text.match(/(?:目录|路径|workdir|cwd|文件夹)[::=\s]+([^\s,,;;]+)/i)
|
|
98
|
+
if (m) return m[1].trim()
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 解析"象限 N" / "quadrant N" / "Q1" 等;返回 1-4 或 null */
|
|
103
|
+
function tryExtractQuadrant(text) {
|
|
104
|
+
const m1 = text.match(/(?:象限|quadrant|q)[::=\s]*([1-4])\b/i)
|
|
105
|
+
if (m1) return Number(m1[1])
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 解析"模板 Bug" / "用 X 模板" 等;返回模板名 (string) 或 null */
|
|
110
|
+
function tryExtractTemplateHint(text) {
|
|
111
|
+
const m = text.match(/(?:用|使用)?\s*[「『"]?([^」』"\s,,;;]+)[」』"]?\s*模板/)
|
|
112
|
+
if (m) return m[1].trim()
|
|
113
|
+
const m2 = text.match(/模板[::=\s]+([^\s,,;;]+)/)
|
|
114
|
+
if (m2) return m2[1].trim()
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseNumericChoice(text, listLength) {
|
|
119
|
+
const m = String(text).trim().match(/^(\d+)\b/)
|
|
120
|
+
if (!m) return null
|
|
121
|
+
const idx = parseInt(m[1], 10) - 1
|
|
122
|
+
if (idx < 0 || idx >= listLength) return null
|
|
123
|
+
return idx
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function findTemplateByHint(templates, hint) {
|
|
127
|
+
if (!hint) return null
|
|
128
|
+
const lower = hint.toLowerCase()
|
|
129
|
+
for (const t of templates) {
|
|
130
|
+
const name = String(t.name || '').toLowerCase()
|
|
131
|
+
if (name === lower || name.startsWith(lower) || name.includes(lower)) return t
|
|
132
|
+
const desc = String(t.description || '').toLowerCase()
|
|
133
|
+
if (desc.startsWith(lower)) return t
|
|
134
|
+
}
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extractTitle(text) {
|
|
139
|
+
// "新建任务: X" / "帮我做 X" / "新建任务 X"
|
|
140
|
+
let s = text.trim()
|
|
141
|
+
// 剥触发词头
|
|
142
|
+
s = s.replace(/^在?\s*(?:agentquad|quadtodo)\s*[里中]?\s*/i, '')
|
|
143
|
+
s = s.replace(/^(新建|开个|开一?个|创建)\s*(任务|todo)?[::\s]*/i, '')
|
|
144
|
+
s = s.replace(/^任务[::]\s*/, '')
|
|
145
|
+
s = s.replace(/^(帮我|帮忙)\s*(做|搞|修|搞定|实现|写一?个|做一?个|修复|重构|调试|debug|加|开发)\s*[::]?\s*/i, '')
|
|
146
|
+
// 剥后缀(目录 / 象限 / 模板)
|
|
147
|
+
s = s.replace(/[,,;;]?\s*(目录|路径|workdir|cwd|文件夹)[::=\s]+[^\s,,;;]+/gi, '')
|
|
148
|
+
s = s.replace(/[,,;;]?\s*(象限|quadrant|q)[::=\s]*[1-4]\b/gi, '')
|
|
149
|
+
s = s.replace(/[,,;;]?\s*(?:用|使用)?\s*[「『"]?[^」』"\s,,;;]+[」』"]?\s*模板/gi, '')
|
|
150
|
+
s = s.replace(/[,,;;]?\s*模板[::=\s]+[^\s,,;;]+/gi, '')
|
|
151
|
+
return s.trim()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildWorkdirMessage(options) {
|
|
155
|
+
const lines = ['📁 选个工作目录:']
|
|
156
|
+
options.forEach((opt, i) => {
|
|
157
|
+
const tag = opt.source === 'default' ? '默认目录'
|
|
158
|
+
: opt.source === 'subdir' ? '子目录'
|
|
159
|
+
: opt.source === 'recent' ? `recent, ${opt.count} 次`
|
|
160
|
+
: opt.source === 'home' ? 'home'
|
|
161
|
+
: opt.source
|
|
162
|
+
lines.push(`${i + 1}. ${opt.path} (${tag})`)
|
|
163
|
+
})
|
|
164
|
+
lines.push(`${options.length + 1}. 自定义路径(请直接输入路径文本)`)
|
|
165
|
+
return lines.join('\n')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function buildQuadrantMessage() {
|
|
169
|
+
const lines = ['🎯 选象限:']
|
|
170
|
+
QUADRANTS.forEach((q) => {
|
|
171
|
+
lines.push(`${q.id}. ${q.label}${q.id === 2 ? ' ✓ 默认' : ''}`)
|
|
172
|
+
})
|
|
173
|
+
return lines.join('\n')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildTemplateMessage(templates) {
|
|
177
|
+
const lines = ['📋 选模板:']
|
|
178
|
+
templates.forEach((t, i) => {
|
|
179
|
+
lines.push(`${i + 1}. ${t.name}${t.description ? ' — ' + t.description : ''}`)
|
|
180
|
+
})
|
|
181
|
+
lines.push(`${templates.length + 1}. 自由模式(不套模板)`)
|
|
182
|
+
return lines.join('\n')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── inline keyboard 构造(Telegram 路径用) ─────────────────────
|
|
186
|
+
//
|
|
187
|
+
// callback_data 编码(≤ 64 字节硬限):
|
|
188
|
+
// workdir: qt:wd:<idx> / qt:wd:custom
|
|
189
|
+
// quadrant: qt:q:<1..4>
|
|
190
|
+
// template: qt:t:<idx> / qt:t:none
|
|
191
|
+
//
|
|
192
|
+
// 数字按钮 label 沿用 "1. xxx" 序号 —— 跟纯文本 prompt 保持一致,
|
|
193
|
+
// 所以双轨用户(按按钮的 / 回数字的)看到的是同一个心智模型。
|
|
194
|
+
const CALLBACK_PREFIX = 'qt'
|
|
195
|
+
const PERMISSION_CALLBACK_KIND = 'perm'
|
|
196
|
+
const PERMISSION_ACTION_ALLOW = 'allow'
|
|
197
|
+
const PERMISSION_ACTION_DENY = 'deny'
|
|
198
|
+
|
|
199
|
+
function parsePermissionCallback(callbackData) {
|
|
200
|
+
const parts = String(callbackData || '').split(':')
|
|
201
|
+
if (parts.length !== 4 || parts[0] !== CALLBACK_PREFIX || parts[1] !== PERMISSION_CALLBACK_KIND) return null
|
|
202
|
+
const short = parts[2]
|
|
203
|
+
const action = parts[3]
|
|
204
|
+
if (!/^[a-z0-9]{4}$/i.test(short) || ![PERMISSION_ACTION_ALLOW, PERMISSION_ACTION_DENY].includes(action)) return null
|
|
205
|
+
return { short, action }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function ellipsisLabel(s, max = 50) {
|
|
209
|
+
const str = String(s || '')
|
|
210
|
+
if (str.length <= max) return str
|
|
211
|
+
return '…' + str.slice(-(max - 1))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildWorkdirReplyMarkup(options) {
|
|
215
|
+
// 每行 1 个:路径常常很长,2 列会强制换行难看
|
|
216
|
+
const rows = options.map((opt, i) => [{
|
|
217
|
+
text: `${i + 1}. ${ellipsisLabel(opt.path, 48)}`,
|
|
218
|
+
callback_data: `${CALLBACK_PREFIX}:wd:${i}`,
|
|
219
|
+
}])
|
|
220
|
+
rows.push([{
|
|
221
|
+
text: `${options.length + 1}. 🖋 自定义路径`,
|
|
222
|
+
callback_data: `${CALLBACK_PREFIX}:wd:custom`,
|
|
223
|
+
}])
|
|
224
|
+
return { inline_keyboard: rows }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildQuadrantReplyMarkup() {
|
|
228
|
+
// 2×2,Q2 默认勾上
|
|
229
|
+
const label = (q) => `Q${q.id} ${q.label}${q.id === 2 ? ' ✓' : ''}`
|
|
230
|
+
return {
|
|
231
|
+
inline_keyboard: [
|
|
232
|
+
[
|
|
233
|
+
{ text: label(QUADRANTS[0]), callback_data: `${CALLBACK_PREFIX}:q:1` },
|
|
234
|
+
{ text: label(QUADRANTS[1]), callback_data: `${CALLBACK_PREFIX}:q:2` },
|
|
235
|
+
],
|
|
236
|
+
[
|
|
237
|
+
{ text: label(QUADRANTS[2]), callback_data: `${CALLBACK_PREFIX}:q:3` },
|
|
238
|
+
{ text: label(QUADRANTS[3]), callback_data: `${CALLBACK_PREFIX}:q:4` },
|
|
239
|
+
],
|
|
240
|
+
],
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function buildTemplateReplyMarkup(templates) {
|
|
245
|
+
// 模板名也可能带描述 → 每行 1 个稳妥
|
|
246
|
+
const rows = templates.map((t, i) => [{
|
|
247
|
+
text: `${i + 1}. ${ellipsisLabel(t.name, 48)}`,
|
|
248
|
+
callback_data: `${CALLBACK_PREFIX}:t:${i}`,
|
|
249
|
+
}])
|
|
250
|
+
rows.push([{
|
|
251
|
+
text: `${templates.length + 1}. 🆓 自由模式`,
|
|
252
|
+
callback_data: `${CALLBACK_PREFIX}:t:none`,
|
|
253
|
+
}])
|
|
254
|
+
return { inline_keyboard: rows }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function buildWorkdirPrompt(options) {
|
|
258
|
+
return { text: buildWorkdirMessage(options), replyMarkup: buildWorkdirReplyMarkup(options) }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function buildQuadrantPrompt() {
|
|
262
|
+
return { text: buildQuadrantMessage(), replyMarkup: buildQuadrantReplyMarkup() }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function buildTemplatePrompt(templates) {
|
|
266
|
+
return { text: buildTemplateMessage(templates), replyMarkup: buildTemplateReplyMarkup(templates) }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 把 (channel, chatId, threadId) 映射成内部 wizards/lastPush 的复合 key。
|
|
271
|
+
* Telegram 的 General topic(threadId=null)和具体 topic 用不同 key,互不干扰。
|
|
272
|
+
* 老的 weixin/openclaw 路径只有 peer(=chatId),threadId=null → key=`openclaw:${peer}:general`。
|
|
273
|
+
*/
|
|
274
|
+
function makeRouteKey(channel, chatId, threadId) {
|
|
275
|
+
return `${channel || 'openclaw'}:${chatId}:${threadId || 'general'}`
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 是否为 Telegram supergroup 的 General 频道。
|
|
280
|
+
*
|
|
281
|
+
* supergroup 的 chatId 是负数且以 -100 开头,General 频道没有 message_thread_id。
|
|
282
|
+
* 全局 AgentQuad slash 命令(/list /stop 等)只在 General 响应:
|
|
283
|
+
* - General 不会有 PTY 直连,命令不会污染任何 AI 上下文
|
|
284
|
+
* - task topic 里发会被拦截 + 提示去 General 用
|
|
285
|
+
*
|
|
286
|
+
* 注意:weixin 之类的旧路径 chatId 不是 -100… 开头 → 返回 false → 不响应 AgentQuad slash,
|
|
287
|
+
* 走老的 fallback / PTY 转发逻辑(保持向后兼容)。
|
|
288
|
+
*/
|
|
289
|
+
function isGeneralChannel(chatId, threadId) {
|
|
290
|
+
if (!chatId) return false
|
|
291
|
+
if (threadId != null) return false
|
|
292
|
+
return /^-100\d+/.test(String(chatId))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// AgentQuad 全局 slash command 集合。仅在 General 频道响应;其它 topic 拦截并提示。
|
|
296
|
+
const QUADTODO_GLOBAL_SLASH = new Set(['list', 'pending', 'stop'])
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 把 sessionInputDispatcher.send() 返回的 action 翻译成 wizard 的 reply / action。
|
|
300
|
+
* 集中映射,避免 lark / telegram 两个接入点重复 switch。
|
|
301
|
+
*/
|
|
302
|
+
function mapDispatcherResultToWizardReply(result, sid, imagePaths) {
|
|
303
|
+
const imgs = imagePaths && imagePaths.length ? imagePaths : undefined
|
|
304
|
+
switch (result?.action) {
|
|
305
|
+
case 'sent':
|
|
306
|
+
return { reply: '', action: 'stdin_proxy', sessionId: sid, imagePaths: imgs }
|
|
307
|
+
case 'queued':
|
|
308
|
+
return {
|
|
309
|
+
// 首条排队 → 简短文字提示;后续排队仅靠 reaction 静默标记,不刷屏
|
|
310
|
+
reply: result.queueSize === 1
|
|
311
|
+
? '🔄 当前任务进行中,已排队,会在结束后投递。'
|
|
312
|
+
: '',
|
|
313
|
+
action: 'queued',
|
|
314
|
+
sessionId: sid,
|
|
315
|
+
queueSize: result.queueSize,
|
|
316
|
+
}
|
|
317
|
+
case 'queue_full':
|
|
318
|
+
return {
|
|
319
|
+
reply: `📥 队列已满 (${result.queueSize}),请等当前任务结束或发送 \`!!\` 中断。`,
|
|
320
|
+
action: 'queue_full',
|
|
321
|
+
sessionId: sid,
|
|
322
|
+
}
|
|
323
|
+
case 'soft_interrupted':
|
|
324
|
+
return { reply: '⏸ 已发 Esc 中断当前任务,新消息会接着投递。', action: 'soft_interrupted', sessionId: sid }
|
|
325
|
+
case 'hard_cancelled':
|
|
326
|
+
return { reply: '⏹ 已中断当前任务(Ctrl+C)。', action: 'hard_cancelled', sessionId: sid }
|
|
327
|
+
case 'noop_idle':
|
|
328
|
+
return { reply: '✅ 当前没有正在跑的任务,无需中断。', action: 'noop_idle', sessionId: sid }
|
|
329
|
+
case 'session_ended':
|
|
330
|
+
return { reply: '这个任务已结束,请在群里重新发起任务。', action: 'session_ended', sessionId: sid }
|
|
331
|
+
default:
|
|
332
|
+
return { reply: '', action: result?.action || 'unknown', sessionId: sid }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 创建协调器实例。
|
|
338
|
+
*
|
|
339
|
+
* 依赖:
|
|
340
|
+
* - db: createTodo, listTemplates, getTemplate, raw
|
|
341
|
+
* - aiTerminal: spawnSession({sessionId, todoId, prompt, tool, cwd, permissionMode, label, extraEnv})
|
|
342
|
+
* - openclaw: registerSessionRoute(sessionId, {targetUserId, threadId?, topicName?, ...})
|
|
343
|
+
* - pending: submitReply(text), listPending() (不直接被调用,但提供给路由层判断)
|
|
344
|
+
* - getConfig: () => 配置快照(拿 defaultCwd / port / defaultTool)
|
|
345
|
+
* - telegramBot: 可选,提供 createForumTopic / sendMessage —— 启用每任务一 topic
|
|
346
|
+
*/
|
|
347
|
+
export function createOpenClawWizard({
|
|
348
|
+
db, aiTerminal, openclaw, pending,
|
|
349
|
+
pty = null, telegramBot = null, larkBot = null, loadingTracker = null,
|
|
350
|
+
sessionInputDispatcher = null,
|
|
351
|
+
getConfig, logger = console,
|
|
352
|
+
} = {}) {
|
|
353
|
+
if (!db) throw new Error('db_required')
|
|
354
|
+
|
|
355
|
+
// routeKey → wizard state object
|
|
356
|
+
const wizards = new Map()
|
|
357
|
+
|
|
358
|
+
function getActiveWizard(routeKey) {
|
|
359
|
+
const w = wizards.get(routeKey)
|
|
360
|
+
if (!w) return null
|
|
361
|
+
if (Date.now() - w.updatedAt > WIZARD_TIMEOUT_MS) {
|
|
362
|
+
wizards.delete(routeKey)
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
return w
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 列工作目录候选:
|
|
370
|
+
* 1) defaultCwd 自己("默认目录")
|
|
371
|
+
* 2) defaultCwd 下的所有 1 级子目录(按字母排序)
|
|
372
|
+
* 3) 历史 recent 里有但不在上面的("recent")
|
|
373
|
+
*
|
|
374
|
+
* 跟 web 端 `/api/config/workdirs` 行为对齐 —— 用户在 telegram 也能选到子目录。
|
|
375
|
+
*/
|
|
376
|
+
function listWorkdirOptions() {
|
|
377
|
+
const cfg = getConfig?.() || {}
|
|
378
|
+
const out = []
|
|
379
|
+
const seen = new Set()
|
|
380
|
+
const defCwd = cfg.defaultCwd
|
|
381
|
+
|
|
382
|
+
// 1. 默认目录本身
|
|
383
|
+
if (defCwd && !seen.has(defCwd)) {
|
|
384
|
+
out.push({ path: defCwd, source: 'default' })
|
|
385
|
+
seen.add(defCwd)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 2. 默认目录的所有 1 级子目录(按字母)
|
|
389
|
+
if (defCwd && existsSync(defCwd)) {
|
|
390
|
+
try {
|
|
391
|
+
const subs = readdirSync(defCwd, { withFileTypes: true })
|
|
392
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
393
|
+
.map((entry) => join(defCwd, entry.name))
|
|
394
|
+
.sort((a, b) => basename(a).localeCompare(basename(b), 'zh-Hans-CN'))
|
|
395
|
+
for (const p of subs) {
|
|
396
|
+
if (!seen.has(p)) {
|
|
397
|
+
out.push({ path: p, source: 'subdir' })
|
|
398
|
+
seen.add(p)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
} catch (e) {
|
|
402
|
+
logger.warn?.(`[wizard] readdirSync ${defCwd} failed: ${e.message}`)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 3. 历史 recent 里独立条目(用过但不在上面)
|
|
407
|
+
const recent = defaultRecentWorkDirs(db, 5)
|
|
408
|
+
for (const r of recent) {
|
|
409
|
+
if (!seen.has(r.path)) {
|
|
410
|
+
out.push({ ...r, source: 'recent' })
|
|
411
|
+
seen.add(r.path)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return out
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function startWizard({ channel = 'openclaw', chatId, threadId, text, messageId = null, rootMessageId = null, imagePaths = [], userId = null }) {
|
|
419
|
+
const routeKey = makeRouteKey(channel, chatId, threadId)
|
|
420
|
+
const title = extractTitle(text) || '(未命名任务)'
|
|
421
|
+
const workdirHint = tryExtractWorkdir(text)
|
|
422
|
+
const quadrantHint = tryExtractQuadrant(text)
|
|
423
|
+
const templateHint = tryExtractTemplateHint(text)
|
|
424
|
+
|
|
425
|
+
const w = {
|
|
426
|
+
peer: chatId, // 兼容字段(旧代码读 w.peer)
|
|
427
|
+
channel,
|
|
428
|
+
chatId,
|
|
429
|
+
userId: userId || null, // dispatch perUser 路由用(Lark open_id / Telegram from_user_id)
|
|
430
|
+
threadId,
|
|
431
|
+
rootMessageId, // lark thread root 锚点(用户在 thread 里起的 wizard 用)
|
|
432
|
+
triggerMessageId: messageId, // 用户触发本任务的消息 id(D 方案:tracker 加 reaction)
|
|
433
|
+
imagePaths: Array.isArray(imagePaths) ? imagePaths.slice() : [], // 创建时一起发的图片附件
|
|
434
|
+
routeKey,
|
|
435
|
+
title,
|
|
436
|
+
workdirOptions: listWorkdirOptions(),
|
|
437
|
+
chosenWorkdir: workdirHint || null,
|
|
438
|
+
chosenQuadrant: quadrantHint || null,
|
|
439
|
+
chosenTemplate: null,
|
|
440
|
+
step: STEP_WORKDIR,
|
|
441
|
+
startedAt: Date.now(),
|
|
442
|
+
updatedAt: Date.now(),
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 模板 hint 解析
|
|
446
|
+
if (templateHint) {
|
|
447
|
+
const tpl = findTemplateByHint(db.listTemplates(), templateHint)
|
|
448
|
+
if (tpl) w.chosenTemplate = { id: tpl.id, name: tpl.name }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// 自动跳过已填字段
|
|
452
|
+
if (w.chosenWorkdir) w.step = STEP_QUADRANT
|
|
453
|
+
if (w.chosenWorkdir && w.chosenQuadrant) w.step = STEP_TEMPLATE
|
|
454
|
+
if (w.chosenWorkdir && w.chosenQuadrant && w.chosenTemplate) w.step = STEP_DONE
|
|
455
|
+
|
|
456
|
+
wizards.set(routeKey, w)
|
|
457
|
+
return w
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function abortWizard(routeKey) {
|
|
461
|
+
const had = wizards.has(routeKey)
|
|
462
|
+
wizards.delete(routeKey)
|
|
463
|
+
return had
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* 推进 wizard 一步。返回 { reply, replyMarkup?, done? }。
|
|
468
|
+
* replyMarkup 仅在 prompt 性 reply 时附带(让 telegram-bot dispatch 自动塞按钮)。
|
|
469
|
+
*/
|
|
470
|
+
async function advance(w, text) {
|
|
471
|
+
w.updatedAt = Date.now()
|
|
472
|
+
|
|
473
|
+
// ─── workdir 步 ───
|
|
474
|
+
if (w.step === STEP_WORKDIR) {
|
|
475
|
+
// 自定义路径子态:用户点过 inline 「自定义」按钮,下一条任意非空文本都当路径,
|
|
476
|
+
// 不再走数字 / `/`/`~` 检测。这是 callback_query 路径独有的子态——
|
|
477
|
+
// 文本路径下用户直接粘 `/path` 也仍然走老逻辑。
|
|
478
|
+
if (w.awaitingCustomWorkdir) {
|
|
479
|
+
const path = text.trim()
|
|
480
|
+
if (!path) return { reply: '🖋 路径为空,请重发' }
|
|
481
|
+
w.chosenWorkdir = path
|
|
482
|
+
w.awaitingCustomWorkdir = false
|
|
483
|
+
w.step = STEP_QUADRANT
|
|
484
|
+
const prompt = buildQuadrantPrompt()
|
|
485
|
+
return { reply: prompt.text, replyMarkup: prompt.replyMarkup }
|
|
486
|
+
}
|
|
487
|
+
// 数字选项?
|
|
488
|
+
const idx = parseNumericChoice(text, w.workdirOptions.length + 1)
|
|
489
|
+
if (idx !== null) {
|
|
490
|
+
if (idx < w.workdirOptions.length) {
|
|
491
|
+
w.chosenWorkdir = w.workdirOptions[idx].path
|
|
492
|
+
w.step = STEP_QUADRANT
|
|
493
|
+
const prompt = buildQuadrantPrompt()
|
|
494
|
+
return { reply: prompt.text, replyMarkup: prompt.replyMarkup }
|
|
495
|
+
} else {
|
|
496
|
+
// 选了"自定义"
|
|
497
|
+
return { reply: '🖋 请输入完整路径(绝对路径或 ~/ 开头)' }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// 自定义路径(文本路径下的隐式触发,老行为)
|
|
501
|
+
if (text.startsWith('/') || text.startsWith('~')) {
|
|
502
|
+
w.chosenWorkdir = text.trim()
|
|
503
|
+
w.step = STEP_QUADRANT
|
|
504
|
+
const prompt = buildQuadrantPrompt()
|
|
505
|
+
return { reply: prompt.text, replyMarkup: prompt.replyMarkup }
|
|
506
|
+
}
|
|
507
|
+
// 看不懂 → 重发提示(保留按钮)
|
|
508
|
+
const prompt = buildWorkdirPrompt(w.workdirOptions)
|
|
509
|
+
return {
|
|
510
|
+
reply: `🤔 没看懂,请点按钮 / 回数字 1-${w.workdirOptions.length + 1} 或粘贴一个绝对路径。\n\n${prompt.text}`,
|
|
511
|
+
replyMarkup: prompt.replyMarkup,
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ─── quadrant 步 ───
|
|
516
|
+
if (w.step === STEP_QUADRANT) {
|
|
517
|
+
const num = String(text).trim().match(/^([1-4])$/)
|
|
518
|
+
if (num) {
|
|
519
|
+
w.chosenQuadrant = Number(num[1])
|
|
520
|
+
} else if (/默认|default|^$/i.test(text.trim())) {
|
|
521
|
+
w.chosenQuadrant = 2
|
|
522
|
+
} else {
|
|
523
|
+
const prompt = buildQuadrantPrompt()
|
|
524
|
+
return {
|
|
525
|
+
reply: `🤔 请点按钮或回 1-4 选象限,回 "默认" 用 Q2。\n\n${prompt.text}`,
|
|
526
|
+
replyMarkup: prompt.replyMarkup,
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
w.step = STEP_TEMPLATE
|
|
530
|
+
const templates = db.listTemplates()
|
|
531
|
+
w.cachedTemplates = templates
|
|
532
|
+
const prompt = buildTemplatePrompt(templates)
|
|
533
|
+
return { reply: prompt.text, replyMarkup: prompt.replyMarkup }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ─── template 步 ───
|
|
537
|
+
if (w.step === STEP_TEMPLATE) {
|
|
538
|
+
const templates = w.cachedTemplates || db.listTemplates()
|
|
539
|
+
const idx = parseNumericChoice(text, templates.length + 1)
|
|
540
|
+
if (idx !== null) {
|
|
541
|
+
if (idx < templates.length) {
|
|
542
|
+
w.chosenTemplate = { id: templates[idx].id, name: templates[idx].name }
|
|
543
|
+
} else {
|
|
544
|
+
// 自由模式
|
|
545
|
+
w.chosenTemplate = null
|
|
546
|
+
}
|
|
547
|
+
} else if (/自由|skip|无|none/i.test(text.trim())) {
|
|
548
|
+
w.chosenTemplate = null
|
|
549
|
+
} else {
|
|
550
|
+
// 试模板名匹配
|
|
551
|
+
const tpl = findTemplateByHint(templates, text.trim())
|
|
552
|
+
if (tpl) {
|
|
553
|
+
w.chosenTemplate = { id: tpl.id, name: tpl.name }
|
|
554
|
+
} else {
|
|
555
|
+
const prompt = buildTemplatePrompt(templates)
|
|
556
|
+
return {
|
|
557
|
+
reply: `🤔 请点按钮 / 回数字 1-${templates.length + 1},或模板名(自由/无)。\n\n${prompt.text}`,
|
|
558
|
+
replyMarkup: prompt.replyMarkup,
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
w.step = STEP_DONE
|
|
563
|
+
return await finalizeWizard(w)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return { reply: '🤔 wizard 状态异常,请重试' }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function finalizeWizard(w) {
|
|
570
|
+
try {
|
|
571
|
+
// 创建 todo
|
|
572
|
+
const todo = db.createTodo({
|
|
573
|
+
title: w.title,
|
|
574
|
+
quadrant: w.chosenQuadrant || 2,
|
|
575
|
+
description: '',
|
|
576
|
+
workDir: w.chosenWorkdir || null,
|
|
577
|
+
brainstorm: false,
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
const shortCode = String(todo.id).replace(/[^a-z0-9]/gi, '').slice(-3).toLowerCase()
|
|
581
|
+
const topicName = `#t${shortCode} ${w.title}`.slice(0, 128)
|
|
582
|
+
|
|
583
|
+
// ── 新增:尝试创建 Telegram Topic(只在有 telegramBot 且 channel=telegram/chatId 是 telegram 数字 ID 时)──
|
|
584
|
+
let createdThreadId = null
|
|
585
|
+
let larkRootMessageId = null
|
|
586
|
+
let larkThreadId = null
|
|
587
|
+
let larkMessageAppLink = null
|
|
588
|
+
const channel = w.channel || 'openclaw'
|
|
589
|
+
const canCreateTopic = !!telegramBot?.createForumTopic
|
|
590
|
+
const looksLikeTelegram = channel === 'telegram' && w.chatId && /^-?\d+$/.test(String(w.chatId)) // 只有 telegram chat id 是纯数字
|
|
591
|
+
logger.info?.(`[wizard] finalize: channel=${channel} chatId=${w.chatId} canCreateTopic=${canCreateTopic} looksLikeTelegram=${looksLikeTelegram} topicName="${topicName}"`)
|
|
592
|
+
if (canCreateTopic && looksLikeTelegram) {
|
|
593
|
+
// 网络抖动重试 1 次:createForumTopic 经常因 fetch failed / timeout 等瞬时错误挂掉,
|
|
594
|
+
// 跟 sendMessage / 图片下载的策略一致 —— 1s 后重试,再失败才 fallback
|
|
595
|
+
const tryCreateTopic = () => telegramBot.createForumTopic({ chatId: w.chatId, name: topicName })
|
|
596
|
+
try {
|
|
597
|
+
let topic
|
|
598
|
+
try { topic = await tryCreateTopic() }
|
|
599
|
+
catch (e1) {
|
|
600
|
+
if (/fetch failed|fetch_error|aborted|timeout/i.test(e1.message)) {
|
|
601
|
+
logger.warn?.(`[wizard] createForumTopic transient error (${e1.message}); retrying once in 1s`)
|
|
602
|
+
await new Promise((res) => setTimeout(res, 1000))
|
|
603
|
+
topic = await tryCreateTopic()
|
|
604
|
+
} else {
|
|
605
|
+
throw e1
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
createdThreadId = topic?.message_thread_id || null
|
|
609
|
+
logger.info?.(`[wizard] createForumTopic OK threadId=${createdThreadId}`)
|
|
610
|
+
} catch (e) {
|
|
611
|
+
logger.warn?.(`[wizard] createForumTopic FAILED after retry: ${e.message}; chatId=${w.chatId} name="${topicName}"; falling back to General`)
|
|
612
|
+
}
|
|
613
|
+
} else if (!canCreateTopic) {
|
|
614
|
+
logger.info?.(`[wizard] no telegramBot — skipping topic creation (likely weixin path)`)
|
|
615
|
+
} else if (!looksLikeTelegram) {
|
|
616
|
+
logger.info?.(`[wizard] channel=${channel} chatId="${w.chatId}" is not telegram-shaped (numeric); skipping topic creation`)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (channel === 'lark' && larkBot && w.chatId) {
|
|
620
|
+
const intro = [
|
|
621
|
+
`${topicName}`,
|
|
622
|
+
`AI 已启动,后续输出会回复在这个话题里。`,
|
|
623
|
+
``,
|
|
624
|
+
`象限 Q${w.chosenQuadrant || 2} · 目录 ${w.chosenWorkdir || '默认'} · 模板 ${w.chosenTemplate?.name || '自由模式'}`,
|
|
625
|
+
].join('\n')
|
|
626
|
+
// 复用用户当前所在话题 thread:把 intro 作为 thread reply 发进去,PTY 后续输出
|
|
627
|
+
// 也用同一个 anchor 进同一话题,不再开新 thread。
|
|
628
|
+
const reuseThreadAnchor = w.rootMessageId || w.triggerMessageId || null
|
|
629
|
+
if (reuseThreadAnchor && larkBot.replyInThread) {
|
|
630
|
+
try {
|
|
631
|
+
const sent = await larkBot.replyInThread({ rootMessageId: reuseThreadAnchor, text: intro })
|
|
632
|
+
if (sent?.ok !== false) {
|
|
633
|
+
const payload = sent?.payload || sent || {}
|
|
634
|
+
// 飞书 reply API 响应 data 里带 root_id(该消息所在 thread 的真正 root)。
|
|
635
|
+
// 用它当 lark route 的 anchor —— 跟 thread 里所有后续事件的 ev.root_id
|
|
636
|
+
// 永远一致。如果飞书没返回 root_id(罕见),退到 reply 自己的 message_id,
|
|
637
|
+
// 再退到用户消息(reuseThreadAnchor)。
|
|
638
|
+
const replyRootId = payload.root_id != null ? String(payload.root_id) : null
|
|
639
|
+
const replyMessageId = payload.message_id != null ? String(payload.message_id) : null
|
|
640
|
+
larkRootMessageId = replyRootId || replyMessageId || reuseThreadAnchor
|
|
641
|
+
larkThreadId = w.threadId || (payload.thread_id != null ? String(payload.thread_id) : null)
|
|
642
|
+
larkMessageAppLink = payload.message_app_link != null ? String(payload.message_app_link) : null
|
|
643
|
+
} else {
|
|
644
|
+
logger.warn?.(`[wizard] lark thread reply failed (${sent.reason || 'unknown'}); falling back to new root`)
|
|
645
|
+
}
|
|
646
|
+
} catch (e) {
|
|
647
|
+
logger.warn?.(`[wizard] lark thread reply threw: ${e.message}; falling back to new root`)
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// 没复用成功(用户从主消息流起的 / reply 失败)→ 创建新 root(保留旧逻辑)
|
|
651
|
+
if (!larkRootMessageId && larkBot.sendMessage) {
|
|
652
|
+
try {
|
|
653
|
+
const sent = await larkBot.sendMessage({ chatId: w.chatId, text: intro })
|
|
654
|
+
if (sent?.ok !== false) {
|
|
655
|
+
const payload = sent?.payload || sent || {}
|
|
656
|
+
larkRootMessageId = payload.message_id != null ? String(payload.message_id) : null
|
|
657
|
+
larkThreadId = payload.thread_id != null ? String(payload.thread_id) : null
|
|
658
|
+
larkMessageAppLink = payload.message_app_link != null ? String(payload.message_app_link) : null
|
|
659
|
+
}
|
|
660
|
+
} catch (e) {
|
|
661
|
+
logger.warn?.(`[wizard] lark root message failed: ${e.message}`)
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// 启动 PTY
|
|
667
|
+
let sessionInfo = null
|
|
668
|
+
if (aiTerminal?.spawnSession) {
|
|
669
|
+
const cfg = getConfig?.() || {}
|
|
670
|
+
const tool = resolveTool({ channel: w.channel, userId: w.userId, chatId: w.chatId }, cfg)
|
|
671
|
+
const port = cfg.port || 5677
|
|
672
|
+
const permissionMode = permissionModeForChannel(channel, cfg)
|
|
673
|
+
const sessionId = `ai-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
|
674
|
+
|
|
675
|
+
let prompt
|
|
676
|
+
if (w.chosenTemplate) {
|
|
677
|
+
const tpl = db.getTemplate(w.chosenTemplate.id)
|
|
678
|
+
if (tpl) {
|
|
679
|
+
prompt = `${tpl.content}\n\n---\n任务: ${w.title}`
|
|
680
|
+
} else {
|
|
681
|
+
prompt = `任务: ${w.title}`
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
prompt = `任务: ${w.title}`
|
|
685
|
+
}
|
|
686
|
+
// 默认不自动 prepend ask_user 规则,避免诱发 Claude Code 交互式 TUI。
|
|
687
|
+
// 需要强制走 Telegram 按钮时,可显式配置 config.aiSession.enforceAskUserRule = true。
|
|
688
|
+
prompt = applySystemRules(prompt, { enforce: cfg.aiSession?.enforceAskUserRule === true })
|
|
689
|
+
// 创建任务时一起带的图 → prepend `@path1 @path2 ` 让 Claude Code 启动就 attach
|
|
690
|
+
if (Array.isArray(w.imagePaths) && w.imagePaths.length > 0) {
|
|
691
|
+
const ats = w.imagePaths.map((p) => `@${p}`).join(' ')
|
|
692
|
+
prompt = `${ats}\n\n${prompt}`
|
|
693
|
+
logger.info?.(`[wizard] finalize prepended ${w.imagePaths.length} image attachment(s) to prompt`)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const extraEnv = {
|
|
697
|
+
QUADTODO_SESSION_ID: sessionId,
|
|
698
|
+
QUADTODO_TODO_ID: String(todo.id),
|
|
699
|
+
QUADTODO_TODO_TITLE: String(w.title),
|
|
700
|
+
QUADTODO_URL: `http://127.0.0.1:${port}`,
|
|
701
|
+
}
|
|
702
|
+
if (w.peer) extraEnv.QUADTODO_TARGET_USER = String(w.peer)
|
|
703
|
+
|
|
704
|
+
let sessionRoute = null
|
|
705
|
+
if (w.channel === 'lark' && larkRootMessageId) {
|
|
706
|
+
sessionRoute = {
|
|
707
|
+
targetUserId: w.peer,
|
|
708
|
+
threadId: larkThreadId,
|
|
709
|
+
rootMessageId: larkRootMessageId,
|
|
710
|
+
topicName,
|
|
711
|
+
messageAppLink: larkMessageAppLink,
|
|
712
|
+
triggerMessageId: w.triggerMessageId || null,
|
|
713
|
+
account: null,
|
|
714
|
+
channel: 'lark',
|
|
715
|
+
}
|
|
716
|
+
} else if (channel !== 'lark' && w.peer) {
|
|
717
|
+
sessionRoute = {
|
|
718
|
+
targetUserId: w.peer,
|
|
719
|
+
threadId: createdThreadId, // ← 关键:把 topic 的 thread id 绑到 session 上
|
|
720
|
+
topicName, // ← 给 SessionEnd 改名 ✅ 用
|
|
721
|
+
triggerMessageId: w.triggerMessageId || null, // D 方案:tracker 加 reaction 用
|
|
722
|
+
account: null,
|
|
723
|
+
channel: createdThreadId ? 'telegram' : null,
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (openclaw?.registerSessionRoute && sessionRoute) {
|
|
727
|
+
openclaw.registerSessionRoute(sessionId, sessionRoute)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
aiTerminal.spawnSession({
|
|
732
|
+
sessionId,
|
|
733
|
+
todoId: todo.id,
|
|
734
|
+
prompt,
|
|
735
|
+
tool,
|
|
736
|
+
cwd: w.chosenWorkdir || cfg.defaultCwd || null,
|
|
737
|
+
permissionMode,
|
|
738
|
+
label: w.chosenTemplate ? `template:${w.chosenTemplate.name}` : null,
|
|
739
|
+
extraEnv,
|
|
740
|
+
skipTelegram: true, // wizard 自管 topic(下面单独 createForumTopic)
|
|
741
|
+
})
|
|
742
|
+
sessionInfo = { sessionId, tool }
|
|
743
|
+
} catch (e) {
|
|
744
|
+
logger.warn?.(`[wizard] spawnSession failed: ${e.message}`)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// 持久化路由到 DB.todo.aiSessions[i].telegramRoute(在 spawnSession 之后,
|
|
748
|
+
// 此时 aiSessions[0] 已被写入)→ AgentQuad 重启时 recoverPendingTodosOnStartup
|
|
749
|
+
// 复活 session 时通过 mergeTodoAiSessions 的 spread 保留 telegramRoute;
|
|
750
|
+
// server.js 启动时扫描 ait.sessions 并 re-register 到 openclaw-bridge
|
|
751
|
+
if (sessionInfo && ((createdThreadId && sessionRoute) || (larkRootMessageId && sessionRoute))) {
|
|
752
|
+
try {
|
|
753
|
+
const todoNow = db.getTodo(todo.id)
|
|
754
|
+
if (todoNow) {
|
|
755
|
+
const updatedSessions = (todoNow.aiSessions || []).map((s) => {
|
|
756
|
+
if (s.sessionId !== sessionId) return s
|
|
757
|
+
if (larkRootMessageId) return { ...s, larkRoute: sessionRoute }
|
|
758
|
+
return { ...s, telegramRoute: sessionRoute }
|
|
759
|
+
})
|
|
760
|
+
db.updateTodo(todo.id, { aiSessions: updatedSessions })
|
|
761
|
+
}
|
|
762
|
+
} catch (e) {
|
|
763
|
+
logger.warn?.(`[wizard] persist session route failed: ${e.message}`)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// 给新 topic 推欢迎消息
|
|
769
|
+
if (createdThreadId && telegramBot?.sendMessage && w.chatId) {
|
|
770
|
+
const welcome = [
|
|
771
|
+
`🤖 任务「${w.title}」AI 已启动 (${sessionInfo?.tool || 'claude'})`,
|
|
772
|
+
``,
|
|
773
|
+
`象限 Q${w.chosenQuadrant || 2} · 目录 ${w.chosenWorkdir || '默认'} · 模板 ${w.chosenTemplate?.name || '自由模式'}`,
|
|
774
|
+
``,
|
|
775
|
+
`AI 一轮回话/卡住/结束会推到这里。直接回任意文本会写进 PTY stdin。`,
|
|
776
|
+
].join('\n')
|
|
777
|
+
telegramBot.sendMessage({
|
|
778
|
+
chatId: w.chatId,
|
|
779
|
+
threadId: createdThreadId,
|
|
780
|
+
text: welcome,
|
|
781
|
+
}).catch((e) => logger.warn?.(`[wizard] welcome message failed: ${e.message}`))
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
wizards.delete(w.routeKey)
|
|
785
|
+
|
|
786
|
+
// 在 General topic 回的 ack 短消息
|
|
787
|
+
const lines = []
|
|
788
|
+
if (createdThreadId) {
|
|
789
|
+
lines.push(`✅ todo #${shortCode} 已建 → 去 topic 「${topicName}」 看进度`)
|
|
790
|
+
lines.push(` Q${w.chosenQuadrant || 2} · ${w.chosenWorkdir || '默认目录'} · ${w.chosenTemplate?.name || '自由模式'}`)
|
|
791
|
+
} else {
|
|
792
|
+
lines.push(`✅ todo #${shortCode} 已建`)
|
|
793
|
+
lines.push(` 标题: ${w.title}`)
|
|
794
|
+
lines.push(` 象限: Q${w.chosenQuadrant || 2}`)
|
|
795
|
+
lines.push(` 目录: ${w.chosenWorkdir || '默认'}`)
|
|
796
|
+
lines.push(` 模板: ${w.chosenTemplate?.name || '自由模式'}`)
|
|
797
|
+
if (sessionInfo) {
|
|
798
|
+
lines.push(`🤖 ${sessionInfo.tool} 终端已启动 (sessionId: ${sessionInfo.sessionId.slice(-8)})`)
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return {
|
|
802
|
+
reply: lines.join('\n'),
|
|
803
|
+
done: true,
|
|
804
|
+
action: 'wizard_done',
|
|
805
|
+
todoId: todo.id,
|
|
806
|
+
threadId: createdThreadId,
|
|
807
|
+
}
|
|
808
|
+
} catch (e) {
|
|
809
|
+
wizards.delete(w.routeKey)
|
|
810
|
+
logger.warn?.(`[wizard] finalize failed: ${e.message}`)
|
|
811
|
+
return { reply: `❌ 创建任务失败: ${e.message}`, action: 'wizard_failed' }
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* 给一个已起来的 PTY session 确保有对应的 Telegram topic(幂等)。
|
|
817
|
+
* 用于:web UI / MCP / CLI 等非 wizard 路径起 session 时自动镜像到 Telegram。
|
|
818
|
+
* 已绑定路由 / 已持久化 telegramRoute → 直接 no-op;否则建 topic + 注路由 + 持久化。
|
|
819
|
+
*/
|
|
820
|
+
async function ensureTopicForSession({ sessionId, todoId } = {}) {
|
|
821
|
+
if (!sessionId || !todoId) return { ok: false, reason: 'missing_args' }
|
|
822
|
+
if (!telegramBot?.createForumTopic) return { ok: false, reason: 'no_telegram_bot' }
|
|
823
|
+
|
|
824
|
+
// 已有路由 → 跳过
|
|
825
|
+
const existing = openclaw?.resolveRoute?.(sessionId)
|
|
826
|
+
if (existing && existing.threadId) return { ok: true, action: 'already_bound' }
|
|
827
|
+
|
|
828
|
+
// DB 里已持久化(rehydrate 时常见)→ 重注路由就行
|
|
829
|
+
const todo = db.getTodo(todoId)
|
|
830
|
+
if (!todo) return { ok: false, reason: 'todo_not_found' }
|
|
831
|
+
const aiSess = (todo.aiSessions || []).find((s) => s.sessionId === sessionId)
|
|
832
|
+
if (aiSess?.telegramRoute?.threadId) {
|
|
833
|
+
openclaw?.registerSessionRoute?.(sessionId, aiSess.telegramRoute)
|
|
834
|
+
return { ok: true, action: 're-registered', threadId: aiSess.telegramRoute.threadId }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// 决定 chatId:优先当前配置字段,兼容 legacy defaultSupergroupId,最后回退 allowedChatIds[0]
|
|
838
|
+
const cfg = getConfig?.() || {}
|
|
839
|
+
const tg = cfg.telegram || {}
|
|
840
|
+
const chatId = tg.supergroupId || tg.defaultSupergroupId || (Array.isArray(tg.allowedChatIds) ? tg.allowedChatIds[0] : null)
|
|
841
|
+
if (!chatId) return { ok: false, reason: 'no_default_chat_id' }
|
|
842
|
+
|
|
843
|
+
// 拼名字:#tXXX <title>
|
|
844
|
+
const shortCode = String(todoId).replace(/[^a-zA-Z0-9]/g, '').slice(-4).toLowerCase() || 'auto'
|
|
845
|
+
const title = (todo.title || `todo-${shortCode}`).slice(0, 96)
|
|
846
|
+
const topicName = `#t${shortCode} ${title}`.slice(0, 128)
|
|
847
|
+
|
|
848
|
+
let threadId = null
|
|
849
|
+
try {
|
|
850
|
+
const topic = await telegramBot.createForumTopic({ chatId: String(chatId), name: topicName })
|
|
851
|
+
threadId = topic?.message_thread_id || null
|
|
852
|
+
} catch (e) {
|
|
853
|
+
logger.warn?.(`[wizard] auto-create topic failed: ${e.message}`)
|
|
854
|
+
return { ok: false, reason: 'create_topic_failed', detail: e.message }
|
|
855
|
+
}
|
|
856
|
+
if (!threadId) return { ok: false, reason: 'no_thread_id' }
|
|
857
|
+
|
|
858
|
+
const route = {
|
|
859
|
+
targetUserId: String(chatId),
|
|
860
|
+
threadId,
|
|
861
|
+
topicName,
|
|
862
|
+
channel: 'telegram',
|
|
863
|
+
}
|
|
864
|
+
openclaw?.registerSessionRoute?.(sessionId, route)
|
|
865
|
+
loadingTracker?.start?.({ sessionId })?.catch?.((e) => logger.warn?.(`[wizard] loading-status start failed: ${e.message}`))
|
|
866
|
+
|
|
867
|
+
// 持久化到 DB
|
|
868
|
+
try {
|
|
869
|
+
const tnow = db.getTodo(todoId)
|
|
870
|
+
const updatedSessions = (tnow?.aiSessions || []).map((s) =>
|
|
871
|
+
s.sessionId === sessionId ? { ...s, telegramRoute: route } : s,
|
|
872
|
+
)
|
|
873
|
+
if (updatedSessions.length) db.updateTodo(todoId, { aiSessions: updatedSessions })
|
|
874
|
+
} catch (e) {
|
|
875
|
+
logger.warn?.(`[wizard] persist auto-route failed: ${e.message}`)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (telegramBot?.sendMessage) {
|
|
879
|
+
telegramBot.sendMessage({
|
|
880
|
+
chatId: String(chatId),
|
|
881
|
+
threadId,
|
|
882
|
+
text: `🤖 任务「${title}」AI 已起(自动镜像 from web/CLI)\n直接回这里转给 PTY stdin;关闭话题 = 标完成。`,
|
|
883
|
+
}).catch((e) => logger.warn?.(`[wizard] auto-welcome failed: ${e.message}`))
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
logger.info?.(`[wizard] auto-bound session ${sessionId} → topic threadId=${threadId} (todo ${todoId})`)
|
|
887
|
+
return { ok: true, action: 'created', threadId }
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Lark 自动镜像:Web/CLI 起 PTY session 时,在 lark.chatId 群里发一条根消息作为 thread anchor。
|
|
892
|
+
* 后续 PTY 输出走 openclawBridge.postText → larkBot.replyInThread 进 thread。
|
|
893
|
+
* 对齐 ensureTopicForSession 的语义:already_bound / re-registered / created。
|
|
894
|
+
*/
|
|
895
|
+
async function ensureLarkThreadForSession({ sessionId, todoId } = {}) {
|
|
896
|
+
if (!sessionId || !todoId) return { ok: false, reason: 'missing_args' }
|
|
897
|
+
if (!larkBot?.sendMessage) return { ok: false, reason: 'no_lark_bot' }
|
|
898
|
+
|
|
899
|
+
const existing = openclaw?.resolveRoute?.(sessionId)
|
|
900
|
+
if (existing?.channel === 'lark' && existing?.rootMessageId) {
|
|
901
|
+
return { ok: true, action: 'already_bound' }
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const todo = db.getTodo(todoId)
|
|
905
|
+
if (!todo) return { ok: false, reason: 'todo_not_found' }
|
|
906
|
+
const aiSess = (todo.aiSessions || []).find((s) => s.sessionId === sessionId)
|
|
907
|
+
if (aiSess?.larkRoute?.rootMessageId) {
|
|
908
|
+
openclaw?.registerSessionRoute?.(sessionId, aiSess.larkRoute)
|
|
909
|
+
return { ok: true, action: 're-registered', rootMessageId: aiSess.larkRoute.rootMessageId }
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const cfg = getConfig?.() || {}
|
|
913
|
+
const lark = cfg.lark || {}
|
|
914
|
+
const chatId = lark.chatId || ''
|
|
915
|
+
if (!chatId) return { ok: false, reason: 'no_lark_chat_id' }
|
|
916
|
+
|
|
917
|
+
const shortCode = String(todoId).replace(/[^a-zA-Z0-9]/g, '').slice(-4).toLowerCase() || 'auto'
|
|
918
|
+
const title = (todo.title || `todo-${shortCode}`).slice(0, 96)
|
|
919
|
+
const topicName = `#t${shortCode} ${title}`.slice(0, 128)
|
|
920
|
+
const intro = [
|
|
921
|
+
`${topicName}`,
|
|
922
|
+
`AI 已启动(自动镜像 from web/CLI),后续输出会回复在这条消息的 thread 里。`,
|
|
923
|
+
`直接在本 thread 里回复转发给 PTY stdin。`,
|
|
924
|
+
].join('\n')
|
|
925
|
+
|
|
926
|
+
let payload = null
|
|
927
|
+
try {
|
|
928
|
+
const sent = await larkBot.sendMessage({ chatId: String(chatId), text: intro })
|
|
929
|
+
if (sent?.ok === false) {
|
|
930
|
+
logger.warn?.(`[wizard] auto-create lark thread failed: ${sent.reason || 'unknown'}`)
|
|
931
|
+
return { ok: false, reason: sent.reason || 'lark_send_failed', detail: sent.detail }
|
|
932
|
+
}
|
|
933
|
+
payload = sent?.payload || sent || {}
|
|
934
|
+
} catch (e) {
|
|
935
|
+
logger.warn?.(`[wizard] auto-create lark thread threw: ${e.message}`)
|
|
936
|
+
return { ok: false, reason: 'lark_send_failed', detail: e.message }
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// 飞书 sendMessage 出来的是普通消息,不是 thread 根;用户对它"回复"时事件的
|
|
940
|
+
// root_id 不一定回到这条 message_id(取决于客户端用的"回复"还是"在话题中回复")。
|
|
941
|
+
// 立即 replyInThread 一次自己,把这条消息升级成真正的 thread 根 —— 之后所有
|
|
942
|
+
// thread 内的事件 root_id 就会稳定回到这个 message_id,findSessionByRoute 才能命中。
|
|
943
|
+
const introMessageId = payload?.message_id != null ? String(payload.message_id) : null
|
|
944
|
+
if (!introMessageId) return { ok: false, reason: 'no_root_message_id' }
|
|
945
|
+
|
|
946
|
+
let rootMessageId = introMessageId
|
|
947
|
+
let threadAnchorPayload = null
|
|
948
|
+
if (larkBot.replyInThread) {
|
|
949
|
+
try {
|
|
950
|
+
const anchor = await larkBot.replyInThread({
|
|
951
|
+
rootMessageId: introMessageId,
|
|
952
|
+
text: '↑ 在这条消息上回复就能直接发给 PTY ↑',
|
|
953
|
+
})
|
|
954
|
+
if (anchor?.ok !== false) {
|
|
955
|
+
threadAnchorPayload = anchor?.payload || anchor || {}
|
|
956
|
+
// 飞书把 thread 真正的 root_id 在 reply 响应里 echo 回来;优先用它。
|
|
957
|
+
// 一般 == introMessageId,但保险起见以飞书返回为准。
|
|
958
|
+
const replyRoot = threadAnchorPayload?.root_id != null ? String(threadAnchorPayload.root_id) : null
|
|
959
|
+
if (replyRoot) rootMessageId = replyRoot
|
|
960
|
+
} else {
|
|
961
|
+
logger.warn?.(`[wizard] auto-bind thread anchor reply failed (${anchor.reason || 'unknown'}); falling back to intro id`)
|
|
962
|
+
}
|
|
963
|
+
} catch (e) {
|
|
964
|
+
logger.warn?.(`[wizard] auto-bind thread anchor reply threw: ${e.message}; falling back to intro id`)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const route = {
|
|
969
|
+
targetUserId: String(chatId),
|
|
970
|
+
rootMessageId,
|
|
971
|
+
// anchor reply 的响应里 thread_id 才是真正的 thread;否则退回 sendMessage 的 thread_id
|
|
972
|
+
threadId: (threadAnchorPayload?.thread_id != null ? String(threadAnchorPayload.thread_id)
|
|
973
|
+
: (payload?.thread_id != null ? String(payload.thread_id) : null)),
|
|
974
|
+
topicName,
|
|
975
|
+
messageAppLink: payload?.message_app_link != null ? String(payload.message_app_link) : null,
|
|
976
|
+
channel: 'lark',
|
|
977
|
+
}
|
|
978
|
+
openclaw?.registerSessionRoute?.(sessionId, route)
|
|
979
|
+
loadingTracker?.start?.({ sessionId })?.catch?.((e) => logger.warn?.(`[wizard] loading-status start failed: ${e.message}`))
|
|
980
|
+
|
|
981
|
+
try {
|
|
982
|
+
const tnow = db.getTodo(todoId)
|
|
983
|
+
const updatedSessions = (tnow?.aiSessions || []).map((s) =>
|
|
984
|
+
s.sessionId === sessionId ? { ...s, larkRoute: route } : s,
|
|
985
|
+
)
|
|
986
|
+
if (updatedSessions.length) db.updateTodo(todoId, { aiSessions: updatedSessions })
|
|
987
|
+
} catch (e) {
|
|
988
|
+
logger.warn?.(`[wizard] persist lark auto-route failed: ${e.message}`)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
logger.info?.(`[wizard] auto-bound session ${sessionId} → lark thread root=${rootMessageId} (todo ${todoId})`)
|
|
992
|
+
return { ok: true, action: 'created', rootMessageId }
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* 通过 telegramRoute.threadId 反查 todo + aiSession。
|
|
997
|
+
* 优先 DB 持久化路由;缺失时用 bridge in-memory 路由 + aiTerminal.sessions 兜底
|
|
998
|
+
* (防老 session 没持久化 telegramRoute 但仍在内存里活着的情况)。
|
|
999
|
+
*/
|
|
1000
|
+
function findTodoByThreadId(chatId, threadId) {
|
|
1001
|
+
if (!threadId) return null
|
|
1002
|
+
const tid = Number(threadId)
|
|
1003
|
+
const todos = db.listTodos({ status: 'all' }) || db.listTodos() || []
|
|
1004
|
+
for (const t of todos) {
|
|
1005
|
+
const ai = (t.aiSessions || []).find((s) => s?.telegramRoute?.threadId === tid
|
|
1006
|
+
&& String(s.telegramRoute.targetUserId) === String(chatId))
|
|
1007
|
+
if (ai) return { todo: t, aiSession: ai }
|
|
1008
|
+
}
|
|
1009
|
+
// 兜底:bridge 内存路由
|
|
1010
|
+
if (openclaw?.findSessionByRoute && aiTerminal?.sessions) {
|
|
1011
|
+
const sid = openclaw.findSessionByRoute({ chatId: String(chatId), threadId: tid })
|
|
1012
|
+
if (sid) {
|
|
1013
|
+
const sess = aiTerminal.sessions.get(sid)
|
|
1014
|
+
if (sess?.todoId) {
|
|
1015
|
+
const todo = db.getTodo(sess.todoId)
|
|
1016
|
+
if (todo) {
|
|
1017
|
+
// 构造一个假 aiSession(DB 里没持久化,能跑就行)
|
|
1018
|
+
const route = openclaw.resolveRoute?.(sid) || {}
|
|
1019
|
+
const fakeAi = {
|
|
1020
|
+
sessionId: sid,
|
|
1021
|
+
tool: sess.tool,
|
|
1022
|
+
nativeSessionId: sess.nativeSessionId,
|
|
1023
|
+
telegramRoute: {
|
|
1024
|
+
targetUserId: route.targetUserId || String(chatId),
|
|
1025
|
+
threadId: tid,
|
|
1026
|
+
topicName: route.topicName || todo.title,
|
|
1027
|
+
channel: 'telegram',
|
|
1028
|
+
},
|
|
1029
|
+
}
|
|
1030
|
+
return { todo, aiSession: fakeAi }
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return null
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* 处理话题生命周期事件:关闭 / 重开。
|
|
1040
|
+
* 关闭:mark todo done,杀 PTY,清路由,话题改名 ✅。
|
|
1041
|
+
* 重开:respawn PTY (--resume nativeSessionId),恢复路由,撤掉 ✅。
|
|
1042
|
+
*/
|
|
1043
|
+
async function handleTopicEvent({ type, chatId, threadId } = {}) {
|
|
1044
|
+
if (!chatId || !threadId) return { ok: false, reason: 'missing_args' }
|
|
1045
|
+
if (type !== 'closed' && type !== 'reopened') return { ok: false, reason: 'unknown_type' }
|
|
1046
|
+
|
|
1047
|
+
const found = findTodoByThreadId(String(chatId), Number(threadId))
|
|
1048
|
+
if (!found) {
|
|
1049
|
+
logger.warn?.(`[wizard] topic ${type}: no todo bound to chatId=${chatId} threadId=${threadId}`)
|
|
1050
|
+
return { ok: false, reason: 'no_todo' }
|
|
1051
|
+
}
|
|
1052
|
+
const { todo, aiSession } = found
|
|
1053
|
+
const topicName = aiSession.telegramRoute?.topicName || todo.title
|
|
1054
|
+
|
|
1055
|
+
if (type === 'closed') {
|
|
1056
|
+
// 1) 杀 PTY(如果还活着,找它的 sessionId)
|
|
1057
|
+
// 关键:先在 session 上打"用户关话题"标记,PTY 的 done 事件晚到时
|
|
1058
|
+
// 不会用 stopped→'todo' 的默认逻辑覆写我们刚写的 status='done'。
|
|
1059
|
+
let killedSid = null
|
|
1060
|
+
if (openclaw?.findSessionByRoute) {
|
|
1061
|
+
const sid = openclaw.findSessionByRoute({ chatId: String(chatId), threadId })
|
|
1062
|
+
if (sid) {
|
|
1063
|
+
killedSid = sid
|
|
1064
|
+
const sess = aiTerminal?.sessions?.get?.(sid)
|
|
1065
|
+
if (sess) sess.userClosedReason = 'topic_closed'
|
|
1066
|
+
if (pty?.stop) {
|
|
1067
|
+
try { pty.stop(sid) } catch (e) { logger.warn?.(`[wizard] pty.stop failed: ${e.message}`) }
|
|
1068
|
+
}
|
|
1069
|
+
openclaw.clearSessionRoute?.(sid, 'topic-closed')
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// 2) 标 todo done(保留 aiSessions,里面 telegramRoute 还在 → 重开能反查)
|
|
1074
|
+
try {
|
|
1075
|
+
db.updateTodo(todo.id, { status: 'done', completedAt: Date.now() })
|
|
1076
|
+
} catch (e) {
|
|
1077
|
+
logger.warn?.(`[wizard] mark done failed: ${e.message}`)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// 3) 改话题名 ✅
|
|
1081
|
+
if (telegramBot?.editForumTopic && !topicName.startsWith('✅')) {
|
|
1082
|
+
telegramBot.editForumTopic({
|
|
1083
|
+
chatId,
|
|
1084
|
+
threadId,
|
|
1085
|
+
name: `✅ ${topicName}`.slice(0, 128),
|
|
1086
|
+
}).catch((e) => logger.warn?.(`[wizard] editForumTopic ✅ failed: ${e.message}`))
|
|
1087
|
+
}
|
|
1088
|
+
logger.info?.(`[wizard] topic closed → todo ${todo.id} done; killed sid=${killedSid || '(none alive)'}`)
|
|
1089
|
+
return { ok: true, action: 'closed', todoId: todo.id, killedSid }
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// type === 'reopened'
|
|
1093
|
+
if (!aiSession.nativeSessionId) {
|
|
1094
|
+
logger.warn?.(`[wizard] topic reopened but no nativeSessionId → cannot resume todo ${todo.id}`)
|
|
1095
|
+
return { ok: false, reason: 'no_native_session' }
|
|
1096
|
+
}
|
|
1097
|
+
const cfg = getConfig?.() || {}
|
|
1098
|
+
const port = cfg.port || 5677
|
|
1099
|
+
const permissionMode = telegramPermissionMode(cfg)
|
|
1100
|
+
const newSessionId = `ai-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
|
1101
|
+
const extraEnv = {
|
|
1102
|
+
QUADTODO_SESSION_ID: newSessionId,
|
|
1103
|
+
QUADTODO_TODO_ID: String(todo.id),
|
|
1104
|
+
QUADTODO_TODO_TITLE: String(todo.title),
|
|
1105
|
+
QUADTODO_URL: `http://127.0.0.1:${port}`,
|
|
1106
|
+
QUADTODO_TARGET_USER: String(chatId),
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
aiTerminal.spawnSession({
|
|
1110
|
+
sessionId: newSessionId,
|
|
1111
|
+
todoId: todo.id,
|
|
1112
|
+
prompt: '继续之前的任务',
|
|
1113
|
+
tool: aiSession.tool || 'claude',
|
|
1114
|
+
cwd: aiSession.cwd || cfg.defaultCwd || null,
|
|
1115
|
+
permissionMode,
|
|
1116
|
+
extraEnv,
|
|
1117
|
+
resumeNativeId: aiSession.nativeSessionId,
|
|
1118
|
+
skipTelegram: true, // 重开走的是已存在 topic(重注路由),不另建
|
|
1119
|
+
})
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
logger.warn?.(`[wizard] resume spawnSession failed: ${e.message}`)
|
|
1122
|
+
return { ok: false, reason: 'spawn_failed', detail: e.message }
|
|
1123
|
+
}
|
|
1124
|
+
if (openclaw?.registerSessionRoute) {
|
|
1125
|
+
openclaw.registerSessionRoute(newSessionId, {
|
|
1126
|
+
targetUserId: String(chatId),
|
|
1127
|
+
threadId,
|
|
1128
|
+
topicName: topicName.replace(/^✅\s*/, ''),
|
|
1129
|
+
channel: 'telegram',
|
|
1130
|
+
})
|
|
1131
|
+
}
|
|
1132
|
+
db.updateTodo(todo.id, { status: 'ai_running' })
|
|
1133
|
+
if (telegramBot?.editForumTopic && topicName.startsWith('✅')) {
|
|
1134
|
+
telegramBot.editForumTopic({
|
|
1135
|
+
chatId,
|
|
1136
|
+
threadId,
|
|
1137
|
+
name: topicName.replace(/^✅\s*/, '').slice(0, 128),
|
|
1138
|
+
}).catch((e) => logger.warn?.(`[wizard] editForumTopic restore failed: ${e.message}`))
|
|
1139
|
+
}
|
|
1140
|
+
if (telegramBot?.sendMessage) {
|
|
1141
|
+
telegramBot.sendMessage({
|
|
1142
|
+
chatId,
|
|
1143
|
+
threadId,
|
|
1144
|
+
text: `🔄 任务「${topicName.replace(/^✅\s*/, '')}」已恢复(resume nativeId=${aiSession.nativeSessionId.slice(0, 8)}…),继续聊吧。`,
|
|
1145
|
+
}).catch((e) => logger.warn?.(`[wizard] reopen welcome failed: ${e.message}`))
|
|
1146
|
+
}
|
|
1147
|
+
logger.info?.(`[wizard] topic reopened → todo ${todo.id} resumed; new sid=${newSessionId}`)
|
|
1148
|
+
return { ok: true, action: 'reopened', todoId: todo.id, sessionId: newSessionId }
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* 通过 larkRoute.rootMessageId 反查 todo + aiSession。
|
|
1153
|
+
* 优先 DB 持久化 larkRoute;缺失时用 bridge in-memory 路由 + aiTerminal.sessions 兜底。
|
|
1154
|
+
*/
|
|
1155
|
+
function findTodoByLarkRoot(chatId, rootMessageId) {
|
|
1156
|
+
if (!rootMessageId) return null
|
|
1157
|
+
const rmid = String(rootMessageId)
|
|
1158
|
+
const todos = db.listTodos({ status: 'all' }) || db.listTodos() || []
|
|
1159
|
+
for (const t of todos) {
|
|
1160
|
+
const ai = (t.aiSessions || []).find((s) => s?.larkRoute?.rootMessageId === rmid
|
|
1161
|
+
&& String(s.larkRoute.targetUserId) === String(chatId))
|
|
1162
|
+
if (ai) return { todo: t, aiSession: ai }
|
|
1163
|
+
}
|
|
1164
|
+
if (openclaw?.findSessionByRoute && aiTerminal?.sessions) {
|
|
1165
|
+
const sid = openclaw.findSessionByRoute({ channel: 'lark', chatId: String(chatId), rootMessageId: rmid })
|
|
1166
|
+
if (sid) {
|
|
1167
|
+
const sess = aiTerminal.sessions.get(sid)
|
|
1168
|
+
if (sess?.todoId) {
|
|
1169
|
+
const todo = db.getTodo(sess.todoId)
|
|
1170
|
+
if (todo) {
|
|
1171
|
+
const route = openclaw.resolveRoute?.(sid) || {}
|
|
1172
|
+
const fakeAi = {
|
|
1173
|
+
sessionId: sid,
|
|
1174
|
+
tool: sess.tool,
|
|
1175
|
+
nativeSessionId: sess.nativeSessionId,
|
|
1176
|
+
larkRoute: {
|
|
1177
|
+
targetUserId: route.targetUserId || String(chatId),
|
|
1178
|
+
rootMessageId: rmid,
|
|
1179
|
+
topicName: route.topicName || todo.title,
|
|
1180
|
+
channel: 'lark',
|
|
1181
|
+
},
|
|
1182
|
+
}
|
|
1183
|
+
return { todo, aiSession: fakeAi }
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return null
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Lark 版「关闭话题」语义:飞书没有 close-topic 原生事件,
|
|
1193
|
+
* 这里供同步对账(/api/sync)用 —— 当 PTY 已死且 todo 还没 done 时手动收尾。
|
|
1194
|
+
* 行为对齐 telegram 的 handleTopicEvent('closed'):
|
|
1195
|
+
* 1) 找到绑定到 (chatId, rootMessageId) 的 PTY session(如果还活着,杀掉)
|
|
1196
|
+
* 2) mark todo done,标 userClosedReason 防 PTY done 事件覆写
|
|
1197
|
+
* 3) 清 bridge 路由
|
|
1198
|
+
* 4) 在 thread 里回一条 "✅ 任务已完成" 收尾消息(让用户察觉到)
|
|
1199
|
+
* 不重命名根消息(lark patch 文本受限,不强求与 telegram 对齐)。
|
|
1200
|
+
*/
|
|
1201
|
+
async function handleLarkThreadClose({ chatId, rootMessageId } = {}) {
|
|
1202
|
+
if (!chatId || !rootMessageId) return { ok: false, reason: 'missing_args' }
|
|
1203
|
+
|
|
1204
|
+
const found = findTodoByLarkRoot(String(chatId), String(rootMessageId))
|
|
1205
|
+
if (!found) {
|
|
1206
|
+
logger.warn?.(`[wizard] lark thread close: no todo bound to chatId=${chatId} rootMessageId=${rootMessageId}`)
|
|
1207
|
+
return { ok: false, reason: 'no_todo' }
|
|
1208
|
+
}
|
|
1209
|
+
const { todo, aiSession } = found
|
|
1210
|
+
const topicName = aiSession.larkRoute?.topicName || todo.title
|
|
1211
|
+
|
|
1212
|
+
// 1) 杀 PTY(如果还活着)
|
|
1213
|
+
let killedSid = null
|
|
1214
|
+
if (openclaw?.findSessionByRoute) {
|
|
1215
|
+
const sid = openclaw.findSessionByRoute({
|
|
1216
|
+
channel: 'lark', chatId: String(chatId), rootMessageId: String(rootMessageId),
|
|
1217
|
+
})
|
|
1218
|
+
if (sid) {
|
|
1219
|
+
killedSid = sid
|
|
1220
|
+
const sess = aiTerminal?.sessions?.get?.(sid)
|
|
1221
|
+
if (sess) sess.userClosedReason = 'lark_thread_closed'
|
|
1222
|
+
if (pty?.stop) {
|
|
1223
|
+
try { pty.stop(sid) } catch (e) { logger.warn?.(`[wizard] pty.stop failed: ${e.message}`) }
|
|
1224
|
+
}
|
|
1225
|
+
openclaw.clearSessionRoute?.(sid, 'lark-thread-closed')
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// 2) 标 todo done
|
|
1230
|
+
try {
|
|
1231
|
+
db.updateTodo(todo.id, { status: 'done', completedAt: Date.now() })
|
|
1232
|
+
} catch (e) {
|
|
1233
|
+
logger.warn?.(`[wizard] mark done failed: ${e.message}`)
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// 3) 在 thread 里回收尾消息
|
|
1237
|
+
if (larkBot?.replyInThread) {
|
|
1238
|
+
larkBot.replyInThread({
|
|
1239
|
+
rootMessageId: String(rootMessageId),
|
|
1240
|
+
text: `✅ 任务「${topicName}」已完成(auto-sync)`,
|
|
1241
|
+
}).catch?.((e) => logger.warn?.(`[wizard] lark reply ✅ failed: ${e.message}`))
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
logger.info?.(`[wizard] lark thread closed → todo ${todo.id} done; killed sid=${killedSid || '(none alive)'}`)
|
|
1245
|
+
return { ok: true, action: 'closed', todoId: todo.id, killedSid }
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* 给状态机喂一条用户消息。两种入参形式:
|
|
1250
|
+
* 旧:{ peer, text } ← weixin / OpenClaw skill 路径
|
|
1251
|
+
* 新:{ chatId, threadId, text, fromUserId } ← Telegram 直连路径
|
|
1252
|
+
* 内部统一为 { chatId, threadId, text } + routeKey。
|
|
1253
|
+
*/
|
|
1254
|
+
async function handleInbound(args = {}) {
|
|
1255
|
+
// 兼容老路径
|
|
1256
|
+
const chatId = args.chatId != null ? String(args.chatId) : (args.peer != null ? String(args.peer) : null)
|
|
1257
|
+
const channel = args.channel || (chatId && /^-?\d+$/.test(String(chatId)) ? 'telegram' : 'openclaw')
|
|
1258
|
+
const threadId = args.threadId != null ? args.threadId : null
|
|
1259
|
+
const rootMessageId = args.rootMessageId != null ? String(args.rootMessageId) : null
|
|
1260
|
+
const text = args.text || ''
|
|
1261
|
+
const messageId = args.messageId != null ? args.messageId : null
|
|
1262
|
+
const replyToMessageId = args.replyToMessageId != null ? args.replyToMessageId : null
|
|
1263
|
+
const fromUserId = args.fromUserId != null ? String(args.fromUserId) : (args.userId != null ? String(args.userId) : null)
|
|
1264
|
+
// 入站图片本地路径(已下载好),格式:[abs_path, ...]
|
|
1265
|
+
const imagePaths = Array.isArray(args.imagePaths) ? args.imagePaths.filter(Boolean) : []
|
|
1266
|
+
const peer = chatId // 内部用,跟旧代码保持一致
|
|
1267
|
+
if (!chatId) return { reply: '⚠️ 缺 from chatId,无法路由' }
|
|
1268
|
+
// 空消息允许 —— 只要带了图就算有效输入
|
|
1269
|
+
if (!text && imagePaths.length === 0) return { reply: '🤔 空消息,请重试' }
|
|
1270
|
+
if (typeof text !== 'string') return { reply: '🤔 空消息,请重试' }
|
|
1271
|
+
const trimmed = text.trim()
|
|
1272
|
+
const routeKey = makeRouteKey(channel, chatId, threadId)
|
|
1273
|
+
const isLarkThreadReply = channel === 'lark' && (threadId || rootMessageId)
|
|
1274
|
+
|
|
1275
|
+
// Lark 任务话题/root 回复必须严格隔离到原始路由:不允许被全局 ask_user、新任务触发词、
|
|
1276
|
+
// lastPush 或单活跃 session 等模糊 fallback 消费,避免把群内任务线程回复送到不相关会话。
|
|
1277
|
+
//
|
|
1278
|
+
// 但 *用户自己新建的话题* 也是 thread message,并未绑过任何 AgentQuad session ——
|
|
1279
|
+
// 这种情况下要把消息当成普通群消息处理(让 NEW_TASK_TRIGGERS / wizard 能在新话题里启动)。
|
|
1280
|
+
// 所以 thread 路径只在 *找到了绑定到这个 thread 的 PTY session* 时启用 stdin proxy。
|
|
1281
|
+
let larkBoundThreadSid = null
|
|
1282
|
+
if (isLarkThreadReply && openclaw?.findSessionByRoute) {
|
|
1283
|
+
larkBoundThreadSid = openclaw.findSessionByRoute({ channel, chatId, threadId, rootMessageId }) || null
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (larkBoundThreadSid) {
|
|
1287
|
+
const sid = larkBoundThreadSid
|
|
1288
|
+
if (!pty?.write || !pty.has?.(sid)) {
|
|
1289
|
+
return {
|
|
1290
|
+
reply: '这个任务已结束,请在群里重新发起任务。',
|
|
1291
|
+
action: 'session_ended',
|
|
1292
|
+
sessionId: sid,
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
// 走 dispatcher(首选);未注入时回退到旧裸投递路径
|
|
1296
|
+
if (sessionInputDispatcher) {
|
|
1297
|
+
loadingTracker?.markRunning?.(sid)?.catch?.(() => {})
|
|
1298
|
+
try {
|
|
1299
|
+
const r = await sessionInputDispatcher.send({
|
|
1300
|
+
sessionId: sid,
|
|
1301
|
+
text: trimmed,
|
|
1302
|
+
imagePaths,
|
|
1303
|
+
channel: 'lark',
|
|
1304
|
+
echoTarget: { chatId, threadId, rootMessageId, messageId },
|
|
1305
|
+
})
|
|
1306
|
+
return mapDispatcherResultToWizardReply(r, sid, imagePaths)
|
|
1307
|
+
} catch (e) {
|
|
1308
|
+
logger.warn?.(`[wizard] lark dispatcher.send failed: ${e.message}`)
|
|
1309
|
+
return {
|
|
1310
|
+
reply: '⚠️ 投递失败,请重试。',
|
|
1311
|
+
action: 'dispatcher_error',
|
|
1312
|
+
sessionId: sid,
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
// Fallback: 裸 stdin proxy(兜底兼容)
|
|
1317
|
+
try {
|
|
1318
|
+
loadingTracker?.markRunning?.(sid)?.catch?.(() => {})
|
|
1319
|
+
try { aiTerminal?.markSessionAwaitingReply?.(sid, false) } catch { /* ignore */ }
|
|
1320
|
+
let payload = trimmed
|
|
1321
|
+
if (imagePaths.length > 0) {
|
|
1322
|
+
const ats = imagePaths.map((p) => `@${p}`).join(' ')
|
|
1323
|
+
payload = trimmed ? `${ats} ${trimmed}` : ats
|
|
1324
|
+
}
|
|
1325
|
+
pty.write(sid, payload)
|
|
1326
|
+
setTimeout(() => {
|
|
1327
|
+
try { pty.write(sid, '\r') } catch (e) {
|
|
1328
|
+
logger.warn?.(`[wizard] stdin proxy submit failed: ${e.message}`)
|
|
1329
|
+
}
|
|
1330
|
+
}, 80)
|
|
1331
|
+
return {
|
|
1332
|
+
reply: '',
|
|
1333
|
+
action: 'stdin_proxy',
|
|
1334
|
+
sessionId: sid,
|
|
1335
|
+
imagePaths: imagePaths.length > 0 ? imagePaths : undefined,
|
|
1336
|
+
}
|
|
1337
|
+
} catch (e) {
|
|
1338
|
+
logger.warn?.(`[wizard] lark exact stdin proxy failed: ${e.message}`)
|
|
1339
|
+
return {
|
|
1340
|
+
reply: '这个任务已结束,请在群里重新发起任务。',
|
|
1341
|
+
action: 'session_ended',
|
|
1342
|
+
sessionId: sid,
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// 0. ask_user 的 ✏️ 补充流:如果是回复我们的 force_reply 提示 → 拼"选项 · 补充"调 submitReply
|
|
1348
|
+
// 放在最顶 —— 优先级高于 wizard / ask_user ticket / PTY proxy;
|
|
1349
|
+
// 匹配条件极强(必须 reply_to 我们之前发的特定 messageId),不会误中。
|
|
1350
|
+
if (replyToMessageId && pending?.submitReply) {
|
|
1351
|
+
const ctx = consumeForceReplyContext(chatId, replyToMessageId)
|
|
1352
|
+
if (ctx) {
|
|
1353
|
+
const merged = buildExtendedReplyText(ctx.optionLabel, trimmed)
|
|
1354
|
+
// 显式 ticket 路由:用 #ticket 前缀确保不会误中"最新 pending"
|
|
1355
|
+
const result = pending.submitReply(`#${ctx.ticket} ${merged}`)
|
|
1356
|
+
if (result.matched) {
|
|
1357
|
+
return {
|
|
1358
|
+
reply: `✓ 已回答 [#${result.ticket}]\n ${merged.slice(0, 120)}${merged.length > 120 ? '…' : ''}`,
|
|
1359
|
+
action: 'ask_user_extended',
|
|
1360
|
+
ticket: result.ticket,
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
// ticket 已过期 / 被取消 → 友好降级
|
|
1364
|
+
return {
|
|
1365
|
+
reply: `⚠️ 这条补充对应的 ticket #${ctx.ticket} 已结束(${result.reason}),未送达 AI。`,
|
|
1366
|
+
action: 'ask_user_extended_stale',
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// 1. 取消语 + 有向导 → 中止
|
|
1372
|
+
if (CANCEL_TRIGGERS.some((re) => re.test(trimmed))) {
|
|
1373
|
+
const had = abortWizard(routeKey)
|
|
1374
|
+
if (had) return { reply: '✓ 已取消向导', action: 'wizard_cancelled' }
|
|
1375
|
+
// 无向导 — fallthrough 让 ask_user 也能取消
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// 1.5 退出 PTY 直连 → 清掉 lastPushedSession
|
|
1379
|
+
if (DETACH_TRIGGERS.some((re) => re.test(trimmed))) {
|
|
1380
|
+
const cleared = openclaw?.clearLastPushForPeer?.(peer)
|
|
1381
|
+
if (cleared) {
|
|
1382
|
+
return {
|
|
1383
|
+
reply: '✓ 已退出 PTY 直连模式。下次发消息会按普通流程处理(新任务 / fallback)。',
|
|
1384
|
+
action: 'detached',
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
return {
|
|
1388
|
+
reply: '🤔 当前不在 PTY 直连模式,没什么可退出的。',
|
|
1389
|
+
action: 'no_active_link',
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// 1.7 AgentQuad 全局 slash command(/list /pending /stop)
|
|
1394
|
+
//
|
|
1395
|
+
// 仅在 Telegram supergroup 内识别(chatId 以 -100 开头)。
|
|
1396
|
+
// - General 频道(threadId=null)→ 执行命令
|
|
1397
|
+
// - task topic 里发 → 拦截 + 提示去 General 用,避免被 PTY 转发污染 AI 上下文
|
|
1398
|
+
// - 非 supergroup(weixin / 私聊 / 任何不带 -100 前缀的 peer)→ fallthrough,
|
|
1399
|
+
// 交给老路径(PTY proxy / fallback),不假设那里有 General 概念
|
|
1400
|
+
//
|
|
1401
|
+
// 优先级:放在 active wizard / NEW_TASK / ask_user / PTY proxy 之前,
|
|
1402
|
+
// 这样在 wizard 进行中也能用 /list 看一眼任务列表(不影响 wizard 状态)。
|
|
1403
|
+
const quadtodoSlash = trimmed.match(/^\/([a-z][a-z0-9_]*)\b\s*(.*)$/i)
|
|
1404
|
+
const isSupergroup = channel === 'telegram' && chatId && /^-100\d+/.test(String(chatId))
|
|
1405
|
+
if (isSupergroup && quadtodoSlash && QUADTODO_GLOBAL_SLASH.has(quadtodoSlash[1].toLowerCase())) {
|
|
1406
|
+
const cmd = quadtodoSlash[1].toLowerCase()
|
|
1407
|
+
const argText = quadtodoSlash[2].trim()
|
|
1408
|
+
if (isGeneralChannel(chatId, threadId)) {
|
|
1409
|
+
return handleSlashCommand({ cmd, argText, chatId, threadId })
|
|
1410
|
+
}
|
|
1411
|
+
// task topic 里 /stop(无参数)→ dispatcher hard_cancel:中断当前 turn 但保留 session
|
|
1412
|
+
// 带参数(/stop all / /stop <短码>)仍 fallback 到 General 的 admin 杀 session 路径
|
|
1413
|
+
if (cmd === 'stop' && !argText && sessionInputDispatcher && openclaw?.findSessionByRoute) {
|
|
1414
|
+
const boundSid = openclaw.findSessionByRoute({ channel: 'telegram', chatId, threadId })
|
|
1415
|
+
if (boundSid && pty?.has?.(boundSid)) {
|
|
1416
|
+
try {
|
|
1417
|
+
const r = await sessionInputDispatcher.send({
|
|
1418
|
+
sessionId: boundSid,
|
|
1419
|
+
text: '/stop',
|
|
1420
|
+
channel: 'telegram',
|
|
1421
|
+
echoTarget: { chatId, threadId, messageId },
|
|
1422
|
+
})
|
|
1423
|
+
return mapDispatcherResultToWizardReply(r, boundSid, [])
|
|
1424
|
+
} catch (e) {
|
|
1425
|
+
logger.warn?.(`[wizard] in-topic /stop dispatcher failed: ${e.message}`)
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return {
|
|
1430
|
+
reply: `⚠️ /${cmd} 只在 General 频道用(避免污染当前 task topic 的 AI 上下文)。\n\n请到 General 里发 /${cmd}。`,
|
|
1431
|
+
action: 'slash_wrong_topic',
|
|
1432
|
+
blockedCommand: cmd,
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// 在 supergroup 内 task topic 里发"做X / 帮我做X"等任务触发词时,不该建新任务
|
|
1437
|
+
// —— 用户已在跟某个 PTY 对话,那段文字是要喂给 Claude Code 的,不是要再建任务。
|
|
1438
|
+
// 只在 General(threadId 空)才允许 NEW_TASK_TRIGGERS 启动 wizard。
|
|
1439
|
+
const isInTopicOfSupergroup =
|
|
1440
|
+
channel === 'telegram' && chatId && /^-100\d+/.test(String(chatId)) && threadId != null
|
|
1441
|
+
// unbound lark thread(用户自己新建的话题,没绑 session)也允许 NEW_TASK_TRIGGERS
|
|
1442
|
+
const newTaskGateOpen = !isInTopicOfSupergroup && !larkBoundThreadSid
|
|
1443
|
+
|
|
1444
|
+
// 2. 进行中 wizard → 推进
|
|
1445
|
+
const active = larkBoundThreadSid ? null : getActiveWizard(routeKey)
|
|
1446
|
+
if (active) {
|
|
1447
|
+
// 触发完成动作的 message id 总是最新一条 → 滚动更新
|
|
1448
|
+
if (messageId) active.triggerMessageId = messageId
|
|
1449
|
+
// wizard 中途用户又发图片 → 累加(同一个任务可以分多条消息上传图)
|
|
1450
|
+
if (imagePaths.length > 0) {
|
|
1451
|
+
active.imagePaths = [...(active.imagePaths || []), ...imagePaths]
|
|
1452
|
+
}
|
|
1453
|
+
// 如果用户在 wizard 中又发新任务触发词 → 重启(仅在 General/DM/普通群有效)
|
|
1454
|
+
if (newTaskGateOpen && NEW_TASK_TRIGGERS.some((re) => re.test(trimmed))) {
|
|
1455
|
+
wizards.delete(routeKey)
|
|
1456
|
+
const w = startWizard({ channel, chatId, threadId, text: trimmed, messageId, rootMessageId, imagePaths, userId: fromUserId })
|
|
1457
|
+
if (w.step === STEP_DONE) return await finalizeWizard(w)
|
|
1458
|
+
if (w.step === STEP_QUADRANT) {
|
|
1459
|
+
const p = buildQuadrantPrompt()
|
|
1460
|
+
return { reply: `(已重启向导,跳过目录步)\n${p.text}`, replyMarkup: p.replyMarkup }
|
|
1461
|
+
}
|
|
1462
|
+
if (w.step === STEP_TEMPLATE) {
|
|
1463
|
+
const tpls = db.listTemplates()
|
|
1464
|
+
w.cachedTemplates = tpls
|
|
1465
|
+
const p = buildTemplatePrompt(tpls)
|
|
1466
|
+
return { reply: `(已重启向导,跳过目录+象限步)\n${p.text}`, replyMarkup: p.replyMarkup }
|
|
1467
|
+
}
|
|
1468
|
+
const p = buildWorkdirPrompt(w.workdirOptions)
|
|
1469
|
+
return { reply: `(已重启向导)\n${p.text}`, replyMarkup: p.replyMarkup, action: 'wizard_started' }
|
|
1470
|
+
}
|
|
1471
|
+
const out = await advance(active, trimmed)
|
|
1472
|
+
return { ...out, action: out.done ? 'wizard_done' : 'wizard_step' }
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// 3. 看起来像新任务 → 启动向导(必须在 ask_user 路由之前,避免被当 free text 吃掉)
|
|
1476
|
+
// 仅在 General/DM/普通群触发;在 supergroup task topic 里"做 X"是给已有 PTY 的输入,
|
|
1477
|
+
// 不该建新任务(避免污染 task 上下文 + 防止用户被意外拉进 wizard)
|
|
1478
|
+
if (newTaskGateOpen && NEW_TASK_TRIGGERS.some((re) => re.test(trimmed))) {
|
|
1479
|
+
const w = startWizard({ channel, chatId, threadId, text: trimmed, messageId, rootMessageId, imagePaths, userId: fromUserId })
|
|
1480
|
+
if (w.step === STEP_DONE) return await finalizeWizard(w)
|
|
1481
|
+
if (w.step === STEP_QUADRANT) {
|
|
1482
|
+
const p = buildQuadrantPrompt()
|
|
1483
|
+
return {
|
|
1484
|
+
reply: `任务: ${w.title}\n(目录已识别为 ${w.chosenWorkdir})\n\n${p.text}`,
|
|
1485
|
+
replyMarkup: p.replyMarkup,
|
|
1486
|
+
action: 'wizard_started',
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
if (w.step === STEP_TEMPLATE) {
|
|
1490
|
+
const tpls = db.listTemplates()
|
|
1491
|
+
w.cachedTemplates = tpls
|
|
1492
|
+
const p = buildTemplatePrompt(tpls)
|
|
1493
|
+
return {
|
|
1494
|
+
reply: `任务: ${w.title}\n(目录+象限已识别)\n\n${p.text}`,
|
|
1495
|
+
replyMarkup: p.replyMarkup,
|
|
1496
|
+
action: 'wizard_started',
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
const p = buildWorkdirPrompt(w.workdirOptions)
|
|
1500
|
+
return {
|
|
1501
|
+
reply: `任务: ${w.title}\n\n${p.text}`,
|
|
1502
|
+
replyMarkup: p.replyMarkup,
|
|
1503
|
+
action: 'wizard_started',
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// 4. pending ask_user → 路由
|
|
1508
|
+
if (pending?.submitReply) {
|
|
1509
|
+
const probe = pending.submitReply(trimmed)
|
|
1510
|
+
if (probe.matched) {
|
|
1511
|
+
const lines = [
|
|
1512
|
+
`✓ 已回复 [#${probe.ticket}]`,
|
|
1513
|
+
]
|
|
1514
|
+
if (probe.chosen != null) lines.push(` 选了: ${probe.chosenIndex + 1}. ${probe.chosen}`)
|
|
1515
|
+
else lines.push(` 自由文本回填: ${probe.answerText.slice(0, 80)}`)
|
|
1516
|
+
return { reply: lines.join('\n'), action: 'ask_user_replied', ticket: probe.ticket }
|
|
1517
|
+
}
|
|
1518
|
+
// probe.reason: empty | no_pending | ticket_not_pending
|
|
1519
|
+
if (probe.reason === 'ticket_not_pending') {
|
|
1520
|
+
return { reply: `⚠️ ticket #${probe.ticket} 已结束(超时/取消/已答复),无需再回。`, action: 'ask_user_stale' }
|
|
1521
|
+
}
|
|
1522
|
+
// no_pending / empty → fallthrough
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// 5. PTY stdin proxy:4 级路由策略
|
|
1526
|
+
// a) Telegram thread 路由:当前消息来自一个 task topic →反查绑定到这个 (chatId, threadId)
|
|
1527
|
+
// 的 PTY session(最准确,0 歧义)
|
|
1528
|
+
// b) peer 最近收过推送(lastPushByPeer)→ 那个 session
|
|
1529
|
+
// c) 系统里只有 1 个活跃 PTY session → 写它(单用户 + 单任务场景)
|
|
1530
|
+
// d) 多个活跃 → 列出来让用户选
|
|
1531
|
+
//
|
|
1532
|
+
// 安全门:supergroup 的 General 频道(threadId 空)禁用 (b)/(c) 模糊匹配 ——
|
|
1533
|
+
// 用户截图反馈:"General 发的话不该转给任何 PTY,差点污染上下文"。
|
|
1534
|
+
// 在 General 只允许严格 thread 匹配 (a),但 (a) 在 General 里必然匹配失败(没 threadId),
|
|
1535
|
+
// 等于完全跳过 stdin proxy → 落到 fallback reply 提示用户。
|
|
1536
|
+
const isInGeneralOfSupergroup =
|
|
1537
|
+
channel === 'telegram' && chatId && /^-100\d+/.test(String(chatId)) && (threadId == null || threadId === undefined)
|
|
1538
|
+
if (pty?.write && !isInGeneralOfSupergroup) {
|
|
1539
|
+
const targetSid = (() => {
|
|
1540
|
+
// a) thread/root 精确路由:找绑定到 (channel, chatId, threadId/rootMessageId) 的 sessionId
|
|
1541
|
+
if ((threadId || rootMessageId) && openclaw?.findSessionByRoute) {
|
|
1542
|
+
const sid = openclaw.findSessionByRoute({ channel, chatId, threadId, rootMessageId })
|
|
1543
|
+
if (sid && pty.has?.(sid)) return sid
|
|
1544
|
+
if (sid && channel === 'lark') return { ended: true, sid }
|
|
1545
|
+
if (channel === 'lark') return { notFound: true }
|
|
1546
|
+
}
|
|
1547
|
+
// b) lastPushByPeer
|
|
1548
|
+
const recent = openclaw?.getLastPushedSession?.(peer)
|
|
1549
|
+
if (recent && pty.has?.(recent)) return recent
|
|
1550
|
+
// c) fallback:系统里的活跃 session(带 todo 上下文,给 ambiguous 提示用)
|
|
1551
|
+
if (!aiTerminal?.sessions) return null
|
|
1552
|
+
const enriched = findActiveSessions() // 已含 todo title + 按时间倒序
|
|
1553
|
+
if (enriched.length === 1) return enriched[0].sid
|
|
1554
|
+
if (enriched.length > 1) {
|
|
1555
|
+
return { ambiguous: true, candidates: enriched }
|
|
1556
|
+
}
|
|
1557
|
+
return null
|
|
1558
|
+
})()
|
|
1559
|
+
|
|
1560
|
+
if (targetSid && typeof targetSid === 'object' && targetSid.ended) {
|
|
1561
|
+
return {
|
|
1562
|
+
reply: '这个任务已结束,请在群里重新发起任务。',
|
|
1563
|
+
action: 'session_ended',
|
|
1564
|
+
sessionId: targetSid.sid,
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
if (targetSid && typeof targetSid === 'object' && targetSid.notFound) {
|
|
1568
|
+
return {
|
|
1569
|
+
reply: '没有找到对应运行中的任务',
|
|
1570
|
+
action: 'session_not_found',
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
if (targetSid && typeof targetSid === 'string') {
|
|
1574
|
+
// ESC 兜底:用户已经卡在 modal 里 → 发文本 "esc" / "退出菜单" / "cancel modal"
|
|
1575
|
+
// 我们把它翻译成单字节 \x1b 写到 PTY stdin,触发 Claude Code TUI 退出 modal
|
|
1576
|
+
if (ESC_TRIGGERS.some((re) => re.test(trimmed))) {
|
|
1577
|
+
try {
|
|
1578
|
+
pty.write(targetSid, '\x1b')
|
|
1579
|
+
return {
|
|
1580
|
+
reply: '✓ 已发送 Esc(应该会退出当前 modal)',
|
|
1581
|
+
action: 'stdin_proxy_esc',
|
|
1582
|
+
sessionId: targetSid,
|
|
1583
|
+
}
|
|
1584
|
+
} catch (e) {
|
|
1585
|
+
logger.warn?.(`[wizard] esc proxy failed: ${e.message}`)
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Ctrl+C 中断:写 `\x03` 到 PTY → SIGINT → Claude Code 打断当前 turn / 工具执行
|
|
1590
|
+
if (INTERRUPT_TRIGGERS.some((re) => re.test(trimmed))) {
|
|
1591
|
+
try {
|
|
1592
|
+
pty.write(targetSid, '\x03')
|
|
1593
|
+
return {
|
|
1594
|
+
reply: '✓ 已发送 Ctrl+C(应该会打断当前任务)',
|
|
1595
|
+
action: 'stdin_proxy_interrupt',
|
|
1596
|
+
sessionId: targetSid,
|
|
1597
|
+
}
|
|
1598
|
+
} catch (e) {
|
|
1599
|
+
logger.warn?.(`[wizard] interrupt proxy failed: ${e.message}`)
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// 黑名单:interactive slash command 在 Telegram 没法用(Esc 发不出来 → 卡死)
|
|
1604
|
+
// 拦截不转发,引导用户去 web 终端
|
|
1605
|
+
const slashMatch = trimmed.match(/^\/([a-z0-9_]+)\b/i)
|
|
1606
|
+
if (slashMatch && INTERACTIVE_SLASH_COMMANDS.has(slashMatch[1].toLowerCase())) {
|
|
1607
|
+
return {
|
|
1608
|
+
reply: [
|
|
1609
|
+
`⚠️ /${slashMatch[1]} 是 Claude Code 的 modal 命令,在 Telegram 用会卡死`,
|
|
1610
|
+
`(modal 要按 Esc 退出,Telegram 发不出 Esc 键)`,
|
|
1611
|
+
``,
|
|
1612
|
+
`请去 web 终端用:http://127.0.0.1:5677/ai-terminal`,
|
|
1613
|
+
``,
|
|
1614
|
+
`如果你已经卡在 modal 里:回 esc / 退出菜单 → 我帮你发 Esc 键`,
|
|
1615
|
+
].join('\n'),
|
|
1616
|
+
action: 'interactive_command_blocked',
|
|
1617
|
+
blocked: slashMatch[1].toLowerCase(),
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// 走 dispatcher(首选);未注入时回退到旧裸投递路径
|
|
1622
|
+
if (sessionInputDispatcher) {
|
|
1623
|
+
loadingTracker?.markRunning?.(targetSid)?.catch?.(() => {})
|
|
1624
|
+
try {
|
|
1625
|
+
const r = await sessionInputDispatcher.send({
|
|
1626
|
+
sessionId: targetSid,
|
|
1627
|
+
text: trimmed,
|
|
1628
|
+
imagePaths,
|
|
1629
|
+
channel: 'telegram',
|
|
1630
|
+
echoTarget: { chatId, threadId, messageId },
|
|
1631
|
+
})
|
|
1632
|
+
const wizardReply = mapDispatcherResultToWizardReply(r, targetSid, imagePaths)
|
|
1633
|
+
// 保留 first-route hint:dispatcher 返回 'sent' 时叠加到 reply 上
|
|
1634
|
+
if (r.action === 'sent' && shouldAnnounceFirstRoute(peer, targetSid)) {
|
|
1635
|
+
const title = lookupTodoTitleForSession(targetSid)
|
|
1636
|
+
if (title) {
|
|
1637
|
+
wizardReply.reply = `📍 已发给 「${title}」 (#${targetSid.slice(-4)})\n(之后这条 chat 默认都发给它,不再提醒)`
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
return wizardReply
|
|
1641
|
+
} catch (e) {
|
|
1642
|
+
logger.warn?.(`[wizard] telegram dispatcher.send failed: ${e.message}`)
|
|
1643
|
+
return { reply: '⚠️ 投递失败,请重试。', action: 'dispatcher_error', sessionId: targetSid }
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
// Fallback: 裸 stdin proxy(兜底兼容)
|
|
1647
|
+
try {
|
|
1648
|
+
// 用户从 telegram 发新输入 → 标题切回 🔄(如果当前是 💤)
|
|
1649
|
+
loadingTracker?.markRunning?.(targetSid)?.catch?.(() => {})
|
|
1650
|
+
try { aiTerminal?.markSessionAwaitingReply?.(targetSid, false) } catch { /* ignore */ }
|
|
1651
|
+
// 组装 PTY 输入:图片用 Claude Code 的 `@path` attach 语法
|
|
1652
|
+
// 纯文本: "text"
|
|
1653
|
+
// 纯图片: "@/path/to/file.jpg"
|
|
1654
|
+
// 图+caption: "@/path/to/file.jpg caption text"
|
|
1655
|
+
// 多图+text: "@/p1 @/p2 caption text"
|
|
1656
|
+
let payload = trimmed
|
|
1657
|
+
if (imagePaths.length > 0) {
|
|
1658
|
+
const ats = imagePaths.map((p) => `@${p}`).join(' ')
|
|
1659
|
+
payload = trimmed ? `${ats} ${trimmed}` : ats
|
|
1660
|
+
}
|
|
1661
|
+
// 拆两步写:先正文,延迟 80ms 再发 \r。
|
|
1662
|
+
// 一次性写 "text+\r" 时 Claude Code TUI 把整段当 paste 缓冲,文字进了输入框但
|
|
1663
|
+
// \r 没被识别为独立的"提交"事件 —— 表现为消息卡在 prompt 不被发送。
|
|
1664
|
+
// 拆开后 TUI 先把文字渲染到输入框,再把单独到达的 \r 当作 Enter 按键处理。
|
|
1665
|
+
pty.write(targetSid, payload)
|
|
1666
|
+
setTimeout(() => {
|
|
1667
|
+
try { pty.write(targetSid, '\r') } catch (e) {
|
|
1668
|
+
logger.warn?.(`[wizard] stdin proxy submit failed: ${e.message}`)
|
|
1669
|
+
}
|
|
1670
|
+
}, 80)
|
|
1671
|
+
// 静默成功:默认返回空 reply(OpenClaw skill 收到空 stdout 不发消息给用户,
|
|
1672
|
+
// AI 回话由 Stop hook 单独推送,体验干净)。
|
|
1673
|
+
// 例外:当前 (peer, sid) 第一次路由时回一条小提示,让用户知道发给了哪个 todo —— 解决
|
|
1674
|
+
// 重启后多 session resume + 静默路由 = 用户完全不知道发给谁的盲点。
|
|
1675
|
+
let firstHint = ''
|
|
1676
|
+
if (shouldAnnounceFirstRoute(peer, targetSid)) {
|
|
1677
|
+
const title = lookupTodoTitleForSession(targetSid)
|
|
1678
|
+
if (title) {
|
|
1679
|
+
firstHint = `📍 已发给 「${title}」 (#${targetSid.slice(-4)})\n(之后这条 chat 默认都发给它,不再提醒)`
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return {
|
|
1683
|
+
reply: firstHint,
|
|
1684
|
+
action: 'stdin_proxy',
|
|
1685
|
+
sessionId: targetSid,
|
|
1686
|
+
imagePaths: imagePaths.length > 0 ? imagePaths : undefined,
|
|
1687
|
+
}
|
|
1688
|
+
} catch (e) {
|
|
1689
|
+
logger.warn?.(`[wizard] stdin proxy failed: ${e.message}`)
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
if (targetSid && typeof targetSid === 'object' && targetSid.ambiguous) {
|
|
1693
|
+
// 多个活跃 session:列 todo title + 时间,并附 inline keyboard 让用户一键绑定。
|
|
1694
|
+
// 短码统一用 sid.slice(-4)(跟 ticket 风格对齐)。绑定语义:写 lastPushByPeer,
|
|
1695
|
+
// 用户**重新发**一条消息时自动路由到所选 session(不重放当前这条,避免状态机化)。
|
|
1696
|
+
const top = targetSid.candidates.slice(0, 5)
|
|
1697
|
+
const lines = top.map((c, i) => {
|
|
1698
|
+
const title = c.todo?.title ? truncateTitle(c.todo.title, 28) : '(未知 todo)'
|
|
1699
|
+
const short = c.sid.slice(-4)
|
|
1700
|
+
return `${i + 1}. #${short} · ${title} ${formatTimeAgo(c.lastOutputAt)}`
|
|
1701
|
+
})
|
|
1702
|
+
const buttons = top.map((c) => {
|
|
1703
|
+
const title = c.todo?.title ? truncateTitle(c.todo.title, 22) : '(未知)'
|
|
1704
|
+
return [{ text: `📦 ${title}`, callback_data: `qt:rt:${c.sid.slice(-4)}` }]
|
|
1705
|
+
})
|
|
1706
|
+
return {
|
|
1707
|
+
reply: [
|
|
1708
|
+
'🔀 多个活跃 AI session,回的不知发给谁:',
|
|
1709
|
+
'',
|
|
1710
|
+
...lines,
|
|
1711
|
+
'',
|
|
1712
|
+
'👆 点按钮选 session(之后这条 chat 都默认发给它),',
|
|
1713
|
+
`或回 "#${top[0].sid.slice(-4)} <内容>" 指定。`,
|
|
1714
|
+
].join('\n'),
|
|
1715
|
+
replyMarkup: { inline_keyboard: buttons },
|
|
1716
|
+
action: 'stdin_proxy_ambiguous',
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// 6. fallback
|
|
1722
|
+
// General 频道里专门提示:保护 PTY 上下文不被污染
|
|
1723
|
+
if (isInGeneralOfSupergroup) {
|
|
1724
|
+
return {
|
|
1725
|
+
reply: [
|
|
1726
|
+
'🤔 这条消息没匹配到任何意图。',
|
|
1727
|
+
'',
|
|
1728
|
+
'General 频道不会把消息盲目转给已有任务(避免污染 Claude Code 上下文)。',
|
|
1729
|
+
'',
|
|
1730
|
+
'想做什么:',
|
|
1731
|
+
' • 新建任务:发「帮我做 X」 / 「新建任务: X」',
|
|
1732
|
+
' • 跟某个任务对话:去对应 task topic 里发',
|
|
1733
|
+
' • 回答 AI 问题:在 ask_user ticket 所在 topic 内回数字',
|
|
1734
|
+
].join('\n'),
|
|
1735
|
+
action: 'fallback',
|
|
1736
|
+
reason: 'general_channel_no_intent',
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return {
|
|
1740
|
+
reply: '🤔 我没看懂这条消息。\n\n要新建任务,发:\n • 新建任务: 修复 X\n • 帮我做 X\n要回答 AI 的问题,发:\n • 数字 1/2/3\n • #xxx 1(指定 ticket)\n要直接给 AI 发指令,先等它推送一条给你,回复就会转过去。',
|
|
1741
|
+
action: 'fallback',
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* 处理 inline keyboard 按钮点击。
|
|
1747
|
+
*
|
|
1748
|
+
* 入参:{chatId, threadId, callbackData, callbackMessageId, fromUserId}
|
|
1749
|
+
* 出参:
|
|
1750
|
+
* - reply?: string — 发的下一步 prompt 文本(caller sendMessage)
|
|
1751
|
+
* - replyMarkup?: object — 下一步 prompt 的按钮(可选)
|
|
1752
|
+
* - chosenLabel?: string — caller 拿来在原消息末尾打 "✓ 已选: …" 标记
|
|
1753
|
+
* - toast?: string — answerCallbackQuery 的轻提示
|
|
1754
|
+
* - editOriginal?: boolean — 默认 true,去除原消息按钮 + 加 ✓ 标记
|
|
1755
|
+
* - action: string — wizard_step|wizard_done|wizard_custom_workdir|invalid|expired
|
|
1756
|
+
*
|
|
1757
|
+
* callback_data 协议:
|
|
1758
|
+
* qt:wd:<idx> 工作目录(按 listWorkdirOptions 顺序)
|
|
1759
|
+
* qt:wd:custom 自定义路径 → 进入 awaitingCustomWorkdir 子态,等下一条文本
|
|
1760
|
+
* qt:q:<1..4> 象限
|
|
1761
|
+
* qt:t:<idx> 模板
|
|
1762
|
+
* qt:t:none 自由模式(不套模板)
|
|
1763
|
+
*
|
|
1764
|
+
* 安全:所有未知 / 不匹配当前 step 的 callback 都返回 toast,从不 throw —— 让
|
|
1765
|
+
* telegram-bot 始终能 answerCallbackQuery 关 loading。
|
|
1766
|
+
*/
|
|
1767
|
+
async function handleCallback(args = {}) {
|
|
1768
|
+
const chatId = args.chatId != null ? String(args.chatId) : null
|
|
1769
|
+
const threadId = args.threadId != null ? args.threadId : null
|
|
1770
|
+
const callbackData = String(args.callbackData || '')
|
|
1771
|
+
if (!chatId) return { toast: '⚠️ 缺 chatId', action: 'invalid' }
|
|
1772
|
+
if (!callbackData) return { toast: '空 callback', action: 'invalid' }
|
|
1773
|
+
|
|
1774
|
+
// ── ask_user 按钮路径 ──────────────────────────────────────────
|
|
1775
|
+
// 这个路径**不依赖 wizard 状态**,所以独立在 wizard lookup 之前
|
|
1776
|
+
// callback_data 形如 qt:ans:<ticket>:<idx> / qt:ext:<ticket>:<idx>
|
|
1777
|
+
const askCb = parseCallbackData(callbackData)
|
|
1778
|
+
if (askCb && (askCb.kind === CB_KIND_ANSWER || askCb.kind === CB_KIND_EXTEND)) {
|
|
1779
|
+
return handleAskUserCallback(askCb, { chatId, threadId })
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// ── 权限按钮路径(qt:perm:<short>:allow|deny)──────────────────
|
|
1783
|
+
const permCb = parsePermissionCallback(callbackData)
|
|
1784
|
+
if (permCb) {
|
|
1785
|
+
return handlePermissionCallback(permCb, { chatId, threadId })
|
|
1786
|
+
}
|
|
1787
|
+
if (callbackData.startsWith(`${CALLBACK_PREFIX}:${PERMISSION_CALLBACK_KIND}:`)) {
|
|
1788
|
+
return { toast: '无效的权限按钮', action: 'invalid', editOriginal: true }
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// ── 多 session ambiguous 按钮路径(qt:rt:<short>)─────────────
|
|
1792
|
+
// 用户从 ambiguous 提示里点了某个 session → 写 lastPushByPeer 把这个 chat 绑过去,
|
|
1793
|
+
// 用户**重新发**一条消息时自动路由到所选 session(不重放当前消息,避免状态机化)。
|
|
1794
|
+
if (callbackData.startsWith('qt:rt:')) {
|
|
1795
|
+
return handleRouteCallback(callbackData.slice(6), { chatId, threadId })
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
const channel = args.channel || (chatId && /^-?\d+$/.test(String(chatId)) ? 'telegram' : 'openclaw')
|
|
1799
|
+
const routeKey = makeRouteKey(channel, chatId, threadId)
|
|
1800
|
+
const w = getActiveWizard(routeKey)
|
|
1801
|
+
if (!w) {
|
|
1802
|
+
// wizard 已超时 / 不存在 → 提示用户重启,editOriginal=true 顺手把按钮去掉
|
|
1803
|
+
return {
|
|
1804
|
+
toast: '向导已超时',
|
|
1805
|
+
reply: '🤔 这个向导已经超时(>10 分钟未操作),请重发触发词重启。',
|
|
1806
|
+
action: 'expired',
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
w.updatedAt = Date.now()
|
|
1810
|
+
|
|
1811
|
+
// 解析 qt:<kind>:<value>
|
|
1812
|
+
const parts = callbackData.split(':')
|
|
1813
|
+
if (parts.length < 3 || parts[0] !== CALLBACK_PREFIX) {
|
|
1814
|
+
return { toast: '无效的按钮', action: 'invalid' }
|
|
1815
|
+
}
|
|
1816
|
+
const kind = parts[1]
|
|
1817
|
+
const value = parts.slice(2).join(':') // 防 path 里包含 ':' 的边界
|
|
1818
|
+
|
|
1819
|
+
// ── workdir step ──────────────────────────────
|
|
1820
|
+
if (kind === 'wd') {
|
|
1821
|
+
if (w.step !== STEP_WORKDIR) {
|
|
1822
|
+
return { toast: '当前步骤不接受目录选择', action: 'invalid' }
|
|
1823
|
+
}
|
|
1824
|
+
if (value === 'custom') {
|
|
1825
|
+
// 进入子态:等用户下一条文本作为路径,不再校验 / / ~
|
|
1826
|
+
w.awaitingCustomWorkdir = true
|
|
1827
|
+
return {
|
|
1828
|
+
chosenLabel: '自定义路径',
|
|
1829
|
+
reply: '🖋 请直接输入完整路径(绝对路径或 ~/ 开头都行)',
|
|
1830
|
+
action: 'wizard_custom_workdir',
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
const idx = Number(value)
|
|
1834
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= w.workdirOptions.length) {
|
|
1835
|
+
return { toast: '选项无效', action: 'invalid' }
|
|
1836
|
+
}
|
|
1837
|
+
w.chosenWorkdir = w.workdirOptions[idx].path
|
|
1838
|
+
w.step = STEP_QUADRANT
|
|
1839
|
+
const prompt = buildQuadrantPrompt()
|
|
1840
|
+
return {
|
|
1841
|
+
chosenLabel: w.chosenWorkdir,
|
|
1842
|
+
reply: prompt.text,
|
|
1843
|
+
replyMarkup: prompt.replyMarkup,
|
|
1844
|
+
action: 'wizard_step',
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// ── quadrant step ─────────────────────────────
|
|
1849
|
+
if (kind === 'q') {
|
|
1850
|
+
if (w.step !== STEP_QUADRANT) {
|
|
1851
|
+
return { toast: '当前步骤不接受象限选择', action: 'invalid' }
|
|
1852
|
+
}
|
|
1853
|
+
const q = Number(value)
|
|
1854
|
+
if (![1, 2, 3, 4].includes(q)) return { toast: '象限无效', action: 'invalid' }
|
|
1855
|
+
w.chosenQuadrant = q
|
|
1856
|
+
w.step = STEP_TEMPLATE
|
|
1857
|
+
const templates = db.listTemplates()
|
|
1858
|
+
w.cachedTemplates = templates
|
|
1859
|
+
const prompt = buildTemplatePrompt(templates)
|
|
1860
|
+
const qLabel = QUADRANTS.find((x) => x.id === q)?.label || ''
|
|
1861
|
+
return {
|
|
1862
|
+
chosenLabel: `Q${q} ${qLabel}`.trim(),
|
|
1863
|
+
reply: prompt.text,
|
|
1864
|
+
replyMarkup: prompt.replyMarkup,
|
|
1865
|
+
action: 'wizard_step',
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// ── template step ─────────────────────────────
|
|
1870
|
+
if (kind === 't') {
|
|
1871
|
+
if (w.step !== STEP_TEMPLATE) {
|
|
1872
|
+
return { toast: '当前步骤不接受模板选择', action: 'invalid' }
|
|
1873
|
+
}
|
|
1874
|
+
const templates = w.cachedTemplates || db.listTemplates()
|
|
1875
|
+
let label
|
|
1876
|
+
if (value === 'none') {
|
|
1877
|
+
w.chosenTemplate = null
|
|
1878
|
+
label = '自由模式'
|
|
1879
|
+
} else {
|
|
1880
|
+
const idx = Number(value)
|
|
1881
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= templates.length) {
|
|
1882
|
+
return { toast: '模板无效', action: 'invalid' }
|
|
1883
|
+
}
|
|
1884
|
+
w.chosenTemplate = { id: templates[idx].id, name: templates[idx].name }
|
|
1885
|
+
label = templates[idx].name
|
|
1886
|
+
}
|
|
1887
|
+
const out = await finalizeWizard(w)
|
|
1888
|
+
return {
|
|
1889
|
+
chosenLabel: label,
|
|
1890
|
+
reply: out.reply,
|
|
1891
|
+
// finalize 返回的 ack 不再带按钮
|
|
1892
|
+
action: out.done ? 'wizard_done' : (out.action || 'wizard_step'),
|
|
1893
|
+
todoId: out.todoId,
|
|
1894
|
+
threadId: out.threadId,
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
return { toast: '未知按钮', action: 'invalid' }
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// ─── 权限按钮回调 ───────────────────────────────────────────────
|
|
1902
|
+
async function handlePermissionCallback({ short, action } = {}, { chatId, threadId } = {}) {
|
|
1903
|
+
const stale = () => ({
|
|
1904
|
+
toast: '会话已结束',
|
|
1905
|
+
reply: `⚠️ 会话已结束(#${short}),无法发送权限选择。`,
|
|
1906
|
+
action: 'permission_session_stale',
|
|
1907
|
+
editOriginal: true,
|
|
1908
|
+
})
|
|
1909
|
+
|
|
1910
|
+
const sid = openclaw?.findSessionByShortId?.(short) || null
|
|
1911
|
+
if (!sid || !pty?.has?.(sid)) return stale()
|
|
1912
|
+
const route = openclaw?.resolveRoute?.(sid) || null
|
|
1913
|
+
const sameChat = route && String(route.targetUserId) === String(chatId)
|
|
1914
|
+
const sameThread = (route?.threadId || null) === (threadId || null)
|
|
1915
|
+
// 允许 telegram / lark 渠道的权限回调;老的"threadId 非空就算"留作 legacy 兜底。
|
|
1916
|
+
const isRoutedChannel = route?.channel === 'telegram' || route?.channel === 'lark' || route?.threadId != null
|
|
1917
|
+
if (!sameChat || !sameThread || !isRoutedChannel) return stale()
|
|
1918
|
+
|
|
1919
|
+
if (action === PERMISSION_ACTION_ALLOW) {
|
|
1920
|
+
try {
|
|
1921
|
+
pty.write(sid, '\r')
|
|
1922
|
+
} catch (e) {
|
|
1923
|
+
logger.warn?.(`[wizard] permission allow write failed: ${e.message}`)
|
|
1924
|
+
return stale()
|
|
1925
|
+
}
|
|
1926
|
+
return {
|
|
1927
|
+
toast: '已发送 Enter',
|
|
1928
|
+
chosenLabel: '允许(Enter)',
|
|
1929
|
+
action: 'permission_allow_sent',
|
|
1930
|
+
editOriginal: true,
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
if (action === PERMISSION_ACTION_DENY) {
|
|
1935
|
+
try {
|
|
1936
|
+
pty.write(sid, '\x1b')
|
|
1937
|
+
} catch (e) {
|
|
1938
|
+
logger.warn?.(`[wizard] permission deny write failed: ${e.message}`)
|
|
1939
|
+
return stale()
|
|
1940
|
+
}
|
|
1941
|
+
return {
|
|
1942
|
+
toast: '已发送 Esc',
|
|
1943
|
+
chosenLabel: '拒绝/退出(Esc)',
|
|
1944
|
+
action: 'permission_deny_sent',
|
|
1945
|
+
editOriginal: true,
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
return { toast: '无效的权限按钮', action: 'invalid' }
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// ─── ask_user 按钮回调 ──────────────────────────────────────────
|
|
1953
|
+
//
|
|
1954
|
+
// 两类 callback:
|
|
1955
|
+
// qt:ans:<ticket>:<idx> → 直接选了选项 → submitReply(<idx+1>)
|
|
1956
|
+
// qt:ext:<ticket>:<idx> → 想补充细节 → 发 force_reply 提示,挂 forceReplyContext
|
|
1957
|
+
// 等用户回复后拼"选项 · 补充"再 submitReply
|
|
1958
|
+
//
|
|
1959
|
+
// submitReply 100% 复用现有 pending-questions 协调器;按钮 = 触发器,DB 不变。
|
|
1960
|
+
//
|
|
1961
|
+
// 边界:
|
|
1962
|
+
// - ticket 已超时 / 已取消 / 已答复 → submitReply 返回 ticket_not_pending → 回提示
|
|
1963
|
+
// - 选项 idx 越界 → toast 警告(实际拼按钮时不会发生,防御性)
|
|
1964
|
+
// - 没接 pending coordinator → 回不可用提示(不应发生,依赖检查在 createWizard 时做)
|
|
1965
|
+
async function handleAskUserCallback(askCb, { chatId, threadId } = {}) {
|
|
1966
|
+
if (!pending?.submitReply) {
|
|
1967
|
+
return { toast: '⚠️ pending coordinator 未启用', action: 'ask_user_unavailable' }
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
const { kind, ticket, idx } = askCb
|
|
1971
|
+
|
|
1972
|
+
// 先把 ticket 拉出来确认 pending 状态 + 拿到选项文本(用于 chosenLabel / 补充场景)
|
|
1973
|
+
let target = null
|
|
1974
|
+
try {
|
|
1975
|
+
target = db.getPendingQuestion?.(ticket) || null
|
|
1976
|
+
} catch { target = null }
|
|
1977
|
+
if (!target || target.status !== 'pending') {
|
|
1978
|
+
// editOriginal=true 顺便去按钮,不让用户再点
|
|
1979
|
+
return {
|
|
1980
|
+
toast: 'ticket 已结束',
|
|
1981
|
+
reply: `⚠️ ticket #${ticket} 已结束(超时/取消/已答复),无需再点。`,
|
|
1982
|
+
action: 'ask_user_stale',
|
|
1983
|
+
editOriginal: true,
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
const optionLabel = target.options?.[idx] ?? null
|
|
1987
|
+
if (optionLabel == null) {
|
|
1988
|
+
return { toast: '选项无效', action: 'invalid' }
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// ── ans:直接答 ──
|
|
1992
|
+
if (kind === CB_KIND_ANSWER) {
|
|
1993
|
+
const result = pending.submitReply(buildAnswerReplyText(idx))
|
|
1994
|
+
// submitReply 已 resolve waiter → AI 阻塞解开
|
|
1995
|
+
if (!result.matched) {
|
|
1996
|
+
// 极少发生:刚刚 ticket 还在,submitReply 时已被别处答了 → race
|
|
1997
|
+
return {
|
|
1998
|
+
toast: '回答失败',
|
|
1999
|
+
reply: `⚠️ 路由 #${ticket} 失败(${result.reason})`,
|
|
2000
|
+
action: 'ask_user_race',
|
|
2001
|
+
editOriginal: true,
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
return {
|
|
2005
|
+
chosenLabel: `${idx + 1}. ${optionLabel}`,
|
|
2006
|
+
toast: '已回答',
|
|
2007
|
+
action: 'ask_user_answered',
|
|
2008
|
+
editOriginal: true,
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// ── ext:发 force_reply 提示,挂 forceReplyContext,等下一条 message ──
|
|
2013
|
+
if (kind === CB_KIND_EXTEND) {
|
|
2014
|
+
return {
|
|
2015
|
+
toast: '请直接回复消息补充',
|
|
2016
|
+
reply: `✏️ 请补充关于「${optionLabel}」的细节,直接回复这条消息即可。\n(5 分钟内有效,超时按"${optionLabel}"原选项答 AI)`,
|
|
2017
|
+
// Telegram force_reply:用户客户端会自动聚焦到回复框;reply_to_message.message_id 指向我们这条
|
|
2018
|
+
replyMarkup: { force_reply: true, selective: true, input_field_placeholder: '补充细节…' },
|
|
2019
|
+
// ↓↓↓ 关键:让 telegram-bot 在 sendMessage 成功后注册 forceReplyContext
|
|
2020
|
+
// 由 telegram-bot 拿到新消息的 message_id 回灌到 wizard.registerForceReplyContext
|
|
2021
|
+
forceReplyContext: { ticket, optionIndex: idx, optionLabel, chatId, threadId },
|
|
2022
|
+
// 不要去原消息按钮(用户可能想换个选 / 改主意)
|
|
2023
|
+
editOriginal: false,
|
|
2024
|
+
action: 'ask_user_extend_pending',
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
return { toast: '未知按钮', action: 'invalid' }
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// ── force_reply 上下文 Map ─────────────────────────────────────
|
|
2032
|
+
// key = chatId|messageId (我们发出去的那条 force_reply 提示消息的 id)
|
|
2033
|
+
// val = { ticket, optionIndex, optionLabel, expireAt }
|
|
2034
|
+
// 5 分钟 TTL,自然过期即清除(懒清理:访问时检查 expireAt)
|
|
2035
|
+
const forceReplyContexts = new Map()
|
|
2036
|
+
const FORCE_REPLY_TTL_MS = 5 * 60 * 1000
|
|
2037
|
+
|
|
2038
|
+
function _frKey(chatId, messageId) { return `${chatId}|${messageId}` }
|
|
2039
|
+
|
|
2040
|
+
function registerForceReplyContext({ chatId, messageId, ticket, optionIndex, optionLabel } = {}) {
|
|
2041
|
+
if (!chatId || !messageId || !ticket) return false
|
|
2042
|
+
forceReplyContexts.set(_frKey(chatId, messageId), {
|
|
2043
|
+
ticket, optionIndex, optionLabel,
|
|
2044
|
+
expireAt: Date.now() + FORCE_REPLY_TTL_MS,
|
|
2045
|
+
})
|
|
2046
|
+
return true
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function consumeForceReplyContext(chatId, messageId) {
|
|
2050
|
+
if (!chatId || !messageId) return null
|
|
2051
|
+
const k = _frKey(chatId, messageId)
|
|
2052
|
+
const ctx = forceReplyContexts.get(k)
|
|
2053
|
+
if (!ctx) return null
|
|
2054
|
+
forceReplyContexts.delete(k)
|
|
2055
|
+
if (Date.now() > ctx.expireAt) return null // 过期视为命中失败
|
|
2056
|
+
return ctx
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// ─── 多 session ambiguous 选择回调 ─────────────────────────────────
|
|
2060
|
+
//
|
|
2061
|
+
// 用户从 ambiguous 提示里点了 [📦 修复登录 bug],触发 callback_data = qt:rt:<short>。
|
|
2062
|
+
// 处理流程:
|
|
2063
|
+
// 1. 在 aiTerminal.sessions 里按 sid 后缀(4 字符)找匹配的活跃 session
|
|
2064
|
+
// 2. 调 openclaw.setLastPushedSession(peer, sid) 把这个 chat 绑过去
|
|
2065
|
+
// 3. 标记 singleSessionRouteAnnounced(避免下次发消息又触发"首次提示")
|
|
2066
|
+
// 4. 回 toast + reply 告诉用户绑定结果(不重放当前消息,让用户重新发)
|
|
2067
|
+
//
|
|
2068
|
+
// 边界:
|
|
2069
|
+
// - short 后缀匹配不到(session 已死) → 回 'session 已没了' 提示
|
|
2070
|
+
// - 多个 sid 同时以这 4 字符结尾(碰撞,理论存在但 36^4≈1.7M 概率极低) → 取第一个
|
|
2071
|
+
// - aiTerminal 不可用 → 提示
|
|
2072
|
+
async function handleRouteCallback(short, { chatId, threadId } = {}) {
|
|
2073
|
+
if (!short || !aiTerminal?.sessions) {
|
|
2074
|
+
return { toast: '⚠️ 无法路由', action: 'route_unavailable' }
|
|
2075
|
+
}
|
|
2076
|
+
let target = null
|
|
2077
|
+
for (const [sid, sess] of aiTerminal.sessions) {
|
|
2078
|
+
if ((sess?.status === 'running' || sess?.status === 'idle' || sess?.status === 'pending_confirm') && sid.endsWith(short)) {
|
|
2079
|
+
target = { sid, sess }
|
|
2080
|
+
break
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
if (!target) {
|
|
2084
|
+
return {
|
|
2085
|
+
toast: '🤔 这个 session 已经没了',
|
|
2086
|
+
reply: `🤔 #${short} 已经不在线了,可能刚刚结束。请回 /list 看当前活跃 session。`,
|
|
2087
|
+
action: 'route_session_gone',
|
|
2088
|
+
editOriginal: true,
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
const todoTitle = lookupTodoTitleForSession(target.sid) || '(未命名)'
|
|
2092
|
+
const peer = chatId // 跟 push 路径里 lastPushByPeer 的 key 对齐
|
|
2093
|
+
const ok = openclaw?.setLastPushedSession?.(peer, target.sid)
|
|
2094
|
+
if (!ok) {
|
|
2095
|
+
return { toast: '⚠️ 路由失败(openclaw 不可用)', action: 'route_failed' }
|
|
2096
|
+
}
|
|
2097
|
+
// 已经显式选过了,下次自然路由不再触发"首次提示"
|
|
2098
|
+
singleSessionRouteAnnounced.add(`${peer}::${target.sid}`)
|
|
2099
|
+
return {
|
|
2100
|
+
toast: `✓ 已绑定到「${todoTitle}」`,
|
|
2101
|
+
chosenLabel: `📦 ${truncateTitle(todoTitle, 22)}`,
|
|
2102
|
+
reply: `📍 接下来你在这条 chat 发的话,会进 「${todoTitle}」 (#${short})`,
|
|
2103
|
+
action: 'route_bound',
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// ─── /list /pending /stop —— AgentQuad 全局 slash command(仅 General 响应) ──
|
|
2108
|
+
//
|
|
2109
|
+
// 设计:
|
|
2110
|
+
// - 入口在 handleInbound 第 1.7 步(DETACH_TRIGGERS 后、active wizard 之前)
|
|
2111
|
+
// - 仅在 supergroup General 频道执行;task topic 里被拦截 + 提示去 General 用
|
|
2112
|
+
// - "短码" = sid.slice(-8),跟现有 stdin_proxy_ambiguous 提示对齐
|
|
2113
|
+
// - /stop 走前缀匹配(4-8 字符都 OK),all 停所有,无参列活跃
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* 找到所有活跃 AI session(status=running / idle / pending_confirm),返回带 todo 上下文的列表:
|
|
2117
|
+
* [{ sid, short, status, lastOutputAt, todo: {id, title, workDir, quadrant} | null }]
|
|
2118
|
+
*
|
|
2119
|
+
* 数据源:
|
|
2120
|
+
* - aiTerminal.sessions (in-memory PTY session map) —— 状态最准
|
|
2121
|
+
* - 通过 todo.aiSessions 反查每个 sid 对应的 todo(最多扫一次未完成 todos)
|
|
2122
|
+
*/
|
|
2123
|
+
function findActiveSessions() {
|
|
2124
|
+
if (!aiTerminal?.sessions) return []
|
|
2125
|
+
const active = []
|
|
2126
|
+
for (const [sid, sess] of aiTerminal.sessions) {
|
|
2127
|
+
if (sess?.status === 'running' || sess?.status === 'idle' || sess?.status === 'pending_confirm') {
|
|
2128
|
+
active.push({
|
|
2129
|
+
sid,
|
|
2130
|
+
short: sid.slice(-8),
|
|
2131
|
+
status: sess.status,
|
|
2132
|
+
lastOutputAt: sess.lastOutputAt || sess.startedAt || 0,
|
|
2133
|
+
todo: null,
|
|
2134
|
+
})
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
if (active.length === 0) return active
|
|
2138
|
+
// 反查 todo —— 只看未完成的(活跃 session 不可能挂在已完成 todo 上)
|
|
2139
|
+
let todos = []
|
|
2140
|
+
try { todos = db.listTodos({ status: 'todo' }) || [] } catch { todos = [] }
|
|
2141
|
+
const sidToTodo = new Map()
|
|
2142
|
+
for (const todo of todos) {
|
|
2143
|
+
const sessions = todo.aiSessions || (todo.aiSession ? [todo.aiSession] : [])
|
|
2144
|
+
for (const s of sessions) {
|
|
2145
|
+
if (s?.sessionId) sidToTodo.set(s.sessionId, todo)
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
for (const item of active) {
|
|
2149
|
+
const t = sidToTodo.get(item.sid)
|
|
2150
|
+
if (t) {
|
|
2151
|
+
item.todo = {
|
|
2152
|
+
id: t.id,
|
|
2153
|
+
title: t.title || '(未命名)',
|
|
2154
|
+
workDir: t.workDir || '',
|
|
2155
|
+
quadrant: t.quadrant || 2,
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
// 最近活动的排前面
|
|
2160
|
+
active.sort((a, b) => b.lastOutputAt - a.lastOutputAt)
|
|
2161
|
+
return active
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
function formatTimeAgo(ms) {
|
|
2165
|
+
if (!ms) return ''
|
|
2166
|
+
const sec = Math.max(0, Math.floor((Date.now() - ms) / 1000))
|
|
2167
|
+
if (sec < 60) return `${sec}s 前`
|
|
2168
|
+
if (sec < 3600) return `${Math.floor(sec / 60)}m 前`
|
|
2169
|
+
return `${Math.floor(sec / 3600)}h 前`
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* 截断 todo 标题用于按钮 / 提示展示。中文 / emoji 占 1 char(粗略),不做精确宽度。
|
|
2174
|
+
* 超出 max 用 '…' 收尾。
|
|
2175
|
+
*/
|
|
2176
|
+
function truncateTitle(s, max = 24) {
|
|
2177
|
+
if (!s) return ''
|
|
2178
|
+
const str = String(s)
|
|
2179
|
+
if (str.length <= max) return str
|
|
2180
|
+
return str.slice(0, max) + '…'
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/**
|
|
2184
|
+
* 单 session 路由首次提示去重:peer + sid 维度。
|
|
2185
|
+
* - peer = chatId(telegram 私聊)或 chatId+threadId(topic)的 string 化
|
|
2186
|
+
* - 重启后清零(自然行为,新 sid 也会重新提示)
|
|
2187
|
+
*/
|
|
2188
|
+
const singleSessionRouteAnnounced = new Set()
|
|
2189
|
+
function shouldAnnounceFirstRoute(peer, sid) {
|
|
2190
|
+
if (!peer || !sid) return false
|
|
2191
|
+
const key = `${peer}::${sid}`
|
|
2192
|
+
if (singleSessionRouteAnnounced.has(key)) return false
|
|
2193
|
+
singleSessionRouteAnnounced.add(key)
|
|
2194
|
+
return true
|
|
2195
|
+
}
|
|
2196
|
+
function lookupTodoTitleForSession(sid) {
|
|
2197
|
+
if (!sid) return ''
|
|
2198
|
+
let todos = []
|
|
2199
|
+
try { todos = db.listTodos({ status: 'todo' }) || [] } catch { todos = [] }
|
|
2200
|
+
for (const t of todos) {
|
|
2201
|
+
const sessions = t.aiSessions || (t.aiSession ? [t.aiSession] : [])
|
|
2202
|
+
if (sessions.some((s) => s?.sessionId === sid)) return t.title || '(未命名)'
|
|
2203
|
+
}
|
|
2204
|
+
return ''
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* /list 或 /pending —— 列未完成 todos,按象限分组。
|
|
2209
|
+
*
|
|
2210
|
+
* 输出限制:30 条;超出加"去 web 看"提示。
|
|
2211
|
+
* 状态显示:把 todo.aiSessions 里 running 的标 🟢,便于一眼看哪些任务在跑。
|
|
2212
|
+
*/
|
|
2213
|
+
function cmdList() {
|
|
2214
|
+
let todos = []
|
|
2215
|
+
try { todos = db.listTodos({ status: 'todo' }) || [] } catch (e) {
|
|
2216
|
+
logger.warn?.(`[wizard] /list listTodos failed: ${e.message}`)
|
|
2217
|
+
return { reply: '⚠️ 读取 todo 列表失败', action: 'slash_list_failed' }
|
|
2218
|
+
}
|
|
2219
|
+
if (todos.length === 0) {
|
|
2220
|
+
return { reply: '✨ 暂无待办任务\n\n要新建:发「帮我做 X」 / 「新建任务: X」', action: 'slash_list' }
|
|
2221
|
+
}
|
|
2222
|
+
const PAGE = 30
|
|
2223
|
+
const visible = todos.slice(0, PAGE)
|
|
2224
|
+
// 活跃 sid 集合 → 标 🟢
|
|
2225
|
+
const activeSids = new Set(findActiveSessions().map((s) => s.sid))
|
|
2226
|
+
// dispatcher 排队信息:sid → queueSize(busy 期间累积的用户输入)
|
|
2227
|
+
const dispatcherDesc = sessionInputDispatcher?.describe?.() || { byId: {} }
|
|
2228
|
+
const groups = new Map()
|
|
2229
|
+
for (const q of QUADRANTS) groups.set(q.id, [])
|
|
2230
|
+
for (const t of visible) {
|
|
2231
|
+
const arr = groups.get(t.quadrant) || groups.get(2)
|
|
2232
|
+
arr.push(t)
|
|
2233
|
+
}
|
|
2234
|
+
const lines = [`📋 待办 (${todos.length}${todos.length > PAGE ? `, 仅显示前 ${PAGE}` : ''})`]
|
|
2235
|
+
for (const q of QUADRANTS) {
|
|
2236
|
+
const arr = groups.get(q.id)
|
|
2237
|
+
if (!arr || arr.length === 0) continue
|
|
2238
|
+
lines.push('')
|
|
2239
|
+
lines.push(`Q${q.id} ${q.label}`)
|
|
2240
|
+
for (const t of arr) {
|
|
2241
|
+
const short = String(t.id).slice(0, 4)
|
|
2242
|
+
const dirTag = t.workDir ? `· ${basename(t.workDir)}` : ''
|
|
2243
|
+
const aiSessions = t.aiSessions || (t.aiSession ? [t.aiSession] : [])
|
|
2244
|
+
const runningSid = aiSessions.find((s) => s?.sessionId && activeSids.has(s.sessionId))?.sessionId
|
|
2245
|
+
const isRunning = !!runningSid
|
|
2246
|
+
const statusTag = isRunning ? '🟢' : '·'
|
|
2247
|
+
const queueSize = runningSid ? (dispatcherDesc.byId?.[runningSid]?.queueSize || 0) : 0
|
|
2248
|
+
const queueTag = queueSize > 0 ? ` 📥${queueSize}` : ''
|
|
2249
|
+
lines.push(` ${statusTag} ${short} ${t.title} ${dirTag}${queueTag}`)
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
if (todos.length > PAGE) {
|
|
2253
|
+
const port = (getConfig?.()?.port) || 5677
|
|
2254
|
+
lines.push('')
|
|
2255
|
+
lines.push(`… 还有 ${todos.length - PAGE} 条,去 web 看:http://127.0.0.1:${port}/`)
|
|
2256
|
+
}
|
|
2257
|
+
return { reply: lines.join('\n'), action: 'slash_list', count: todos.length }
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/**
|
|
2261
|
+
* /stop —— 停 AI session。
|
|
2262
|
+
*
|
|
2263
|
+
* /stop → 列所有活跃会话 + 提示用法
|
|
2264
|
+
* /stop <短码> → 按 sid.slice(-8) 前缀匹配,命中且唯一就停(多个 / 没命中 → 提示)
|
|
2265
|
+
* /stop all → 停所有活跃
|
|
2266
|
+
*
|
|
2267
|
+
* 副作用:
|
|
2268
|
+
* 1. pty.stop(sid) —— 杀进程(PTY done handler 会异步清理 in-memory session)
|
|
2269
|
+
* 2. 更新 todo.aiSessions 里这条的 status='stopped',方便 web UI 看到
|
|
2270
|
+
* 3. todo.status 不动(仍 pending),用户可以手动重启
|
|
2271
|
+
*/
|
|
2272
|
+
function cmdStop({ argText = '' } = {}) {
|
|
2273
|
+
const arg = String(argText || '').trim()
|
|
2274
|
+
const active = findActiveSessions()
|
|
2275
|
+
if (active.length === 0) {
|
|
2276
|
+
return { reply: '✅ 当前没有正在跑的 AI 会话', action: 'slash_stop_noop' }
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// 没有参数 → 列出来让用户选
|
|
2280
|
+
if (!arg) {
|
|
2281
|
+
const lines = [`🟢 当前活跃 AI 会话 (${active.length}):`]
|
|
2282
|
+
active.forEach((s, i) => {
|
|
2283
|
+
const title = s.todo?.title || '(未绑定 todo)'
|
|
2284
|
+
lines.push(` ${i + 1}. ${s.short} ${title} · ${formatTimeAgo(s.lastOutputAt)}`)
|
|
2285
|
+
})
|
|
2286
|
+
lines.push('')
|
|
2287
|
+
lines.push('停止某个:/stop <短码> (短码=上面 8 位)')
|
|
2288
|
+
lines.push('全部停: /stop all')
|
|
2289
|
+
return { reply: lines.join('\n'), action: 'slash_stop_list', activeCount: active.length }
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// 决定要停的列表
|
|
2293
|
+
let targets = []
|
|
2294
|
+
if (/^all$/i.test(arg)) {
|
|
2295
|
+
targets = active.slice()
|
|
2296
|
+
} else {
|
|
2297
|
+
const needle = arg.toLowerCase()
|
|
2298
|
+
const matched = active.filter((s) => s.short.toLowerCase().startsWith(needle))
|
|
2299
|
+
if (matched.length === 0) {
|
|
2300
|
+
return {
|
|
2301
|
+
reply: `🤔 没找到匹配 "${arg}" 的活跃会话。\n\n回 /stop 看活跃列表。`,
|
|
2302
|
+
action: 'slash_stop_no_match',
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
if (matched.length > 1) {
|
|
2306
|
+
const list = matched.map((s) => ` • ${s.short} ${s.todo?.title || '(未绑定)'}`).join('\n')
|
|
2307
|
+
return {
|
|
2308
|
+
reply: `⚠️ "${arg}" 同时匹配多个会话,请加更长的短码:\n${list}`,
|
|
2309
|
+
action: 'slash_stop_ambiguous',
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
targets = matched
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
// 真正去停
|
|
2316
|
+
const stopped = []
|
|
2317
|
+
const failed = []
|
|
2318
|
+
for (const t of targets) {
|
|
2319
|
+
try {
|
|
2320
|
+
// 标记是用户主动停 —— PTY done handler 不会用 stopped→'todo' 默认逻辑覆写
|
|
2321
|
+
const sess = aiTerminal?.sessions?.get?.(t.sid)
|
|
2322
|
+
if (sess) sess.userClosedReason = 'slash_stop'
|
|
2323
|
+
if (pty?.stop) pty.stop(t.sid)
|
|
2324
|
+
// 更新 todo 里这条 aiSession 的状态
|
|
2325
|
+
if (t.todo?.id) {
|
|
2326
|
+
try {
|
|
2327
|
+
const todo = db.getTodo(t.todo.id)
|
|
2328
|
+
if (todo) {
|
|
2329
|
+
const sessions = (todo.aiSessions || []).map((s) =>
|
|
2330
|
+
s?.sessionId === t.sid
|
|
2331
|
+
? { ...s, status: 'stopped', stoppedAt: Date.now(), stopReason: 'slash_stop' }
|
|
2332
|
+
: s
|
|
2333
|
+
)
|
|
2334
|
+
db.updateTodo(t.todo.id, { aiSessions: sessions })
|
|
2335
|
+
}
|
|
2336
|
+
} catch (e) {
|
|
2337
|
+
logger.warn?.(`[wizard] /stop persist status failed for sid=${t.short}: ${e.message}`)
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
// 解绑路由 + 清 lastPushedSession,避免残留
|
|
2341
|
+
openclaw?.clearSessionRoute?.(t.sid, 'slash-stop')
|
|
2342
|
+
stopped.push(t)
|
|
2343
|
+
} catch (e) {
|
|
2344
|
+
logger.warn?.(`[wizard] /stop pty.stop failed for sid=${t.short}: ${e.message}`)
|
|
2345
|
+
failed.push({ ...t, error: e.message })
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
if (stopped.length === 0 && failed.length > 0) {
|
|
2350
|
+
return {
|
|
2351
|
+
reply: `❌ 停止失败:${failed.map((f) => f.short).join(', ')}`,
|
|
2352
|
+
action: 'slash_stop_failed',
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
const lines = [`⏹ 已停止 ${stopped.length} 个会话:`]
|
|
2356
|
+
for (const s of stopped) {
|
|
2357
|
+
const title = s.todo?.title || '(未绑定 todo)'
|
|
2358
|
+
lines.push(` • ${s.short} ${title}`)
|
|
2359
|
+
}
|
|
2360
|
+
if (failed.length > 0) {
|
|
2361
|
+
lines.push('')
|
|
2362
|
+
lines.push(`⚠️ 失败 ${failed.length} 个:${failed.map((f) => f.short).join(', ')}`)
|
|
2363
|
+
}
|
|
2364
|
+
return {
|
|
2365
|
+
reply: lines.join('\n'),
|
|
2366
|
+
action: 'slash_stop_done',
|
|
2367
|
+
stoppedCount: stopped.length,
|
|
2368
|
+
failedCount: failed.length,
|
|
2369
|
+
stoppedSids: stopped.map((s) => s.sid),
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/**
|
|
2374
|
+
* 分发 AgentQuad 全局 slash command。被 handleInbound 在 General 频道命中时调用。
|
|
2375
|
+
* 不在 General 时由 handleInbound 直接拦截,不会进这里。
|
|
2376
|
+
*/
|
|
2377
|
+
function handleSlashCommand({ cmd, argText = '' } = {}) {
|
|
2378
|
+
switch (String(cmd || '').toLowerCase()) {
|
|
2379
|
+
case 'list':
|
|
2380
|
+
case 'pending':
|
|
2381
|
+
return cmdList()
|
|
2382
|
+
case 'stop':
|
|
2383
|
+
return cmdStop({ argText })
|
|
2384
|
+
default:
|
|
2385
|
+
// 不该走到这(QUADTODO_GLOBAL_SLASH 已经过滤了)
|
|
2386
|
+
return { reply: `🤔 未知命令 /${cmd}`, action: 'slash_unknown' }
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function describe() {
|
|
2391
|
+
return {
|
|
2392
|
+
activeWizards: wizards.size,
|
|
2393
|
+
peers: [...wizards.keys()],
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// 测试 / 调试钩子。peer 可以是旧风格 'u1' 或 routeKey 'u1:general'
|
|
2398
|
+
function _peek(peerOrRouteKey) {
|
|
2399
|
+
if (peerOrRouteKey == null) return null
|
|
2400
|
+
const k = String(peerOrRouteKey)
|
|
2401
|
+
return wizards.get(k)
|
|
2402
|
+
|| [...wizards.entries()].find(([routeKey]) => routeKey.endsWith(`:${k}`))?.[1]
|
|
2403
|
+
|| wizards.get(makeRouteKey('openclaw', k, null))
|
|
2404
|
+
|| null
|
|
2405
|
+
}
|
|
2406
|
+
function _reset() { wizards.clear() }
|
|
2407
|
+
|
|
2408
|
+
return {
|
|
2409
|
+
handleInbound,
|
|
2410
|
+
handleCallback,
|
|
2411
|
+
handleSlashCommand,
|
|
2412
|
+
handleTopicEvent,
|
|
2413
|
+
handleLarkThreadClose,
|
|
2414
|
+
ensureTopicForSession,
|
|
2415
|
+
ensureLarkThreadForSession,
|
|
2416
|
+
abortWizard,
|
|
2417
|
+
registerForceReplyContext,
|
|
2418
|
+
describe,
|
|
2419
|
+
_peek,
|
|
2420
|
+
_reset,
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
export const __test__ = {
|
|
2425
|
+
extractTitle,
|
|
2426
|
+
tryExtractWorkdir,
|
|
2427
|
+
tryExtractQuadrant,
|
|
2428
|
+
tryExtractTemplateHint,
|
|
2429
|
+
parseNumericChoice,
|
|
2430
|
+
findTemplateByHint,
|
|
2431
|
+
buildWorkdirMessage,
|
|
2432
|
+
buildQuadrantMessage,
|
|
2433
|
+
buildTemplateMessage,
|
|
2434
|
+
buildWorkdirReplyMarkup,
|
|
2435
|
+
buildQuadrantReplyMarkup,
|
|
2436
|
+
buildTemplateReplyMarkup,
|
|
2437
|
+
CALLBACK_PREFIX,
|
|
2438
|
+
isGeneralChannel,
|
|
2439
|
+
QUADTODO_GLOBAL_SLASH,
|
|
2440
|
+
NEW_TASK_TRIGGERS,
|
|
2441
|
+
CANCEL_TRIGGERS,
|
|
2442
|
+
}
|