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,875 @@
1
+ /**
2
+ * AgentQuad 自己跑的 Telegram bot:
3
+ * - 长轮询 getUpdates 拿入站消息(含 message_thread_id 用于 Topic 路由)
4
+ * - 出站 sendMessage / sendDocument / createForumTopic / closeForumTopic / editForumTopic
5
+ *
6
+ * 设计原则:
7
+ * - 全 fetch 走 ProxyAgent(HTTPS_PROXY env),与 openclaw-bridge 一致
8
+ * - 入站派发到 wizard.handleInbound({ chatId, threadId, text, fromUserId })
9
+ * - wizard 返回 reply 时,自动 sendMessage 回去(保持 thread)
10
+ * - 安全:白名单 allowedChatIds(空 = 拒所有);不在白名单的消息只 log + drop
11
+ * - offset 持久化到 ~/.agentquad/telegram-offset.json,重启不丢
12
+ * - 失败一律不阻塞主循环,5s 退避
13
+ *
14
+ * 不在 v1:
15
+ * - 媒体(图片/语音/文件)入站
16
+ * - inline keyboard 按钮
17
+ * - 多 bot 多 supergroup
18
+ */
19
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs'
20
+ import { dirname, join } from 'node:path'
21
+ import { Blob } from 'node:buffer'
22
+ import net from 'node:net'
23
+ import { toTelegramV2, toPlainText } from './telegram-markdown.js'
24
+ import { downloadTelegramFile, pickLargestPhoto } from './telegram-image.js'
25
+ import { downloadTelegramVideo, extractTelegramVideo } from './telegram-video.js'
26
+ import { DEFAULT_ROOT_DIR } from './config.js'
27
+
28
+ const TELEGRAM_API = 'https://api.telegram.org'
29
+ const DEFAULT_LONG_POLL_TIMEOUT_SEC = 30
30
+ const DEFAULT_OFFSET_FILE = join(DEFAULT_ROOT_DIR, 'telegram-offset.json')
31
+ const POLL_RETRY_DELAY_MS = 5_000
32
+
33
+ function readProxyUrl() {
34
+ return process.env.HTTPS_PROXY || process.env.https_proxy
35
+ || process.env.HTTP_PROXY || process.env.http_proxy
36
+ || ''
37
+ }
38
+
39
+ /**
40
+ * 走 HTTPS_PROXY 的 fetch。按 proxyUrl 缓存 dispatcher,URL 变了自动切,
41
+ * 这样 Clash / 代理重启不再需要重启 AgentQuad。
42
+ */
43
+ const dispatcherCache = new Map()
44
+ export async function getProxyFetch() {
45
+ const proxyUrl = readProxyUrl()
46
+ const cached = dispatcherCache.get(proxyUrl)
47
+ if (cached) return cached
48
+ let fetcher
49
+ if (!proxyUrl) {
50
+ fetcher = (url, opts) => fetch(url, opts)
51
+ } else {
52
+ try {
53
+ const { ProxyAgent, fetch: undiciFetch } = await import('undici')
54
+ const dispatcher = new ProxyAgent(proxyUrl)
55
+ fetcher = (url, opts = {}) => undiciFetch(url, { ...opts, dispatcher })
56
+ } catch {
57
+ fetcher = (url, opts) => fetch(url, opts)
58
+ }
59
+ }
60
+ dispatcherCache.set(proxyUrl, fetcher)
61
+ return fetcher
62
+ }
63
+
64
+ function tcpProbe(host, port, timeoutMs = 500) {
65
+ return new Promise((resolve) => {
66
+ let settled = false
67
+ const sock = net.createConnection({ host, port })
68
+ const finish = (ok) => {
69
+ if (settled) return
70
+ settled = true
71
+ try { sock.destroy() } catch {}
72
+ resolve(ok)
73
+ }
74
+ sock.once('connect', () => finish(true))
75
+ sock.once('error', () => finish(false))
76
+ sock.setTimeout(timeoutMs, () => finish(false))
77
+ })
78
+ }
79
+
80
+ async function diagnoseProxyReachability() {
81
+ const proxyUrl = readProxyUrl()
82
+ if (!proxyUrl) return { proxyUrl: '', reachable: null }
83
+ let host, port
84
+ try {
85
+ const u = new URL(proxyUrl)
86
+ host = u.hostname
87
+ port = parseInt(u.port, 10) || (u.protocol === 'https:' ? 443 : 80)
88
+ } catch {
89
+ return { proxyUrl, reachable: null }
90
+ }
91
+ const reachable = await tcpProbe(host, port, 500)
92
+ return { proxyUrl, reachable }
93
+ }
94
+
95
+ function readJsonFile(path, fallback) {
96
+ if (!existsSync(path)) return fallback
97
+ try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return fallback }
98
+ }
99
+
100
+ function writeJsonFile(path, data) {
101
+ try {
102
+ mkdirSync(dirname(path), { recursive: true })
103
+ writeFileSync(path, JSON.stringify(data, null, 2))
104
+ } catch { /* 持久化失败不阻塞 */ }
105
+ }
106
+
107
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)) }
108
+
109
+ /**
110
+ * 创建一个 Telegram bot 实例。
111
+ *
112
+ * 依赖:
113
+ * - getConfig: () => 配置(拿 telegram.* + 读 OpenClaw token)
114
+ * - wizard: { handleInbound({chatId, threadId, text, fromUserId}) }
115
+ * - logger
116
+ * - fetchFn: 测试用替身;默认 lazy 加载 undici proxy fetch
117
+ * - offsetFile 测试用
118
+ */
119
+ export function createTelegramBot({
120
+ getConfig,
121
+ wizard,
122
+ reactionTracker = null,
123
+ logger = console,
124
+ fetchFn,
125
+ offsetFile = DEFAULT_OFFSET_FILE,
126
+ } = {}) {
127
+ if (typeof getConfig !== 'function') throw new Error('getConfig_required')
128
+ if (!wizard || typeof wizard.handleInbound !== 'function') throw new Error('wizard_required')
129
+
130
+ let running = false
131
+ let pollPromise = null
132
+ let offset = readJsonFile(offsetFile, { offset: 0 }).offset || 0
133
+ let lastSeenChatId = null
134
+ let consecutiveErrors = 0
135
+
136
+ function getTgConfig() { return getConfig()?.telegram || {} }
137
+
138
+ async function callApi(method, params = {}, opts = {}) {
139
+ const tg = getTgConfig()
140
+ const token = readBotToken(getConfig)
141
+ if (!token) throw new Error('telegram_token_missing')
142
+ const url = `${TELEGRAM_API}/bot${token}/${method}`
143
+ const f = fetchFn || (await getProxyFetch())
144
+ const ctrl = new AbortController()
145
+ const timeoutMs = opts.timeoutMs || (tg.longPollTimeoutSec || DEFAULT_LONG_POLL_TIMEOUT_SEC) * 1000 + 5000
146
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs)
147
+ timer.unref?.()
148
+ try {
149
+ const res = await f(url, {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify(params),
153
+ signal: ctrl.signal,
154
+ })
155
+ const data = await res.json().catch(() => null)
156
+ if (!res.ok || !data?.ok) {
157
+ const desc = data?.description || `HTTP ${res.status}`
158
+ const err = new Error(`telegram_${method}_failed: ${desc}`)
159
+ err.code = data?.error_code
160
+ err.description = desc
161
+ throw err
162
+ }
163
+ return data.result
164
+ } finally {
165
+ clearTimeout(timer)
166
+ }
167
+ }
168
+
169
+ /** 上传文件 multipart/form-data —— sendDocument 用。 */
170
+ async function callApiUpload(method, fields = {}, fileField, filePath, fileName) {
171
+ const tg = getTgConfig()
172
+ const token = readBotToken(getConfig)
173
+ if (!token) throw new Error('telegram_token_missing')
174
+ const url = `${TELEGRAM_API}/bot${token}/${method}`
175
+ const f = fetchFn || (await getProxyFetch())
176
+
177
+ const form = new FormData()
178
+ for (const [k, v] of Object.entries(fields)) {
179
+ if (v == null || v === '') continue
180
+ form.append(k, String(v))
181
+ }
182
+ if (fileField && filePath) {
183
+ const buf = readFileSync(filePath)
184
+ const blob = new Blob([buf])
185
+ form.append(fileField, blob, fileName || 'file.txt')
186
+ }
187
+
188
+ const res = await f(url, { method: 'POST', body: form })
189
+ const data = await res.json().catch(() => null)
190
+ if (!res.ok || !data?.ok) {
191
+ throw new Error(`telegram_${method}_failed: ${data?.description || res.status}`)
192
+ }
193
+ return data.result
194
+ }
195
+
196
+ // ─── 出站 API ─────────────────────────────────────────
197
+
198
+ async function sendMessage({ chatId, threadId, text, parseMode = 'MarkdownV2', disableNotification = false, replyMarkup = null } = {}) {
199
+ if (!chatId || !text) throw new Error('chatId_and_text_required')
200
+ // V2 默认对所有 caller 透明:内部跑 telegramify 转换;caller 想发 raw 可显式传 parseMode=null
201
+ const safeText = parseMode === 'MarkdownV2' ? toTelegramV2(text) : text
202
+ const params = { chat_id: chatId, text: safeText, disable_notification: !!disableNotification }
203
+ if (parseMode) params.parse_mode = parseMode
204
+ if (threadId) params.message_thread_id = threadId
205
+ if (replyMarkup) params.reply_markup = replyMarkup
206
+ try {
207
+ return await callApi('sendMessage', params)
208
+ } catch (e) {
209
+ // V2 / Markdown 解析错(极少 — telegramify 兜过一道 — 但库 bug / 极端 input 仍可能)→ 降级纯文本
210
+ // 用 toPlainText 剥 markdown 标记,避免字面 #### / ** / > 出现在用户面前
211
+ if (parseMode && /parse|entities/i.test(e.description || '')) {
212
+ logger.warn?.(`[telegram-bot] V2 parse failed (${e.description}); retrying as plain text on threadId=${threadId || 'none'}`)
213
+ return await callApi('sendMessage', { ...params, text: toPlainText(text), parse_mode: undefined })
214
+ }
215
+ throw e
216
+ }
217
+ }
218
+
219
+ async function sendDocument({ chatId, threadId, filePath, fileName, caption, parseMode = 'MarkdownV2' } = {}) {
220
+ if (!chatId || !filePath) throw new Error('chatId_and_filePath_required')
221
+ if (!existsSync(filePath)) throw new Error(`file_not_found: ${filePath}`)
222
+ const fields = { chat_id: chatId }
223
+ if (threadId) fields.message_thread_id = threadId
224
+ if (caption) {
225
+ fields.caption = parseMode === 'MarkdownV2' ? toTelegramV2(caption) : caption
226
+ if (parseMode) fields.parse_mode = parseMode
227
+ }
228
+ return await callApiUpload('sendDocument', fields, 'document', filePath, fileName)
229
+ }
230
+
231
+ async function createForumTopic({ chatId, name, iconColor, iconCustomEmojiId } = {}) {
232
+ if (!chatId || !name) throw new Error('chatId_and_name_required')
233
+ const params = { chat_id: chatId, name: String(name).slice(0, 128) }
234
+ if (iconColor != null) params.icon_color = iconColor
235
+ if (iconCustomEmojiId) params.icon_custom_emoji_id = iconCustomEmojiId
236
+ return await callApi('createForumTopic', params, { timeoutMs: 15000 })
237
+ }
238
+
239
+ async function closeForumTopic({ chatId, threadId } = {}) {
240
+ if (!chatId || !threadId) throw new Error('chatId_and_threadId_required')
241
+ return await callApi('closeForumTopic', { chat_id: chatId, message_thread_id: threadId }, { timeoutMs: 15000 })
242
+ }
243
+
244
+ async function reopenForumTopic({ chatId, threadId } = {}) {
245
+ if (!chatId || !threadId) throw new Error('chatId_and_threadId_required')
246
+ return await callApi('reopenForumTopic', { chat_id: chatId, message_thread_id: threadId }, { timeoutMs: 15000 })
247
+ }
248
+
249
+ /**
250
+ * 编辑已有消息(用于 loading 状态条等自更新场景)。
251
+ * 失败种类(caller 可据 detail 判断是否清掉本地 messageId 重新发送):
252
+ * - "message to edit not found" / "MESSAGE_ID_INVALID" → 消息已删 / 太老
253
+ * - "message is not modified" → 内容相同(safely ignore)
254
+ */
255
+ async function editMessageText({ chatId, messageId, text, parseMode = 'MarkdownV2', disableNotification = true, replyMarkup = null } = {}) {
256
+ if (!chatId || !messageId || !text) throw new Error('chatId_messageId_text_required')
257
+ const safeText = parseMode === 'MarkdownV2' ? toTelegramV2(text) : text
258
+ const params = { chat_id: chatId, message_id: messageId, text: safeText, disable_notification: !!disableNotification }
259
+ if (parseMode) params.parse_mode = parseMode
260
+ if (replyMarkup) params.reply_markup = replyMarkup
261
+ try {
262
+ return await callApi('editMessageText', params, { timeoutMs: 10000 })
263
+ } catch (e) {
264
+ // 内容未变 → 静默成功语义
265
+ if (/not modified/i.test(e.description || '')) return { ok: true, unchanged: true }
266
+ // V2 解析失败 → 降级到 plain text 重试一次(剥 markdown 标记,跟 sendMessage 一致)
267
+ if (parseMode && /parse|entities/i.test(e.description || '')) {
268
+ logger.warn?.(`[telegram-bot] editMessageText V2 parse failed (${e.description}); retrying as plain text mid=${messageId}`)
269
+ return await callApi('editMessageText', { ...params, text: toPlainText(text), parse_mode: undefined })
270
+ }
271
+ throw e
272
+ }
273
+ }
274
+
275
+ /**
276
+ * 移除(或替换)已发出消息上的 inline keyboard。
277
+ * 用 reply_markup={inline_keyboard: []} 等价于"清空按钮"。
278
+ * 错误处理跟 editMessageText 一致:not modified → 静默成功;其它错误抛出。
279
+ */
280
+ async function editMessageReplyMarkup({ chatId, messageId, replyMarkup = null } = {}) {
281
+ if (!chatId || !messageId) throw new Error('chatId_and_messageId_required')
282
+ const params = {
283
+ chat_id: chatId,
284
+ message_id: messageId,
285
+ reply_markup: replyMarkup || { inline_keyboard: [] },
286
+ }
287
+ try {
288
+ return await callApi('editMessageReplyMarkup', params, { timeoutMs: 10000 })
289
+ } catch (e) {
290
+ if (/not modified/i.test(e.description || '')) return { ok: true, unchanged: true }
291
+ throw e
292
+ }
293
+ }
294
+
295
+ /**
296
+ * 关闭 callback_query 的 loading 转圈;可选弹 toast / alert。
297
+ * 必须在 ~3s 内回,否则 Telegram 客户端会一直转圈。
298
+ */
299
+ async function answerCallbackQuery({ callbackQueryId, text = '', showAlert = false, cacheTimeSec = 0 } = {}) {
300
+ if (!callbackQueryId) throw new Error('callbackQueryId_required')
301
+ const params = {
302
+ callback_query_id: callbackQueryId,
303
+ show_alert: !!showAlert,
304
+ }
305
+ if (text) params.text = String(text).slice(0, 200)
306
+ if (cacheTimeSec > 0) params.cache_time = cacheTimeSec
307
+ return await callApi('answerCallbackQuery', params, { timeoutMs: 5000 })
308
+ }
309
+
310
+ async function editForumTopic({ chatId, threadId, name, iconCustomEmojiId } = {}) {
311
+ if (!chatId || !threadId) throw new Error('chatId_and_threadId_required')
312
+ const params = { chat_id: chatId, message_thread_id: threadId }
313
+ if (name) params.name = String(name).slice(0, 128)
314
+ if (iconCustomEmojiId !== undefined) params.icon_custom_emoji_id = iconCustomEmojiId
315
+ return await callApi('editForumTopic', params, { timeoutMs: 15000 })
316
+ }
317
+
318
+ async function getMe() {
319
+ return await callApi('getMe', {}, { timeoutMs: 10000 })
320
+ }
321
+
322
+ /**
323
+ * 给消息加 emoji reaction(D 方案:在用户触发消息上显示状态)。
324
+ * emoji=null/空数组 → 清除所有 reaction。
325
+ * 仅支持标准 emoji(不支持 custom_emoji_id),且必须在 Telegram 默认列表内
326
+ * (👀 🎉 💔 🤷 等都在;⏹ 这种"控制字符"不在)。
327
+ */
328
+ async function setMessageReaction({ chatId, messageId, emoji = null, isBig = false } = {}) {
329
+ if (!chatId || !messageId) throw new Error('chatId_and_messageId_required')
330
+ const reaction = emoji
331
+ ? (Array.isArray(emoji) ? emoji : [emoji]).map((e) => ({ type: 'emoji', emoji: e }))
332
+ : []
333
+ return await callApi('setMessageReaction', {
334
+ chat_id: chatId,
335
+ message_id: messageId,
336
+ reaction,
337
+ is_big: !!isBig,
338
+ }, { timeoutMs: 10000 })
339
+ }
340
+
341
+ /**
342
+ * 注册 bot 的 slash 命令菜单。
343
+ * @param {object} opts
344
+ * @param {Array<{command:string,description:string}>} opts.commands
345
+ * @param {string} [opts.scope] - 'default' | 'all_private_chats' | 'all_group_chats' | 'chat'
346
+ * @param {string|number} [opts.chatId] - 当 scope='chat' 时必填(限定到这个 supergroup)
347
+ * @param {string} [opts.languageCode] - 可选,按语言注册('en' / 'zh' 等)
348
+ */
349
+ async function setMyCommands({ commands, scope = 'default', chatId, languageCode } = {}) {
350
+ if (!Array.isArray(commands)) throw new Error('commands_array_required')
351
+ const params = { commands }
352
+ if (scope === 'chat') {
353
+ if (!chatId) throw new Error('chatId_required_for_scope_chat')
354
+ params.scope = { type: 'chat', chat_id: Number(chatId) || chatId }
355
+ } else if (scope && scope !== 'default') {
356
+ params.scope = { type: scope }
357
+ }
358
+ if (languageCode) params.language_code = languageCode
359
+ return await callApi('setMyCommands', params, { timeoutMs: 15000 })
360
+ }
361
+
362
+ /**
363
+ * 清空 slash 命令菜单(同 scope)。
364
+ */
365
+ async function deleteMyCommands({ scope = 'default', chatId, languageCode } = {}) {
366
+ const params = {}
367
+ if (scope === 'chat') {
368
+ if (!chatId) throw new Error('chatId_required_for_scope_chat')
369
+ params.scope = { type: 'chat', chat_id: Number(chatId) || chatId }
370
+ } else if (scope && scope !== 'default') {
371
+ params.scope = { type: scope }
372
+ }
373
+ if (languageCode) params.language_code = languageCode
374
+ return await callApi('deleteMyCommands', params, { timeoutMs: 15000 })
375
+ }
376
+
377
+ // ─── 入站长轮询 ───────────────────────────────────────
378
+
379
+ function isAuthorizedChat(chatId) {
380
+ const tg = getTgConfig()
381
+ const allow = Array.isArray(tg.allowedChatIds) ? tg.allowedChatIds.map(String) : []
382
+ if (allow.length === 0) return false // 空 = 拒所有
383
+ return allow.includes(String(chatId))
384
+ }
385
+
386
+ let probeListener = null
387
+ function setProbeListener(fn) {
388
+ probeListener = (typeof fn === 'function') ? fn : null
389
+ }
390
+
391
+ /**
392
+ * 处理 inline keyboard 按钮点击。
393
+ *
394
+ * 流程:
395
+ * 1. 鉴权(白名单 chatId)
396
+ * 2. 调 wizard.handleCallback({chatId, threadId, callbackData, callbackMessageId, fromUserId})
397
+ * —— 由 wizard 决定怎么处理(推进状态 / 触发 finalize / 等待自定义文本输入)
398
+ * 3. 不论 wizard 返回什么,先 answerCallbackQuery 关 loading
399
+ * 4. wizard 返回 editOriginal=true → editMessageReplyMarkup 把按钮去掉 + editMessageText
400
+ * 在原文末尾追加 "✓ 已选: …",避免历史滚屏看不出选了什么
401
+ * 5. wizard 返回 reply 字符串 → sendMessage 发新一步 prompt(带新 reply_markup)
402
+ *
403
+ * 安全:wizard 没实现 handleCallback 时也得 answerCallbackQuery,否则用户客户端一直转圈
404
+ */
405
+ async function dispatchCallbackQuery(cq) {
406
+ const callbackQueryId = cq.id
407
+ const msg = cq.message || {}
408
+ const chatId = String(msg.chat?.id || '')
409
+ const threadId = msg.message_thread_id || null
410
+ const fromUserId = cq.from ? String(cq.from.id) : null
411
+ const callbackMessageId = msg.message_id || null
412
+ const data = String(cq.data || '')
413
+
414
+ // 鉴权失败 → answer 一下避免转圈,不回任何业务消息
415
+ if (!isAuthorizedChat(chatId)) {
416
+ logger.warn?.(`[telegram-bot] dropped callback_query from unauthorized chat=${chatId}`)
417
+ try { await answerCallbackQuery({ callbackQueryId }) } catch {}
418
+ return
419
+ }
420
+
421
+ // wizard 没实现 handleCallback → 当作 noop,至少 answer 掉 loading
422
+ if (typeof wizard.handleCallback !== 'function') {
423
+ logger.warn?.(`[telegram-bot] callback_query received but wizard has no handleCallback; data=${data}`)
424
+ try { await answerCallbackQuery({ callbackQueryId, text: '该功能未启用' }) } catch {}
425
+ return
426
+ }
427
+
428
+ let result
429
+ try {
430
+ result = await wizard.handleCallback({
431
+ chatId,
432
+ threadId,
433
+ callbackData: data,
434
+ callbackMessageId,
435
+ fromUserId,
436
+ })
437
+ } catch (e) {
438
+ logger.warn?.(`[telegram-bot] wizard.handleCallback threw: ${e.message}`)
439
+ try { await answerCallbackQuery({ callbackQueryId, text: '处理失败' }) } catch {}
440
+ return
441
+ }
442
+
443
+ // 1) answer,关 loading(带可选 toast)
444
+ try {
445
+ await answerCallbackQuery({
446
+ callbackQueryId,
447
+ text: result?.toast || '',
448
+ showAlert: !!result?.showAlert,
449
+ })
450
+ } catch (e) {
451
+ logger.warn?.(`[telegram-bot] answerCallbackQuery failed: ${e.message}`)
452
+ }
453
+
454
+ // 2) 编辑原消息:去按钮 + 在末尾标记 "✓ 已选: …"
455
+ // editOriginal=false 时跳过(譬如 wizard 想保留按钮让用户多选)
456
+ // chosenLabel 缺省时只去按钮,不改文本
457
+ if (result?.editOriginal !== false && callbackMessageId) {
458
+ const originalText = msg.text || ''
459
+ try {
460
+ if (result?.chosenLabel && originalText) {
461
+ await editMessageText({
462
+ chatId,
463
+ messageId: callbackMessageId,
464
+ text: `${originalText}\n\n✓ 已选: ${result.chosenLabel}`,
465
+ replyMarkup: { inline_keyboard: [] },
466
+ })
467
+ } else {
468
+ await editMessageReplyMarkup({
469
+ chatId,
470
+ messageId: callbackMessageId,
471
+ replyMarkup: { inline_keyboard: [] },
472
+ })
473
+ }
474
+ } catch (e) {
475
+ // 消息太老 / 已删 → 不阻塞主流程
476
+ logger.warn?.(`[telegram-bot] edit original after callback failed: ${e.message}`)
477
+ }
478
+ }
479
+
480
+ // 3) 发下一步 prompt(可能带新按钮 / force_reply)
481
+ if (result && typeof result.reply === 'string' && result.reply !== '') {
482
+ try {
483
+ const sent = await sendMessage({
484
+ chatId,
485
+ threadId,
486
+ text: result.reply,
487
+ replyMarkup: result.replyMarkup || null,
488
+ })
489
+ // ask_user 的 ✏️ 补充流:把"刚发出的 force_reply 消息 id"回灌到 wizard,
490
+ // 这样用户回复时 wizard 能用 reply_to_message_id 反查上下文
491
+ if (result.forceReplyContext && sent?.message_id && typeof wizard.registerForceReplyContext === 'function') {
492
+ try {
493
+ wizard.registerForceReplyContext({
494
+ ...result.forceReplyContext,
495
+ chatId,
496
+ messageId: sent.message_id,
497
+ })
498
+ } catch (e) {
499
+ logger.warn?.(`[telegram-bot] registerForceReplyContext failed: ${e.message}`)
500
+ }
501
+ }
502
+ } catch (e) {
503
+ logger.warn?.(`[telegram-bot] sendMessage after callback failed: ${e.message}`)
504
+ }
505
+ }
506
+ }
507
+
508
+ async function dispatch(update) {
509
+ // ─── callback_query:inline keyboard 按钮点击 ────────────────
510
+ // 单独走,不跟 message 路径混;有自己的鉴权 + 路由 + answerCallbackQuery 兜底
511
+ if (update.callback_query) {
512
+ await dispatchCallbackQuery(update.callback_query)
513
+ return
514
+ }
515
+ const msg = update.message
516
+ if (!msg) return
517
+ const chatId = String(msg.chat.id)
518
+ const threadId = msg.message_thread_id || null
519
+ lastSeenChatId = chatId
520
+ // Probe listener:在白名单检查前 fork 一份给订阅者(拿 chatId 用)
521
+ if (probeListener) {
522
+ try {
523
+ probeListener({
524
+ chatId: String(msg.chat.id),
525
+ chatTitle: msg.chat.title || msg.chat.username || null,
526
+ chatType: msg.chat.type || null,
527
+ fromUserId: msg.from ? String(msg.from.id) : null,
528
+ fromUsername: msg.from?.username || null,
529
+ textPreview: typeof msg.text === 'string' ? msg.text.slice(0, 80) : null,
530
+ at: Date.now(),
531
+ })
532
+ } catch (e) {
533
+ logger.warn?.(`[telegram-bot] probeListener threw: ${e.message}`)
534
+ }
535
+ }
536
+ if (!isAuthorizedChat(chatId)) {
537
+ logger.warn?.(`[telegram-bot] dropped message from unauthorized chat=${chatId} (allowedChatIds 未配置或不含此 chat)`)
538
+ return
539
+ }
540
+
541
+ // ─── 话题生命周期事件(service messages,无 text) ────────────
542
+ if (msg.forum_topic_closed && wizard.handleTopicEvent) {
543
+ try {
544
+ await wizard.handleTopicEvent({ type: 'closed', chatId, threadId })
545
+ } catch (e) {
546
+ logger.warn?.(`[telegram-bot] handleTopicEvent(closed) threw: ${e.message}`)
547
+ }
548
+ return
549
+ }
550
+ if (msg.forum_topic_reopened && wizard.handleTopicEvent) {
551
+ try {
552
+ await wizard.handleTopicEvent({ type: 'reopened', chatId, threadId })
553
+ } catch (e) {
554
+ logger.warn?.(`[telegram-bot] handleTopicEvent(reopened) threw: ${e.message}`)
555
+ }
556
+ return
557
+ }
558
+
559
+ // ─── 图片处理:下载到本地,把 @path 喂给 PTY 当 attach ────
560
+ // photo 是 array of PhotoSize,挑最大那张。下载失败 → imagePaths=null + 警告,
561
+ // 但**不能丢消息**:caption 是用户的文字,也得继续走 wizard。
562
+ let imagePaths = null
563
+ let photoDownloadFailed = false
564
+ const hasPhoto = Array.isArray(msg.photo) && msg.photo.length > 0
565
+ if (hasPhoto) {
566
+ const largest = pickLargestPhoto(msg.photo)
567
+ if (largest?.file_id) {
568
+ const token = readBotToken(getConfig)
569
+ if (token) {
570
+ const fetcher = fetchFn || (await getProxyFetch())
571
+ // 网络抖动重试 1 次,跟 bridge 的策略一致
572
+ const tryDownload = () => downloadTelegramFile({
573
+ token, fetchFn: fetcher,
574
+ fileId: largest.file_id, fileSize: largest.file_size,
575
+ })
576
+ try {
577
+ let r
578
+ try { r = await tryDownload() }
579
+ catch (e1) {
580
+ if (/fetch failed|fetch_error|aborted|timeout/i.test(e1.message)) {
581
+ logger.warn?.(`[telegram-bot] photo download transient error (${e1.message}); retrying once in 1s`)
582
+ await new Promise((res) => setTimeout(res, 1000))
583
+ r = await tryDownload()
584
+ } else {
585
+ throw e1
586
+ }
587
+ }
588
+ imagePaths = [r.localPath]
589
+ logger.info?.(`[telegram-bot] downloaded photo file_id=${largest.file_id.slice(0, 12)}… → ${r.localPath} (${(r.fileSize / 1024).toFixed(1)}kB)`)
590
+ } catch (e) {
591
+ photoDownloadFailed = true
592
+ logger.warn?.(`[telegram-bot] photo download failed: ${e.message}`)
593
+ }
594
+ } else {
595
+ photoDownloadFailed = true
596
+ logger.warn?.(`[telegram-bot] photo received but no bot token to download with`)
597
+ }
598
+ }
599
+ }
600
+
601
+ // ─── 视频处理:跟图片一样下载本地,把路径塞进 imagePaths(CC 自己消化),
602
+ // 额外在 caption 里追加一行 [用户发了视频:xxx] 让 CC 明确知道附件是视频。
603
+ // >20MB 不下载,给用户回提示;下载失败逻辑参考图片。
604
+ let videoPath = null
605
+ let videoTooLarge = false
606
+ let videoDownloadFailed = false
607
+ let videoCaptionTag = null
608
+ const videoMeta = extractTelegramVideo(msg)
609
+ if (videoMeta?.fileId) {
610
+ const labelName = videoMeta.fileName || `${videoMeta.kind}.mp4`
611
+ const sizeBytes = videoMeta.fileSize || 0
612
+ if (sizeBytes && sizeBytes > 20 * 1024 * 1024) {
613
+ videoTooLarge = true
614
+ logger.warn?.(`[telegram-bot] video too large kind=${videoMeta.kind} size=${(sizeBytes / 1024 / 1024).toFixed(1)}MB`)
615
+ } else {
616
+ const token = readBotToken(getConfig)
617
+ if (token) {
618
+ const fetcher = fetchFn || (await getProxyFetch())
619
+ try {
620
+ const r = await downloadTelegramVideo({
621
+ token, fetchFn: fetcher,
622
+ fileId: videoMeta.fileId, fileSize: videoMeta.fileSize,
623
+ })
624
+ videoPath = r.localPath
625
+ videoCaptionTag = `[用户发了视频:${labelName}]`
626
+ logger.info?.(`[telegram-bot] downloaded video kind=${videoMeta.kind} → ${r.localPath} (${(r.fileSize / 1024).toFixed(1)}kB)`)
627
+ } catch (e) {
628
+ videoDownloadFailed = true
629
+ logger.warn?.(`[telegram-bot] video download failed: ${e.message}`)
630
+ }
631
+ } else {
632
+ videoDownloadFailed = true
633
+ logger.warn?.(`[telegram-bot] video received but no bot token to download with`)
634
+ }
635
+ }
636
+ }
637
+ if (videoPath) {
638
+ imagePaths = [...(imagePaths || []), videoPath]
639
+ }
640
+
641
+ // 既无文本(含 caption)也无图片/视频 → drop
642
+ const hasText = (msg.text && typeof msg.text === 'string') || (msg.caption && typeof msg.caption === 'string')
643
+ if (!imagePaths && !hasText && !videoTooLarge) {
644
+ // 其他非文本/非图/非视频(sticker/system msg)暂不处理
645
+ return
646
+ }
647
+
648
+ const fromUserId = msg.from ? String(msg.from.id) : null
649
+ // text:图片/视频消息时优先用 caption;纯文本消息用 text
650
+ const rawText = msg.text || msg.caption || ''
651
+ // Group 里点 slash 命令时 Telegram 自动加 @botUsername 做消歧
652
+ // (`/review` → `/review@lzhtestBot`),这里剥掉,让 PTY / wizard 收到干净的 `/review`
653
+ // 只剥**消息开头**首词的 @xxx,正文中的 @ 不动
654
+ let text = rawText.replace(/^(\/[A-Za-z0-9_]+)@\w+/, '$1')
655
+ if (videoCaptionTag) {
656
+ text = text ? `${videoCaptionTag}\n${text}` : videoCaptionTag
657
+ }
658
+
659
+ // 视频太大:直接告知用户,不进 wizard(caption 文本若有则继续走)
660
+ if (videoTooLarge && !imagePaths && !hasText) {
661
+ try {
662
+ await sendMessage({
663
+ chatId, threadId,
664
+ text: '⚠️ 视频太大(>20MB),请压缩或截短后重发。',
665
+ })
666
+ } catch {}
667
+ return
668
+ }
669
+ if (videoTooLarge) {
670
+ try {
671
+ await sendMessage({
672
+ chatId, threadId,
673
+ text: '⚠️ 视频太大(>20MB),无法转给 AI;caption 文字部分已送达。',
674
+ })
675
+ } catch {}
676
+ }
677
+
678
+ let result
679
+ try {
680
+ result = await wizard.handleInbound({
681
+ chatId, threadId, text, fromUserId,
682
+ messageId: msg.message_id,
683
+ // 用户 reply 我们之前发的消息时带这个;wizard 用它匹配 force_reply 上下文
684
+ replyToMessageId: msg.reply_to_message?.message_id || null,
685
+ imagePaths,
686
+ })
687
+ } catch (e) {
688
+ logger.warn?.(`[telegram-bot] wizard.handleInbound threw: ${e.message}`)
689
+ return
690
+ }
691
+ // wizard 返回 sessionId 表示这条消息触发了一轮 PTY 处理 → 通知 reactionTracker
692
+ // 加 ✍ reaction,并记录 (chatId, messageId);等 Stop hook 触发 clearReactionsForSession 时统一删
693
+ if (result?.sessionId && reactionTracker?.noteUserMessage) {
694
+ reactionTracker.noteUserMessage({
695
+ sessionId: result.sessionId,
696
+ chatId,
697
+ messageId: msg.message_id,
698
+ }).catch((e) => logger.warn?.(`[telegram-bot] reactionTracker.noteUserMessage failed: ${e.message}`))
699
+ }
700
+
701
+ // 图片下载失败时给用户提个示:我们用 caption 当文本送了,但图丢了
702
+ if (photoDownloadFailed && result?.action === 'stdin_proxy') {
703
+ try {
704
+ await sendMessage({
705
+ chatId, threadId,
706
+ text: '⚠️ 图片下载失败(网络问题),仅文本部分已转给 AI。要让 AI 看图请重发一次。',
707
+ })
708
+ } catch {}
709
+ }
710
+ // 视频下载失败:跟图片同款提示
711
+ if (videoDownloadFailed && result?.action === 'stdin_proxy') {
712
+ try {
713
+ await sendMessage({
714
+ chatId, threadId,
715
+ text: '⚠️ 视频下载失败(网络问题),仅文本部分已转给 AI。要让 AI 看视频请重发一次。',
716
+ })
717
+ } catch {}
718
+ }
719
+ if (result && typeof result.reply === 'string' && result.reply !== '') {
720
+ try {
721
+ await sendMessage({
722
+ chatId, threadId, text: result.reply,
723
+ replyMarkup: result.replyMarkup || null,
724
+ })
725
+ } catch (e) {
726
+ logger.warn?.(`[telegram-bot] sendMessage reply failed: ${e.message}`)
727
+ }
728
+ }
729
+ }
730
+
731
+ function persistOffset() {
732
+ writeJsonFile(offsetFile, { offset, savedAt: Date.now(), lastSeenChatId })
733
+ }
734
+
735
+ async function pollOnce() {
736
+ const tg = getTgConfig()
737
+ const timeoutSec = tg.longPollTimeoutSec || DEFAULT_LONG_POLL_TIMEOUT_SEC
738
+ const updates = await callApi('getUpdates', {
739
+ offset,
740
+ timeout: timeoutSec,
741
+ allowed_updates: ['message', 'callback_query', 'forum_topic_created', 'forum_topic_closed', 'forum_topic_reopened'],
742
+ })
743
+ if (!Array.isArray(updates) || updates.length === 0) return 0
744
+ for (const u of updates) {
745
+ offset = (u.update_id || offset) + 1
746
+ try {
747
+ await dispatch(u)
748
+ } catch (e) {
749
+ logger.warn?.(`[telegram-bot] dispatch error: ${e.message}`)
750
+ }
751
+ }
752
+ persistOffset()
753
+ return updates.length
754
+ }
755
+
756
+ async function pollLoop() {
757
+ consecutiveErrors = 0
758
+ let lastErrorMsg = null
759
+ let lastErrorLoggedAt = 0
760
+ let currentErrorStreak = 0
761
+ let suppressedErrorCount = 0
762
+ const ERROR_VERBOSE_THRESHOLD = 3
763
+ const ERROR_QUIET_INTERVAL_MS = 5 * 60 * 1000
764
+ while (running) {
765
+ try {
766
+ await pollOnce()
767
+ if (consecutiveErrors > 0) {
768
+ logger.info?.(`[telegram-bot] poll recovered after ${consecutiveErrors} errors`)
769
+ }
770
+ consecutiveErrors = 0
771
+ lastErrorMsg = null
772
+ lastErrorLoggedAt = 0
773
+ currentErrorStreak = 0
774
+ suppressedErrorCount = 0
775
+ } catch (e) {
776
+ consecutiveErrors++
777
+ const baseDelayMs = getTgConfig().pollRetryDelayMs || POLL_RETRY_DELAY_MS
778
+ const backoff = Math.min(60_000, baseDelayMs * consecutiveErrors)
779
+ const msg = e.message || String(e)
780
+ const now = Date.now()
781
+ if (msg === lastErrorMsg) currentErrorStreak++
782
+ else currentErrorStreak = 1
783
+ const shouldLog = currentErrorStreak <= ERROR_VERBOSE_THRESHOLD
784
+ || (now - lastErrorLoggedAt >= ERROR_QUIET_INTERVAL_MS)
785
+ if (shouldLog) {
786
+ const diag = await diagnoseProxyReachability().catch(() => null)
787
+ const diagStr = diag && diag.proxyUrl
788
+ ? ` proxyUrl=${diag.proxyUrl} proxyReachable=${diag.reachable ? 'yes' : 'no'}`
789
+ : ''
790
+ const suffix = suppressedErrorCount > 0 ? ` (suppressed ${suppressedErrorCount} similar)` : ''
791
+ logger.warn?.(`[telegram-bot] poll error (${consecutiveErrors}): ${msg}; retry in ${backoff}ms${diagStr}${suffix}`)
792
+ lastErrorMsg = msg
793
+ lastErrorLoggedAt = now
794
+ suppressedErrorCount = 0
795
+ } else {
796
+ suppressedErrorCount++
797
+ }
798
+ if (running) await sleep(backoff)
799
+ }
800
+ }
801
+ }
802
+
803
+ function start() {
804
+ if (running) return
805
+ running = true
806
+ pollPromise = pollLoop().catch((e) => logger.warn?.(`[telegram-bot] loop crashed: ${e.message}`))
807
+ logger.info?.(`[telegram-bot] started; offset=${offset}`)
808
+ }
809
+
810
+ async function stop() {
811
+ if (!running) return
812
+ running = false
813
+ persistOffset()
814
+ // 不强中断 inflight long-poll;它会在下一次 timeout 时自然返回
815
+ }
816
+
817
+ function describe() {
818
+ const tg = getTgConfig()
819
+ return {
820
+ enabled: !!tg.enabled,
821
+ running,
822
+ offset,
823
+ lastSeenChatId,
824
+ allowedChatIds: tg.allowedChatIds || [],
825
+ consecutiveErrors,
826
+ hasToken: !!readBotToken(getConfig),
827
+ }
828
+ }
829
+
830
+ return {
831
+ start,
832
+ stop,
833
+ sendMessage,
834
+ sendDocument,
835
+ editMessageText,
836
+ editMessageReplyMarkup,
837
+ answerCallbackQuery,
838
+ setMessageReaction,
839
+ createForumTopic,
840
+ closeForumTopic,
841
+ reopenForumTopic,
842
+ editForumTopic,
843
+ setMyCommands,
844
+ deleteMyCommands,
845
+ getMe,
846
+ pollOnce, // 测试用:触发一次拉取
847
+ isAuthorizedChat, // 测试用
848
+ setProbeListener,
849
+ describe,
850
+ __getPollRetryDelayMs: () => getTgConfig().pollRetryDelayMs || POLL_RETRY_DELAY_MS,
851
+ }
852
+ }
853
+
854
+ /**
855
+ * 读 bot token,并返回来源标记。
856
+ * - source: "agentquad" | "missing"
857
+ */
858
+ export function readBotTokenWithSource(getConfig) {
859
+ const tg = getConfig?.()?.telegram || {}
860
+ if (tg.botToken && typeof tg.botToken === 'string') {
861
+ return { token: tg.botToken, source: 'agentquad' }
862
+ }
863
+ return { token: null, source: 'missing' }
864
+ }
865
+
866
+ /** 兼容旧调用方:只返回 token 字符串。新代码请用 readBotTokenWithSource。 */
867
+ export function readBotToken(getConfig) {
868
+ return readBotTokenWithSource(getConfig).token
869
+ }
870
+
871
+ export const __test__ = { readJsonFile, writeJsonFile }
872
+
873
+ // 旧别名,给老测试用;新代码请直接用 getProxyFetch。
874
+ export async function __getProxyFetch() { return getProxyFetch() }
875
+ export function __resetProxyFetchCache() { dispatcherCache.clear() }