agentquad 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/dist-web/assets/index-CMaXwixo.js +1234 -0
- package/dist-web/assets/index-DBHApzV1.css +32 -0
- package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
- package/dist-web/assets/logo-D4DDtU-r.png +0 -0
- package/dist-web/favicon.png +0 -0
- package/dist-web/index.html +14 -0
- package/package.json +88 -0
- package/src/ask-user-buttons.js +142 -0
- package/src/claude-transcript.js +203 -0
- package/src/cli.js +1040 -0
- package/src/codex-event-emitter.js +111 -0
- package/src/codex-prompt-detector.js +53 -0
- package/src/codex-sidecar.js +52 -0
- package/src/codex-transcript.js +74 -0
- package/src/config.js +692 -0
- package/src/data/claude-code-commands.json +52 -0
- package/src/db.js +1503 -0
- package/src/dispatch.js +13 -0
- package/src/export/todoMarkdown.js +246 -0
- package/src/first-run-wizard.js +82 -0
- package/src/git/gitStatus.js +139 -0
- package/src/lark-api-client.js +205 -0
- package/src/lark-bot.js +510 -0
- package/src/lark-card.js +88 -0
- package/src/lark-config-service.js +16 -0
- package/src/lark-event-client.js +107 -0
- package/src/lark-image.js +99 -0
- package/src/lark-markdown.js +51 -0
- package/src/lark-video.js +163 -0
- package/src/mcp/audit.js +34 -0
- package/src/mcp/server.js +83 -0
- package/src/mcp/tools/destructive/index.js +252 -0
- package/src/mcp/tools/openclaw/index.js +405 -0
- package/src/mcp/tools/read/index.js +269 -0
- package/src/mcp/tools/write/index.js +157 -0
- package/src/openclaw-bridge.js +566 -0
- package/src/openclaw-hook-installer.js +338 -0
- package/src/openclaw-hook.js +908 -0
- package/src/openclaw-wizard.js +2442 -0
- package/src/pending-questions.js +297 -0
- package/src/pricing.js +45 -0
- package/src/prompt-render.js +36 -0
- package/src/pty.js +992 -0
- package/src/routes/ai-terminal.js +1228 -0
- package/src/routes/git.js +89 -0
- package/src/routes/openclaw-hook.js +67 -0
- package/src/routes/openclaw-inbound.js +36 -0
- package/src/routes/recurringRules.js +80 -0
- package/src/routes/reports.js +50 -0
- package/src/routes/search.js +46 -0
- package/src/routes/stats.js +31 -0
- package/src/routes/telegram-config.js +152 -0
- package/src/routes/telegram-sync.js +221 -0
- package/src/routes/templates.js +63 -0
- package/src/routes/todos.js +649 -0
- package/src/routes/transcripts.js +75 -0
- package/src/routes/uploads.js +107 -0
- package/src/routes/wiki.js +142 -0
- package/src/search/fts.js +209 -0
- package/src/search/index.js +199 -0
- package/src/search/transcripts.js +148 -0
- package/src/server.js +1791 -0
- package/src/session-input-dispatcher.js +256 -0
- package/src/stats/markdown.js +42 -0
- package/src/stats/report.js +207 -0
- package/src/summarize.js +84 -0
- package/src/system-rules.js +52 -0
- package/src/telegram-bot.js +875 -0
- package/src/telegram-commands.js +149 -0
- package/src/telegram-config-service.js +84 -0
- package/src/telegram-image.js +95 -0
- package/src/telegram-loading-status.js +112 -0
- package/src/telegram-markdown.js +82 -0
- package/src/telegram-reaction-tracker.js +69 -0
- package/src/telegram-video.js +75 -0
- package/src/templates/claude-hooks/notify.js +103 -0
- package/src/transcript.js +305 -0
- package/src/transcripts/blocks.js +56 -0
- package/src/transcripts/index.js +222 -0
- package/src/transcripts/indexer.js +34 -0
- package/src/transcripts/matcher.js +70 -0
- package/src/transcripts/scanner.js +259 -0
- package/src/usage-footer.js +170 -0
- package/src/usage-parser.js +132 -0
- package/src/wiki/guide.js +44 -0
- package/src/wiki/index.js +232 -0
- package/src/wiki/redact.js +34 -0
- package/src/wiki/sources.js +122 -0
|
@@ -0,0 +1,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() }
|