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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -0
  3. package/dist-web/assets/index-CMaXwixo.js +1234 -0
  4. package/dist-web/assets/index-DBHApzV1.css +32 -0
  5. package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  6. package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  7. package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  8. package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  9. package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  10. package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  11. package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
  12. package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
  13. package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  14. package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  15. package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  16. package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  17. package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  18. package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  19. package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
  20. package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
  21. package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  22. package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  23. package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  24. package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  25. package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  26. package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  27. package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
  28. package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
  29. package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  30. package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  31. package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  32. package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  33. package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  34. package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  35. package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
  36. package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
  37. package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  38. package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  39. package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  40. package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  41. package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  42. package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  43. package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  44. package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  45. package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  46. package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  47. package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  48. package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  49. package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  50. package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  51. package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
  52. package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
  53. package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  54. package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  55. package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  56. package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  57. package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  58. package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  59. package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
  60. package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
  61. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  62. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  63. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
  64. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
  65. package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  66. package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  67. package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
  68. package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
  69. package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  70. package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  71. package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  72. package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  73. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  74. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  75. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
  76. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
  77. package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  78. package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
  79. package/dist-web/assets/logo-D4DDtU-r.png +0 -0
  80. package/dist-web/favicon.png +0 -0
  81. package/dist-web/index.html +14 -0
  82. package/package.json +88 -0
  83. package/src/ask-user-buttons.js +142 -0
  84. package/src/claude-transcript.js +203 -0
  85. package/src/cli.js +1040 -0
  86. package/src/codex-event-emitter.js +111 -0
  87. package/src/codex-prompt-detector.js +53 -0
  88. package/src/codex-sidecar.js +52 -0
  89. package/src/codex-transcript.js +74 -0
  90. package/src/config.js +692 -0
  91. package/src/data/claude-code-commands.json +52 -0
  92. package/src/db.js +1503 -0
  93. package/src/dispatch.js +13 -0
  94. package/src/export/todoMarkdown.js +246 -0
  95. package/src/first-run-wizard.js +82 -0
  96. package/src/git/gitStatus.js +139 -0
  97. package/src/lark-api-client.js +205 -0
  98. package/src/lark-bot.js +510 -0
  99. package/src/lark-card.js +88 -0
  100. package/src/lark-config-service.js +16 -0
  101. package/src/lark-event-client.js +107 -0
  102. package/src/lark-image.js +99 -0
  103. package/src/lark-markdown.js +51 -0
  104. package/src/lark-video.js +163 -0
  105. package/src/mcp/audit.js +34 -0
  106. package/src/mcp/server.js +83 -0
  107. package/src/mcp/tools/destructive/index.js +252 -0
  108. package/src/mcp/tools/openclaw/index.js +405 -0
  109. package/src/mcp/tools/read/index.js +269 -0
  110. package/src/mcp/tools/write/index.js +157 -0
  111. package/src/openclaw-bridge.js +566 -0
  112. package/src/openclaw-hook-installer.js +338 -0
  113. package/src/openclaw-hook.js +908 -0
  114. package/src/openclaw-wizard.js +2442 -0
  115. package/src/pending-questions.js +297 -0
  116. package/src/pricing.js +45 -0
  117. package/src/prompt-render.js +36 -0
  118. package/src/pty.js +992 -0
  119. package/src/routes/ai-terminal.js +1228 -0
  120. package/src/routes/git.js +89 -0
  121. package/src/routes/openclaw-hook.js +67 -0
  122. package/src/routes/openclaw-inbound.js +36 -0
  123. package/src/routes/recurringRules.js +80 -0
  124. package/src/routes/reports.js +50 -0
  125. package/src/routes/search.js +46 -0
  126. package/src/routes/stats.js +31 -0
  127. package/src/routes/telegram-config.js +152 -0
  128. package/src/routes/telegram-sync.js +221 -0
  129. package/src/routes/templates.js +63 -0
  130. package/src/routes/todos.js +649 -0
  131. package/src/routes/transcripts.js +75 -0
  132. package/src/routes/uploads.js +107 -0
  133. package/src/routes/wiki.js +142 -0
  134. package/src/search/fts.js +209 -0
  135. package/src/search/index.js +199 -0
  136. package/src/search/transcripts.js +148 -0
  137. package/src/server.js +1791 -0
  138. package/src/session-input-dispatcher.js +256 -0
  139. package/src/stats/markdown.js +42 -0
  140. package/src/stats/report.js +207 -0
  141. package/src/summarize.js +84 -0
  142. package/src/system-rules.js +52 -0
  143. package/src/telegram-bot.js +875 -0
  144. package/src/telegram-commands.js +149 -0
  145. package/src/telegram-config-service.js +84 -0
  146. package/src/telegram-image.js +95 -0
  147. package/src/telegram-loading-status.js +112 -0
  148. package/src/telegram-markdown.js +82 -0
  149. package/src/telegram-reaction-tracker.js +69 -0
  150. package/src/telegram-video.js +75 -0
  151. package/src/templates/claude-hooks/notify.js +103 -0
  152. package/src/transcript.js +305 -0
  153. package/src/transcripts/blocks.js +56 -0
  154. package/src/transcripts/index.js +222 -0
  155. package/src/transcripts/indexer.js +34 -0
  156. package/src/transcripts/matcher.js +70 -0
  157. package/src/transcripts/scanner.js +259 -0
  158. package/src/usage-footer.js +170 -0
  159. package/src/usage-parser.js +132 -0
  160. package/src/wiki/guide.js +44 -0
  161. package/src/wiki/index.js +232 -0
  162. package/src/wiki/redact.js +34 -0
  163. 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
+ }