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,566 @@
1
+ /**
2
+ * OpenClaw 出站桥:通过 `openclaw message send` CLI 把消息推到微信。
3
+ *
4
+ * 设计选型:
5
+ * - 不直接说 OpenClaw gateway 的 WebSocket 协议(不稳定、版本会变)
6
+ * - shell out CLI 走官方契约,--json 拿结构化结果
7
+ *
8
+ * 安全:
9
+ * - 命令通过 args 数组传,绝不拼字符串(避免 shell 注入)
10
+ * - 出站限流(rateLimitPerMin),防个人微信被风控
11
+ * - sessionId → targetUserId 的路由内存表,沿配置 fallback
12
+ */
13
+ import { spawn } from 'node:child_process'
14
+ import { toTelegramV2, toPlainText } from './telegram-markdown.js'
15
+ import { hasPermissionButtons, buildPermissionCard } from './lark-card.js'
16
+
17
+ const DEFAULT_CLI_BIN = 'openclaw'
18
+ // openclaw CLI 冷启动 ~17s + Telegram 网络 ~10s,给 60s 足够
19
+ const DEFAULT_TIMEOUT_MS = 60_000
20
+ const TELEGRAM_API_TIMEOUT_MS = 10_000
21
+
22
+ // channel → 期望的 target 后缀(OpenClaw CLI 要求 target 带 channel 后缀,
23
+ // 但 OpenClaw skill context 里拿到的 from_user_id 通常不带)
24
+ const CHANNEL_TARGET_SUFFIX = {
25
+ 'openclaw-weixin': '@im.wechat',
26
+ // 其他渠道暂不强制;缺了的话 CLI 会自己报错让我们补
27
+ }
28
+
29
+ function normalizeTarget(target, channel) {
30
+ if (!target || typeof target !== 'string') return target
31
+ const suffix = CHANNEL_TARGET_SUFFIX[channel]
32
+ if (!suffix) return target
33
+ if (target.includes(suffix)) return target
34
+ // 已经有别的 @ 后缀(如 @im.wechat、@example.com)— 保持原样
35
+ if (target.includes('@')) return target
36
+ return `${target}${suffix}`
37
+ }
38
+
39
+ function nowMs() { return Date.now() }
40
+
41
+ function getTelegramTokenFromConfig(config) {
42
+ const token = config?.telegram?.botToken
43
+ return typeof token === 'string' && token ? token : null
44
+ }
45
+
46
+ // 走系统 HTTPS_PROXY env 的 fetch(undici ProxyAgent)—— 国内连 Telegram 必备
47
+ let _undiciFetch = null
48
+ let _proxyDispatcher = null
49
+ async function getProxyFetch() {
50
+ if (_undiciFetch) return _undiciFetch
51
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy
52
+ || process.env.HTTP_PROXY || process.env.http_proxy
53
+ if (!proxyUrl) {
54
+ _undiciFetch = (url, opts) => fetch(url, opts) // 无 proxy,用 Node 内置 fetch
55
+ return _undiciFetch
56
+ }
57
+ try {
58
+ const { ProxyAgent, fetch: undiciFetch } = await import('undici')
59
+ _proxyDispatcher = new ProxyAgent(proxyUrl)
60
+ _undiciFetch = (url, opts = {}) => undiciFetch(url, { ...opts, dispatcher: _proxyDispatcher })
61
+ return _undiciFetch
62
+ } catch {
63
+ _undiciFetch = (url, opts) => fetch(url, opts) // undici 没装:fallback 直连
64
+ return _undiciFetch
65
+ }
66
+ }
67
+
68
+ async function sendViaTelegramAPI({ token, chatId, threadId, text, replyMarkup = null, logger = null }) {
69
+ const url = `https://api.telegram.org/bot${token}/sendMessage`
70
+ const ctrl = new AbortController()
71
+ const timer = setTimeout(() => ctrl.abort(), TELEGRAM_API_TIMEOUT_MS)
72
+ timer.unref?.()
73
+ try {
74
+ const fetchFn = await getProxyFetch()
75
+ const v2Text = toTelegramV2(text)
76
+ // 诊断日志:dump V2 处理后的前 120 字,确认进程加载的是新代码(带 V2 转换)
77
+ logger?.info?.(`[openclaw-bridge] V2 head: ${JSON.stringify(v2Text.slice(0, 120))} (rawLen=${text.length} v2Len=${v2Text.length})`)
78
+ const body = { chat_id: chatId, text: v2Text, parse_mode: 'MarkdownV2' }
79
+ if (threadId) body.message_thread_id = threadId
80
+ if (replyMarkup) body.reply_markup = replyMarkup
81
+ const res = await fetchFn(url, {
82
+ method: 'POST',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify(body),
85
+ signal: ctrl.signal,
86
+ })
87
+ clearTimeout(timer)
88
+ const data = await res.json().catch(() => null)
89
+ if (!res.ok || !data?.ok) {
90
+ // Markdown 解析失败时降级为纯文本重发(保留 thread + 按钮,避免泄漏到 General / 丢按钮)
91
+ if (data?.description?.includes('parse')) {
92
+ logger?.warn?.(`[openclaw-bridge] V2 parse failed (${data.description}); retrying plain text on threadId=${threadId || 'none'}`)
93
+ return await sendViaTelegramAPI_plain({ token, chatId, threadId, text, replyMarkup })
94
+ }
95
+ return { ok: false, reason: 'telegram_api_error', detail: data?.description || `${res.status}`, status: res.status }
96
+ }
97
+ return { ok: true, payload: data.result }
98
+ } catch (e) {
99
+ clearTimeout(timer)
100
+ return { ok: false, reason: e.name === 'AbortError' ? 'timeout' : 'fetch_error', detail: e.message }
101
+ }
102
+ }
103
+
104
+ async function sendViaTelegramAPI_plain({ token, chatId, threadId, text, replyMarkup = null }) {
105
+ const url = `https://api.telegram.org/bot${token}/sendMessage`
106
+ try {
107
+ const fetchFn = await getProxyFetch()
108
+ // 用 telegramify 'remove' 模式剥掉 markdown 标记,避免满屏 #### / ** / > 字面字符
109
+ const body = { chat_id: chatId, text: toPlainText(text) }
110
+ if (threadId) body.message_thread_id = threadId
111
+ if (replyMarkup) body.reply_markup = replyMarkup
112
+ const res = await fetchFn(url, {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify(body),
116
+ })
117
+ const data = await res.json().catch(() => null)
118
+ if (!res.ok || !data?.ok) return { ok: false, reason: 'telegram_api_error', detail: data?.description }
119
+ return { ok: true, payload: data.result }
120
+ } catch (e) {
121
+ return { ok: false, reason: 'fetch_error', detail: e.message }
122
+ }
123
+ }
124
+
125
+ export const __test__ = { sendViaTelegramAPI, sendViaTelegramAPI_plain }
126
+
127
+ export function createOpenClawBridge({
128
+ getConfig,
129
+ cliBin = DEFAULT_CLI_BIN,
130
+ spawnFn = spawn,
131
+ logger = console,
132
+ telegramSender = sendViaTelegramAPI, // 测试用:可 mock fake fetch
133
+ telegramBot: initialTelegramBot = null, // 可选:用于 sendDocument 附件
134
+ larkBot: initialLarkBot = null,
135
+ } = {}) {
136
+ let telegramBot = initialTelegramBot
137
+ let larkBot = initialLarkBot
138
+ let topicGoneHandler = null // ({chatId, threadId}) → void:sendMessage 拿到 topic 已删错时调用
139
+ if (typeof getConfig !== 'function') throw new Error('getConfig_required')
140
+
141
+ // 出站限流环形缓冲:每分钟 ≤ rateLimitPerMin 条
142
+ const sendTimestamps = []
143
+ // sessionId → { targetUserId, account, channel }
144
+ const sessionRoutes = new Map()
145
+ // peerUserId → { sessionId, sentAt } — 最近一次推到该 peer 的 session
146
+ // 用于 PTY stdin proxy:用户在微信回话时知道往哪个 PTY 写
147
+ const lastPushByPeer = new Map()
148
+
149
+ function getOpenClawConfig() {
150
+ const cfg = getConfig() || {}
151
+ return cfg.openclaw || {}
152
+ }
153
+
154
+ function isEnabled() {
155
+ const oc = getOpenClawConfig()
156
+ return Boolean(oc.enabled)
157
+ }
158
+
159
+ function rateLimitOk() {
160
+ const oc = getOpenClawConfig()
161
+ const limit = Math.max(1, Number(oc?.askUser?.rateLimitPerMin) || 6)
162
+ const cutoff = nowMs() - 60_000
163
+ while (sendTimestamps.length && sendTimestamps[0] < cutoff) sendTimestamps.shift()
164
+ return sendTimestamps.length < limit
165
+ }
166
+
167
+ function recordSend() {
168
+ sendTimestamps.push(nowMs())
169
+ }
170
+
171
+ function registerSessionRoute(sessionId, { targetUserId, account, channel, threadId, rootMessageId, topicName, triggerMessageId, messageAppLink } = {}) {
172
+ if (!sessionId || !targetUserId) return
173
+ sessionRoutes.set(sessionId, {
174
+ targetUserId,
175
+ account: account || null,
176
+ channel: channel || getOpenClawConfig().channel || 'openclaw-weixin',
177
+ threadId: threadId != null ? threadId : null, // ← Telegram Topic 路由用
178
+ rootMessageId: rootMessageId || null,
179
+ topicName: topicName || null, // ← SessionEnd 改名 ✅ 用
180
+ triggerMessageId: triggerMessageId != null ? triggerMessageId : null, // D 方案:reaction 加在用户触发消息上
181
+ messageAppLink: messageAppLink || null,
182
+ })
183
+ }
184
+
185
+ function clearSessionRoute(sessionId, reason = 'unknown') {
186
+ if (sessionId && sessionRoutes.has(sessionId)) {
187
+ logger.info?.(`[openclaw-bridge] clearSessionRoute sid=${sessionId} reason=${reason}`)
188
+ }
189
+ sessionRoutes.delete(sessionId)
190
+ }
191
+
192
+ function hasExplicitRoute(sessionId) {
193
+ return Boolean(sessionId && sessionRoutes.has(sessionId))
194
+ }
195
+
196
+ function resolveRoute(sessionId) {
197
+ const explicit = sessionRoutes.get(sessionId)
198
+ if (explicit) return explicit
199
+ const oc = getOpenClawConfig()
200
+ if (!oc.targetUserId) return null
201
+ return {
202
+ targetUserId: oc.targetUserId,
203
+ account: null,
204
+ channel: oc.channel || 'openclaw-weixin',
205
+ threadId: null,
206
+ rootMessageId: null,
207
+ topicName: null,
208
+ triggerMessageId: null,
209
+ messageAppLink: null,
210
+ }
211
+ }
212
+
213
+ /**
214
+ * 调用 `openclaw message send`,返回 { ok: true, payload } 或 { ok: false, reason, stderr? }。
215
+ * 失败原因可能是:disabled / rate_limited / misconfigured / cli_failed / timeout
216
+ */
217
+ async function postText({ sessionId, target, message, channel, account, replyToId, attachment = null, replyMarkup = null } = {}) {
218
+ if (!message || typeof message !== 'string') return { ok: false, reason: 'message_required' }
219
+ if (!rateLimitOk()) return { ok: false, reason: 'rate_limited' }
220
+
221
+ const oc = getOpenClawConfig()
222
+ const route = sessionId ? resolveRoute(sessionId) : null
223
+ const effectiveChannel = channel || route?.channel || oc.channel || 'openclaw-weixin'
224
+ const rawTarget = target || route?.targetUserId || oc.targetUserId
225
+ const effectiveTarget = normalizeTarget(rawTarget, effectiveChannel)
226
+ const effectiveAccount = account || route?.account
227
+
228
+ if (!effectiveTarget) return { ok: false, reason: 'misconfigured', detail: 'targetUserId missing' }
229
+
230
+ if (effectiveChannel === 'lark') {
231
+ const rootMessageId = route?.rootMessageId || null
232
+ if (!rootMessageId) {
233
+ logger.warn?.(`[openclaw-bridge] refuse lark send: sid=${sessionId} has no rootMessageId`)
234
+ return { ok: false, reason: 'lark_root_message_missing' }
235
+ }
236
+ if (!larkBot?.replyInThread) return { ok: false, reason: 'lark_bot_not_running' }
237
+
238
+ // 权限按钮:把 telegram 风格的 inline_keyboard 转成飞书 interactive card
239
+ // 走 replyWithCard 回到同 thread;按钮 value 保留 qt:perm:<short>:allow|deny
240
+ // callback_data,飞书 card.action.trigger 事件触发后由 lark-bot 路由回 wizard。
241
+ if (hasPermissionButtons(replyMarkup) && larkBot?.replyWithCard) {
242
+ const card = buildPermissionCard({ message, replyMarkup })
243
+ const cardR = await larkBot.replyWithCard({ rootMessageId, card })
244
+ if (cardR.ok) {
245
+ recordSend()
246
+ if (sessionId && rawTarget) lastPushByPeer.set(String(rawTarget), { sessionId, sentAt: Date.now() })
247
+ return { ok: true, payload: cardR.payload, fast: true, card: true }
248
+ }
249
+ logger.warn?.(`[openclaw-bridge] lark permission card send failed (${cardR.reason || 'unknown'}: ${cardR.detail || ''}); falling back to plain text reply`)
250
+ // 卡片发失败 → fallback 到纯文本(至少把"等待授权"消息丢进 thread,让用户知道)
251
+ }
252
+
253
+ const r = await larkBot.replyInThread({ rootMessageId, text: message })
254
+ if (r.ok) {
255
+ recordSend()
256
+ if (sessionId && rawTarget) lastPushByPeer.set(String(rawTarget), { sessionId, sentAt: Date.now() })
257
+ return { ok: true, payload: r.payload, fast: true }
258
+ }
259
+ // thread root 失效(用户撤回 / 飞书 5xx) → 静默 drop,不 fallback 到群主消息流。
260
+ // 用户撤回 root 的语义就是"不想看这个 task 了",把 PTY 输出泼到群里反而是污染。
261
+ logger.warn?.(`[openclaw-bridge] lark reply failed (${r.reason || 'unknown'}: ${r.detail || ''}); dropping (thread root may be gone)`)
262
+ return { ok: false, reason: r.reason || 'lark_send_failed', detail: r.detail || r.stderr }
263
+ }
264
+
265
+ // ─── Telegram 快路径:直接 HTTPS POST Bot API(~1-3s vs CLI 30+s 冷启动) ───
266
+ if (effectiveChannel === 'telegram') {
267
+ const token = getTelegramTokenFromConfig(getConfig())
268
+ if (token) {
269
+ const threadIdForSend = route?.threadId || null
270
+ // 防御兜底:sessionId-routed 但没拿到 thread → fallback 路径,不能静默落 General。
271
+ // 仅当 caller 传了 sessionId 时启用(无 sessionId 是显式 broadcast,允许直发默认 chat)。
272
+ if (!threadIdForSend && sessionId && !sessionRoutes.has(sessionId)) {
273
+ logger.warn?.(`[openclaw-bridge] refuse send to telegram general: sid=${sessionId} has no registered route (routesSize=${sessionRoutes.size}); would have leaked to General. msgLen=${message.length}`)
274
+ return { ok: false, reason: 'no_thread_id_route_missing' }
275
+ }
276
+ logger.info?.(`[openclaw-bridge] telegram send sessionId=${sessionId} chatId=${effectiveTarget} threadId=${threadIdForSend} (route=${route ? JSON.stringify({tid: route.threadId, tn: route.topicName}) : 'null'}) attachment=${attachment ? 'yes' : 'no'} msgLen=${message.length}`)
277
+ // 网络抖动重试 1 次:fetch_error / timeout 才重试,telegram 业务错误(parse 失败、429)不重试
278
+ const sendOnce = () => telegramSender({
279
+ token,
280
+ chatId: String(effectiveTarget),
281
+ threadId: threadIdForSend,
282
+ text: message,
283
+ replyMarkup, // ask_user / wizard 注入的 inline keyboard,无则 null
284
+ logger,
285
+ })
286
+ let r = await sendOnce()
287
+ if (!r.ok && (r.reason === 'fetch_error' || r.reason === 'timeout')) {
288
+ logger.warn?.(`[openclaw-bridge] fast-path transient error (${r.reason}); retrying once after 1s`)
289
+ await new Promise((res) => setTimeout(res, 1000))
290
+ r = await sendOnce()
291
+ }
292
+ if (r.ok) {
293
+ recordSend()
294
+ if (sessionId && rawTarget) {
295
+ lastPushByPeer.set(String(rawTarget), { sessionId, sentAt: Date.now() })
296
+ }
297
+ // 文本送达后,如果有附件,再发一次 sendDocument(不阻塞主结果)
298
+ if (attachment && telegramBot?.sendDocument) {
299
+ telegramBot.sendDocument({
300
+ chatId: String(effectiveTarget),
301
+ threadId: threadIdForSend,
302
+ filePath: attachment,
303
+ fileName: attachment.split('/').pop(),
304
+ }).catch((e) => logger.warn?.(`[openclaw-bridge] sendDocument failed: ${e.message}`))
305
+ }
306
+ return { ok: true, payload: r.payload, fast: true }
307
+ }
308
+ // 懒检测:topic 被删 / thread 失效 → 触发 onTopicGone(同关闭语义)
309
+ const detail = String(r.detail || '').toLowerCase()
310
+ const looksLikeTopicGone = /thread not found|topic.*deleted|message.*not.*found|topic_closed/.test(detail)
311
+ if (looksLikeTopicGone && threadIdForSend && topicGoneHandler) {
312
+ logger.warn?.(`[openclaw-bridge] topic gone detected (${r.detail}); triggering close handler chatId=${effectiveTarget} threadId=${threadIdForSend}`)
313
+ try { topicGoneHandler({ chatId: String(effectiveTarget), threadId: threadIdForSend }) } catch (e) {
314
+ logger.warn?.(`[openclaw-bridge] topicGoneHandler threw: ${e.message}`)
315
+ }
316
+ return { ok: false, reason: 'topic_gone', detail: r.detail }
317
+ }
318
+ // 关键:fast-path 失败时,如果**应该发到 topic**(有 threadId)→ 不能 fallback 到 CLI,
319
+ // 因为 CLI 命令不带 threadId,会把消息默认丢到 General → 数据落错地方。
320
+ // 网络失败让 caller 决定重试 / 上报;topic 路由是正确的,不能为了"成功"而牺牲位置。
321
+ if (threadIdForSend) {
322
+ logger.warn?.(`[openclaw-bridge] telegram fast-path failed (${r.reason}: ${r.detail}); refusing CLI fallback (threadId=${threadIdForSend} would leak to General)`)
323
+ return { ok: false, reason: r.reason || 'telegram_api_error', detail: r.detail }
324
+ }
325
+ logger.warn?.(`[openclaw-bridge] telegram fast-path failed (${r.reason}: ${r.detail}); falling back to CLI`)
326
+ // fallthrough to CLI(无 threadId 的场景才允许)
327
+ } else {
328
+ logger.warn?.(`[openclaw-bridge] telegram fast-path: token missing — falling back to CLI`)
329
+ }
330
+ }
331
+
332
+ // openclaw CLI fallback path: 这一段是 spawn `openclaw message send`(微信渠道),
333
+ // 必须由 openclaw.enabled gating。lark / telegram 上面已直接 return,不会落到这里。
334
+ // 历史上这个 gate 在函数顶部,导致 openclaw.enabled=false 时 lark 也被静默拒掉
335
+ // (hook 触发后 bridge 直接返回 disabled,飞书永远收不到 AI 回复)。
336
+ if (!isEnabled()) return { ok: false, reason: 'disabled' }
337
+
338
+ const args = [
339
+ 'message', 'send',
340
+ '--channel', effectiveChannel,
341
+ '--target', String(effectiveTarget),
342
+ '--message', message,
343
+ '--json',
344
+ ]
345
+ if (effectiveAccount) args.push('--account', effectiveAccount)
346
+ if (replyToId) args.push('--reply-to', String(replyToId))
347
+
348
+ // openclaw CLI 自己读 ~/.openclaw/openclaw.json(0600)取 gateway token,
349
+ // 不需要在这里注入;继承父进程 env 即可。
350
+ const env = process.env
351
+
352
+ return new Promise((resolve) => {
353
+ let stdout = ''
354
+ let stderr = ''
355
+ let settled = false
356
+
357
+ const finish = (result) => {
358
+ if (settled) return
359
+ settled = true
360
+ if (result.ok) recordSend()
361
+ resolve(result)
362
+ }
363
+
364
+ let proc
365
+ try {
366
+ proc = spawnFn(cliBin, args, { env })
367
+ } catch (e) {
368
+ finish({ ok: false, reason: 'cli_spawn_failed', detail: e.message })
369
+ return
370
+ }
371
+
372
+ const timer = setTimeout(() => {
373
+ try { proc.kill('SIGTERM') } catch {}
374
+ finish({ ok: false, reason: 'timeout', stderr })
375
+ }, DEFAULT_TIMEOUT_MS)
376
+ timer.unref?.()
377
+
378
+ proc.stdout?.on('data', (d) => { stdout += d.toString() })
379
+ proc.stderr?.on('data', (d) => { stderr += d.toString() })
380
+ proc.on('error', (e) => {
381
+ clearTimeout(timer)
382
+ finish({ ok: false, reason: 'cli_error', detail: e.message })
383
+ })
384
+ proc.on('close', (code) => {
385
+ clearTimeout(timer)
386
+ if (code !== 0) {
387
+ logger.warn?.(`[openclaw-bridge] cli exit ${code}: ${stderr.trim().slice(0, 240)}`)
388
+ finish({ ok: false, reason: 'cli_failed', exitCode: code, stderr })
389
+ return
390
+ }
391
+ let payload = null
392
+ try { payload = JSON.parse(stdout) } catch {}
393
+ // 记 last-push:peer → sessionId(用于 stdin proxy)
394
+ if (sessionId && rawTarget) {
395
+ lastPushByPeer.set(rawTarget, { sessionId, sentAt: Date.now() })
396
+ }
397
+ finish({ ok: true, payload })
398
+ })
399
+ })
400
+ }
401
+
402
+ /**
403
+ * 拿这个 peer 最近一次被推过的 sessionId(PTY stdin proxy 用)。
404
+ * 超过 maxAgeMs(默认 6 小时)就视为过期 —— 用户体验考虑:只要那个
405
+ * session 还活着,就允许直接回复给它。
406
+ */
407
+ function getLastPushedSession(peer, maxAgeMs = 6 * 60 * 60 * 1000) {
408
+ if (!peer) return null
409
+ const entry = lastPushByPeer.get(peer)
410
+ if (!entry) return null
411
+ if (Date.now() - entry.sentAt > maxAgeMs) {
412
+ lastPushByPeer.delete(peer)
413
+ return null
414
+ }
415
+ return entry.sessionId
416
+ }
417
+
418
+ /**
419
+ * 显式把 peer 绑定到 sessionId(telegram inline button 选 session 用)。
420
+ * 跟 push 路径里的 lastPushByPeer.set 对齐;sentAt 用 now,下次 stdin proxy 路由生效。
421
+ */
422
+ function setLastPushedSession(peer, sessionId) {
423
+ if (!peer || !sessionId) return false
424
+ lastPushByPeer.set(String(peer), { sessionId, sentAt: Date.now() })
425
+ return true
426
+ }
427
+
428
+ /** session 结束时清掉它的 last-push 记录,避免下条用户消息误投到死 session */
429
+ function clearLastPushForSession(sessionId) {
430
+ if (!sessionId) return
431
+ for (const [peer, entry] of lastPushByPeer) {
432
+ if (entry.sessionId === sessionId) lastPushByPeer.delete(peer)
433
+ }
434
+ }
435
+
436
+ /** 用户主动退出 PTY 直连:清这个 peer 的 last-push */
437
+ function clearLastPushForPeer(peer) {
438
+ if (!peer) return false
439
+ return lastPushByPeer.delete(peer)
440
+ }
441
+
442
+ /**
443
+ * 反查:哪些 sessionId 绑定到这个 peer 上。
444
+ */
445
+ function findSessionsByTarget(peer) {
446
+ if (!peer) return []
447
+ const out = []
448
+ for (const [sid, info] of sessionRoutes) {
449
+ // 同一个 peer 可能有多个 session;route 里 targetUserId 可能带或不带后缀
450
+ const tgt = info?.targetUserId || ''
451
+ if (tgt === peer || tgt.startsWith(peer + '@') || peer.startsWith(tgt + '@')) {
452
+ out.push(sid)
453
+ }
454
+ }
455
+ return out
456
+ }
457
+
458
+ /**
459
+ * 反查:按 sessionId 后缀找唯一 session。
460
+ * 权限按钮只携带短码;短码碰撞时保守返回 null,避免点错会话。
461
+ */
462
+ function findSessionByShortId(shortId) {
463
+ if (!shortId) return null
464
+ const short = String(shortId)
465
+ let found = null
466
+ for (const sid of sessionRoutes.keys()) {
467
+ if (!String(sid).endsWith(short)) continue
468
+ if (found) return null
469
+ found = sid
470
+ }
471
+ return found
472
+ }
473
+
474
+ /**
475
+ * 反查:找绑定到 (chatId, threadId/rootMessageId) 的 session。
476
+ * Telegram Topic / Lark thread 路由用:用户在 task topic/thread 里回话时知道写哪个 PTY。
477
+ * 返回 sessionId 或 null。
478
+ */
479
+ function findSessionByRoute({ channel = null, chatId, threadId = null, rootMessageId = null } = {}) {
480
+ if (!chatId) return null
481
+ const targetStr = String(chatId)
482
+ for (const [sid, info] of sessionRoutes) {
483
+ if (channel && info?.channel !== channel) continue
484
+ if (String(info?.targetUserId || '') !== targetStr) continue
485
+ if (rootMessageId) {
486
+ if (info?.rootMessageId === rootMessageId) return sid
487
+ continue
488
+ }
489
+ if ((info?.threadId || null) !== (threadId || null)) continue
490
+ return sid
491
+ }
492
+ return null
493
+ }
494
+
495
+ /**
496
+ * 健康检查:跑 `openclaw doctor` 或简单跑 `openclaw --version`。
497
+ * 仅看是否能起进程 + 退出码 0;不深入语义。
498
+ */
499
+ async function healthCheck() {
500
+ return new Promise((resolve) => {
501
+ let ok = false
502
+ let stderr = ''
503
+ const proc = spawnFn(cliBin, ['--version'], { env: process.env })
504
+ const timer = setTimeout(() => {
505
+ try { proc.kill() } catch {}
506
+ resolve({ ok: false, reason: 'timeout' })
507
+ }, 5_000)
508
+ timer.unref?.()
509
+ proc.stdout?.on('data', () => { ok = true })
510
+ proc.stderr?.on('data', (d) => { stderr += d.toString() })
511
+ proc.on('error', (e) => {
512
+ clearTimeout(timer)
513
+ resolve({ ok: false, reason: 'cli_unavailable', detail: e.message })
514
+ })
515
+ proc.on('close', (code) => {
516
+ clearTimeout(timer)
517
+ if (code === 0 && ok) resolve({ ok: true })
518
+ else resolve({ ok: false, reason: 'cli_failed', exitCode: code, stderr })
519
+ })
520
+ })
521
+ }
522
+
523
+ function describe() {
524
+ const oc = getOpenClawConfig()
525
+ return {
526
+ enabled: Boolean(oc.enabled),
527
+ channel: oc.channel || 'openclaw-weixin',
528
+ gatewayUrl: oc.gatewayUrl || null,
529
+ targetUserIdSet: Boolean(oc.targetUserId),
530
+ sessionRoutesCount: sessionRoutes.size,
531
+ rateLimit: {
532
+ perMin: Math.max(1, Number(oc?.askUser?.rateLimitPerMin) || 6),
533
+ recent: sendTimestamps.length,
534
+ },
535
+ }
536
+ }
537
+
538
+ function setTelegramBot(bot) { telegramBot = bot }
539
+ function setLarkBot(bot) { larkBot = bot || null }
540
+ function setTopicGoneHandler(fn) { topicGoneHandler = typeof fn === 'function' ? fn : null }
541
+ function listSessionRoutes() {
542
+ return Array.from(sessionRoutes.entries()).map(([sessionId, info]) => ({ sessionId, ...info }))
543
+ }
544
+
545
+ return {
546
+ postText,
547
+ healthCheck,
548
+ isEnabled,
549
+ registerSessionRoute,
550
+ clearSessionRoute,
551
+ resolveRoute,
552
+ getLastPushedSession,
553
+ setLastPushedSession,
554
+ clearLastPushForSession,
555
+ clearLastPushForPeer,
556
+ findSessionsByTarget,
557
+ findSessionByShortId,
558
+ findSessionByRoute,
559
+ setTelegramBot,
560
+ setLarkBot,
561
+ setTopicGoneHandler,
562
+ listSessionRoutes,
563
+ hasExplicitRoute,
564
+ describe,
565
+ }
566
+ }