@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.11.1

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.
@@ -0,0 +1,663 @@
1
+ /**
2
+ * Telegram channel adapter (platform 'telegram', kind 'telegram_bot').
3
+ *
4
+ * Official documentation followed (read 2026-11):
5
+ * - Bot API reference ............ https://core.telegram.org/bots/api
6
+ * · Making requests / ResponseParameters (ok, description, error_code, parameters.retry_after)
7
+ * · getUpdates (offset, limit, timeout, allowed_updates; "will not work if an outgoing webhook is set up")
8
+ * · getWebhookInfo / deleteWebhook (drop_pending_updates)
9
+ * · sendMessage (text 1-4096 characters, parse_mode, link_preview_options, reply_parameters, reply_markup)
10
+ * · sendPhoto (multipart/form-data upload, caption 0-1024 characters)
11
+ * · HTML style formatting (only b/i/u/s/code/pre/a/blockquote...; escape & < >)
12
+ * · InlineKeyboardButton.callback_data (1-64 bytes), CallbackQuery, answerCallbackQuery (text 0-200)
13
+ * · MessageEntity (mention / text_mention / bot_command, offsets in UTF-16 code units)
14
+ * · setMessageReaction + ReactionTypeEmoji (👀 👍 👎 are in the allowed emoji list)
15
+ * - Privacy mode (groups: bots receive commands, replies and mentions) — https://core.telegram.org/bots/features#privacy-mode
16
+ *
17
+ * No SDK: plain fetch (via ./http.js request, every call has a timeout).
18
+ */
19
+ import { randomBytes } from 'node:crypto'
20
+ import { ChannelError, request } from './http.js'
21
+ import { renderChartPng as defaultRenderChartPng, chartable, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown } from './message.js'
22
+
23
+ export const DEFAULT_API_BASE = 'https://api.telegram.org'
24
+ export const TEXT_LIMIT = 4096
25
+ const CHUNK = 4000
26
+ const CAPTION_LIMIT = 1024
27
+ const REACTIONS = { working: '👀', done: '👍', failed: '👎' }
28
+
29
+ const cfg = (channel, key) => channel?.[key] ?? channel?.config?.[key]
30
+ const sleep = (ms, signal) => new Promise((resolve) => {
31
+ if (signal?.aborted) return resolve()
32
+ const timer = setTimeout(done, ms)
33
+ function done() { clearTimeout(timer); signal?.removeEventListener?.('abort', done); resolve() }
34
+ signal?.addEventListener?.('abort', done, { once: true })
35
+ })
36
+
37
+ // ------------------------------------------------------------------ API
38
+
39
+ export function apiBaseOf(channel) {
40
+ return String(cfg(channel, 'apiBase') || DEFAULT_API_BASE).replace(/\/+$/, '')
41
+ }
42
+
43
+ /** Map a Bot API error to a ChannelError with a Chinese message. */
44
+ export function telegramError(json, status) {
45
+ const code = json?.error_code ?? status
46
+ const desc = String(json?.description ?? json?.raw ?? '').slice(0, 300)
47
+ const retryAfter = json?.parameters?.retry_after
48
+ let err
49
+ if (code === 401 || code === 404 && /not found/i.test(desc) && !/chat/i.test(desc)) err = new ChannelError('channel/auth', 'Telegram Bot Token 无效或已被撤销,请在 @BotFather 重新获取')
50
+ else if (code === 429) err = new ChannelError('channel/rate-limit', `Telegram 限流,请在 ${retryAfter ?? '几'} 秒后重试`)
51
+ else if (code === 409) err = new ChannelError('channel/rejected', /webhook/i.test(desc) ? 'Telegram 已设置 Webhook,无法使用长轮询(将自动删除 Webhook 后重试)' : '同一个 Bot Token 正在别处轮询消息(getUpdates 冲突),请停止其他实例')
52
+ else if (json?.parameters?.migrate_to_chat_id) err = new ChannelError('channel/config', `该群已升级为超级群,请把 Chat ID 改为 ${json.parameters.migrate_to_chat_id}`)
53
+ else if (code === 403) err = new ChannelError('channel/rejected', /blocked/i.test(desc) ? '用户已屏蔽机器人' : /kicked|not a member|no rights|not enough rights/i.test(desc) ? '机器人不在该聊天中或没有发言权限,请先把机器人加入群/频道(频道需设为管理员)' : `Telegram 拒绝了请求:${desc}`)
54
+ else if (code === 400 && /chat not found/i.test(desc)) err = new ChannelError('channel/config', '找不到该聊天:请确认 Chat ID 正确,并已把机器人加入该群/频道(私聊需用户先给机器人发消息)')
55
+ else if (code === 400 && /can't parse entities/i.test(desc)) err = new ChannelError('channel/telegram-parse', `消息格式解析失败:${desc}`)
56
+ else err = new ChannelError('channel/rejected', `Telegram 返回错误(${code ?? '未知'}):${desc || '未知错误'}`)
57
+ err.status = status
58
+ err.platformCode = code
59
+ err.description = desc
60
+ if (retryAfter) err.retryAfter = retryAfter
61
+ return err
62
+ }
63
+
64
+ function withSignal(fetchImpl, signal) {
65
+ if (!signal) return fetchImpl
66
+ return (url, init = {}) => fetchImpl(url, { ...init, signal: init.signal ? AbortSignal.any([init.signal, signal]) : signal })
67
+ }
68
+
69
+ /**
70
+ * Call a Bot API method. `params` is sent as JSON, or pass `form` (FormData) for uploads.
71
+ * Returns `result`; throws ChannelError on `ok: false`.
72
+ */
73
+ export async function callApi({ base, token, fetch: fetchImpl = globalThis.fetch }, method, params, { form, timeoutMs = 15000, signal } = {}) {
74
+ if (!token) throw new ChannelError('channel/config', '缺少 Telegram Bot Token')
75
+ const url = `${base}/bot${token}/${method}`
76
+ const res = await request(url, {
77
+ method: 'POST',
78
+ ...(form ? { body: form } : { json: params ?? {} }),
79
+ timeoutMs,
80
+ label: 'Telegram ',
81
+ fetch: withSignal(fetchImpl, signal),
82
+ })
83
+ if (res.json?.ok === true) return res.json.result
84
+ throw telegramError(res.json, res.status)
85
+ }
86
+
87
+ // ------------------------------------------------------- Markdown → HTML
88
+
89
+ export const escapeHtml = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
90
+ const escapeAttr = (s) => escapeHtml(s).replace(/"/g, '&quot;')
91
+
92
+ /** Telegram HTML back to plain text (fallback when the HTML is rejected). */
93
+ export function htmlToPlain(html) {
94
+ return String(html ?? '').replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&amp;/g, '&')
95
+ }
96
+
97
+ function emphasis(escaped) {
98
+ return escaped
99
+ .replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, '<b>$1</b>')
100
+ .replace(/~~(?=\S)(.+?)(?<=\S)~~/g, '<s>$1</s>')
101
+ .replace(/(^|[^\w*])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?![\w*])/g, '$1<i>$2</i>')
102
+ }
103
+
104
+ /** Inline Markdown (code, links, bold, italic, strike) → Telegram HTML. Everything else is escaped. */
105
+ export function inlineToHtml(raw) {
106
+ const s = String(raw ?? '')
107
+ let out = ''
108
+ let last = 0
109
+ for (const m of s.matchAll(/`([^`\n]+)`|\[([^\]\n]+)\]\(([^)\s]+)\)/g)) {
110
+ out += emphasis(escapeHtml(s.slice(last, m.index)))
111
+ if (m[1] !== undefined) out += `<code>${escapeHtml(m[1])}</code>`
112
+ else if (/^(https?:\/\/|tg:\/\/|mailto:)/i.test(m[3])) out += `<a href="${escapeAttr(m[3])}">${emphasis(escapeHtml(m[2]))}</a>`
113
+ else out += emphasis(escapeHtml(m[0]))
114
+ last = m.index + m[0].length
115
+ }
116
+ return out + emphasis(escapeHtml(s.slice(last)))
117
+ }
118
+
119
+ const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/
120
+ function displayWidth(s) {
121
+ let w = 0
122
+ for (const ch of String(s)) w += WIDE.test(ch) || ch.codePointAt(0) > 0x1F000 ? 2 : 1
123
+ return w
124
+ }
125
+ function splitRow(line) {
126
+ return line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '').split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|').replace(/\*\*/g, ''))
127
+ }
128
+
129
+ /** A Markdown table (array of lines, 2nd is the separator) as aligned monospace text. */
130
+ export function tableToText(lines) {
131
+ const sep = splitRow(lines[1] ?? '')
132
+ const rows = [lines[0], ...lines.slice(2)].map(splitRow)
133
+ const n = Math.max(...rows.map(r => r.length))
134
+ const widths = Array.from({ length: n }, (_, i) => Math.max(...rows.map(r => displayWidth(r[i] ?? ''))))
135
+ const right = Array.from({ length: n }, (_, i) => /-:$/.test(sep[i] ?? ''))
136
+ const pad = (v, i) => {
137
+ const gap = ' '.repeat(Math.max(0, widths[i] - displayWidth(v)))
138
+ return right[i] ? gap + v : v + gap
139
+ }
140
+ const fmt = (r) => Array.from({ length: n }, (_, i) => pad(r[i] ?? '', i)).join(' ').replace(/\s+$/, '')
141
+ return [fmt(rows[0]), widths.map(w => '─'.repeat(w)).join(' '), ...rows.slice(1).map(fmt)].join('\n')
142
+ }
143
+
144
+ /**
145
+ * Markdown → list of self-contained Telegram HTML blocks ({ html, text, pre? }).
146
+ * Headings become bold, lists text bullets, tables and code fences <pre>,
147
+ * quotes <blockquote>. Joining blocks with '\n' gives the full message.
148
+ */
149
+ export function markdownToBlocks(md) {
150
+ const lines = String(md ?? '').replace(/\r\n?/g, '\n').split('\n')
151
+ const blocks = []
152
+ const push = (html, text, pre) => blocks.push({ html, text, ...(pre ? { pre } : {}) })
153
+ for (let i = 0; i < lines.length; i++) {
154
+ const line = lines[i]
155
+ const fence = /^\s*```\s*([\w+-]*)\s*$/.exec(line)
156
+ if (fence) {
157
+ const body = []
158
+ for (i++; i < lines.length && !/^\s*```\s*$/.test(lines[i]); i++) body.push(lines[i])
159
+ const text = body.join('\n')
160
+ const lang = fence[1]
161
+ push(lang ? `<pre><code class="language-${escapeAttr(lang)}">${escapeHtml(text)}</code></pre>` : `<pre>${escapeHtml(text)}</pre>`, text, { lang, text })
162
+ continue
163
+ }
164
+ if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1] ?? '') && (lines[i + 1] ?? '').includes('|')) {
165
+ const tbl = [line, lines[i + 1]]
166
+ for (i += 2; i < lines.length && /^\s*\|/.test(lines[i]); i++) tbl.push(lines[i])
167
+ i--
168
+ const text = tableToText(tbl)
169
+ push(`<pre>${escapeHtml(text)}</pre>`, text, { lang: '', text })
170
+ continue
171
+ }
172
+ if (/^\s*>/.test(line)) {
173
+ const body = []
174
+ for (; i < lines.length && /^\s*>/.test(lines[i]); i++) body.push(lines[i].replace(/^\s*>+\s?/, ''))
175
+ i--
176
+ push(`<blockquote>${body.map(inlineToHtml).join('\n')}</blockquote>`, body.join('\n'))
177
+ continue
178
+ }
179
+ let m
180
+ if ((m = /^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/.exec(line))) { push(`<b>${inlineToHtml(m[1])}</b>`, m[1]); continue }
181
+ if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) { push('──────────', '──────────'); continue }
182
+ if ((m = /^(\s*)[-*+]\s+(.*)$/.exec(line))) { const ind = ' '.repeat(Math.floor(m[1].length / 2)); push(`${ind}• ${inlineToHtml(m[2])}`, `${ind}• ${m[2]}`); continue }
183
+ if ((m = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(line))) { const ind = ' '.repeat(Math.floor(m[1].length / 2)); push(`${ind}${m[2]}. ${inlineToHtml(m[3])}`, `${ind}${m[2]}. ${m[3]}`); continue }
184
+ push(inlineToHtml(line), line)
185
+ }
186
+ // Collapse runs of blank lines.
187
+ return blocks.filter((b, i) => b.html !== '' || (i > 0 && blocks[i - 1].html !== ''))
188
+ }
189
+
190
+ function cutText(text, max) {
191
+ const out = []
192
+ let piece = ''
193
+ for (const ch of String(text)) {
194
+ if ((piece + ch).length > max) { out.push(piece); piece = '' }
195
+ piece += ch
196
+ }
197
+ if (piece) out.push(piece)
198
+ return out
199
+ }
200
+
201
+ /** Pack blocks into HTML messages of at most `max` characters without breaking tags. */
202
+ export function packBlocks(blocks, max = CHUNK) {
203
+ const parts = []
204
+ for (const b of blocks) {
205
+ if (b.html.length <= max) { parts.push(b.html); continue }
206
+ if (b.pre) {
207
+ // Split a long <pre> on line boundaries; each piece is its own <pre>.
208
+ let cur = []
209
+ const flush = () => { if (cur.length) parts.push(`<pre>${escapeHtml(cur.join('\n'))}</pre>`); cur = [] }
210
+ for (const ln of b.pre.text.split('\n')) {
211
+ for (const seg of cutText(ln, Math.floor((max - 20) / 5))) {
212
+ if (escapeHtml([...cur, seg].join('\n')).length + 11 > max) flush()
213
+ cur.push(seg)
214
+ }
215
+ }
216
+ flush()
217
+ continue
218
+ }
219
+ for (const seg of cutText(b.text, Math.floor(max / 5))) parts.push(escapeHtml(seg))
220
+ }
221
+ const chunks = []
222
+ let cur = ''
223
+ for (const p of parts) {
224
+ const next = cur === '' ? p : `${cur}\n${p}`
225
+ if (next.length <= max) { cur = next; continue }
226
+ if (cur.trim()) chunks.push(cur)
227
+ cur = p
228
+ }
229
+ if (cur.trim()) chunks.push(cur)
230
+ return chunks.length ? chunks : ['']
231
+ }
232
+
233
+ /** Markdown (with Lark-flavoured extensions) → Telegram HTML chunks ≤ 4000 characters. */
234
+ export function markdownToTelegram(md, max = CHUNK) {
235
+ return packBlocks(markdownToBlocks(toPlainMarkdown(md, { mentionAll: '' })), max)
236
+ }
237
+
238
+ // ------------------------------------------------------- inline keyboard
239
+
240
+ const callbackCache = new Map()
241
+ const CALLBACK_CACHE_MAX = 5000
242
+
243
+ /**
244
+ * Encode an opaque action value ({ a, t }) and optional option into callback_data (≤ 64 bytes).
245
+ * Compact form `a|t[|option]`; anything longer is kept in a process-local cache under `k:<id>`.
246
+ */
247
+ export function encodeCallback(value, option) {
248
+ if (value && typeof value.a === 'string' && typeof value.t === 'string' && /^[\w-]+$/.test(value.a) && !value.t.includes('|') && Object.keys(value).length === 2) {
249
+ const s = option === undefined ? `${value.a}|${value.t}` : `${value.a}|${value.t}|${option}`
250
+ if (Buffer.byteLength(s) <= 64) return s
251
+ }
252
+ const key = `k:${randomBytes(9).toString('base64url')}`
253
+ callbackCache.set(key, { value, option })
254
+ while (callbackCache.size > CALLBACK_CACHE_MAX) callbackCache.delete(callbackCache.keys().next().value)
255
+ return key
256
+ }
257
+
258
+ /** Inverse of encodeCallback; null when unknown/expired. */
259
+ export function decodeCallback(data) {
260
+ const s = String(data ?? '')
261
+ if (s.startsWith('k:')) return callbackCache.get(s) ?? null
262
+ const i = s.indexOf('|')
263
+ if (i <= 0) return null
264
+ const j = s.indexOf('|', i + 1)
265
+ return j < 0 ? { value: { a: s.slice(0, i), t: s.slice(i + 1) } } : { value: { a: s.slice(0, i), t: s.slice(i + 1, j) }, option: s.slice(j + 1) }
266
+ }
267
+
268
+ /** ActionSpec → InlineKeyboardMarkup (select options become one button each). */
269
+ export function inlineKeyboard(spec) {
270
+ if (!spec) return undefined
271
+ const rows = []
272
+ const buttons = (spec.buttons ?? []).slice(0, 9).map(b => ({ text: String(b.text).slice(0, 40), callback_data: encodeCallback(b.value) }))
273
+ for (let i = 0; i < buttons.length; i += 3) rows.push(buttons.slice(i, i + 3))
274
+ if (spec.select?.options?.length) {
275
+ const opts = spec.select.options.slice(0, 10).map(o => ({ text: `🔎 ${String(o.text).slice(0, 36)}`, callback_data: encodeCallback(spec.select.value, String(o.value)) }))
276
+ for (let i = 0; i < opts.length; i += 2) rows.push(opts.slice(i, i + 2))
277
+ }
278
+ return rows.length ? { inline_keyboard: rows } : undefined
279
+ }
280
+
281
+ // ------------------------------------------------------------ rendering
282
+
283
+ function resultMarkdown(r, { includeSql, maxRows = 10 } = {}) {
284
+ const head = `**${r.title || '查询结果'}** ${resultMeta(r)}`
285
+ if (r.error) return `${head}\n${r.error}`
286
+ const lines = [head]
287
+ if (!r.rows?.length) lines.push('无数据')
288
+ else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n' }))
289
+ else lines.push(resultAsMarkdownTable(r, maxRows, { maxCols: 6 }))
290
+ if (r.sql && includeSql) lines.push('```sql\n' + r.sql.slice(0, 1500) + '\n```')
291
+ return lines.join('\n')
292
+ }
293
+
294
+ /**
295
+ * Plan an OutboundMessage as Telegram segments:
296
+ * [{ kind:'text', md, keyboard? } | { kind:'photo', result, caption, keyboard?, sqlMd? }]
297
+ */
298
+ export function planMessage(message, { buttons = true } = {}) {
299
+ const segs = []
300
+ const head = [message.title ? `**${message.title}**` : '', message.text ?? ''].filter(Boolean).join('\n\n')
301
+ let text = head
302
+ const flush = (keyboard) => { if (text.trim() || keyboard) segs.push({ kind: 'text', md: text, ...(keyboard ? { keyboard } : {}) }); text = '' }
303
+ for (const r of (message.results ?? []).slice(0, 10)) {
304
+ const keyboard = buttons ? inlineKeyboard(r.actions) : undefined
305
+ if (message.chartMode !== 'table' && !r.error && chartable(r)) {
306
+ flush()
307
+ segs.push({ kind: 'photo', result: r, caption: `<b>${escapeHtml(r.title || '查询结果')}</b>\n${escapeHtml(resultMeta(r))}`.slice(0, CAPTION_LIMIT), ...(keyboard ? { keyboard } : {}), ...(r.sql && message.includeSql ? { sqlMd: '```sql\n' + r.sql.slice(0, 1500) + '\n```' } : {}) })
308
+ continue
309
+ }
310
+ text = [text, resultMarkdown(r, { includeSql: message.includeSql })].filter(s => s.trim()).join('\n\n')
311
+ if (keyboard) flush(keyboard)
312
+ }
313
+ if (message.footer) text = [text, message.footer].filter(s => s.trim()).join('\n\n')
314
+ flush()
315
+ return segs
316
+ }
317
+
318
+ function createSender(api) {
319
+ async function sendText(chatId, chunks, { replyTo, threadId, keyboard } = {}) {
320
+ let first
321
+ for (let i = 0; i < chunks.length; i++) {
322
+ const params = {
323
+ chat_id: chatId,
324
+ text: chunks[i] || '​',
325
+ parse_mode: 'HTML',
326
+ link_preview_options: { is_disabled: true },
327
+ ...(threadId ? { message_thread_id: threadId } : {}),
328
+ ...(i === 0 && replyTo ? { reply_parameters: { message_id: Number(replyTo), allow_sending_without_reply: true } } : {}),
329
+ ...(i === chunks.length - 1 && keyboard ? { reply_markup: keyboard } : {}),
330
+ }
331
+ let msg
332
+ try {
333
+ msg = await api.call('sendMessage', params)
334
+ } catch (error) {
335
+ if (error.code !== 'channel/telegram-parse') throw error
336
+ const { parse_mode, ...plain } = params
337
+ msg = await api.call('sendMessage', { ...plain, text: htmlToPlain(params.text) || '​' })
338
+ }
339
+ first ??= msg?.message_id
340
+ }
341
+ return first
342
+ }
343
+
344
+ async function sendPhoto(chatId, png, { caption, replyTo, threadId, keyboard } = {}) {
345
+ const form = new FormData()
346
+ form.append('chat_id', String(chatId))
347
+ form.append('photo', new Blob([png], { type: 'image/png' }), 'chart.png')
348
+ if (caption) { form.append('caption', caption); form.append('parse_mode', 'HTML') }
349
+ if (threadId) form.append('message_thread_id', String(threadId))
350
+ if (replyTo) form.append('reply_parameters', JSON.stringify({ message_id: Number(replyTo), allow_sending_without_reply: true }))
351
+ if (keyboard) form.append('reply_markup', JSON.stringify(keyboard))
352
+ const msg = await api.call('sendPhoto', undefined, { form, timeoutMs: 60000 })
353
+ return msg?.message_id
354
+ }
355
+
356
+ /** Deliver an OutboundMessage. Returns { messageId, images }. */
357
+ async function deliver(chatId, message, { replyTo, threadId, renderChartPng = defaultRenderChartPng, logger, buttons = true } = {}) {
358
+ let images = 0
359
+ let messageId
360
+ let reply = replyTo
361
+ for (const seg of planMessage(message, { buttons })) {
362
+ let id
363
+ if (seg.kind === 'photo') {
364
+ let png
365
+ try { png = await renderChartPng(seg.result) } catch (error) { logger?.warn?.(`telegram: chart render failed: ${error?.message}`) }
366
+ if (png) {
367
+ id = await sendPhoto(chatId, png, { caption: seg.caption, replyTo: reply, threadId, keyboard: seg.keyboard })
368
+ images++
369
+ if (seg.sqlMd) await sendText(chatId, markdownToTelegram(seg.sqlMd), { threadId })
370
+ } else {
371
+ id = await sendText(chatId, markdownToTelegram(resultMarkdown(seg.result, { includeSql: message.includeSql })), { replyTo: reply, threadId, keyboard: seg.keyboard })
372
+ }
373
+ } else {
374
+ id = await sendText(chatId, markdownToTelegram(seg.md), { replyTo: reply, threadId, keyboard: seg.keyboard })
375
+ }
376
+ messageId ??= id
377
+ reply = undefined
378
+ }
379
+ return { messageId: messageId === undefined ? undefined : String(messageId), images }
380
+ }
381
+
382
+ return { sendText, sendPhoto, deliver }
383
+ }
384
+
385
+ // ------------------------------------------------------------ validate
386
+
387
+ const TOKEN_RE = /^\d{5,}:[A-Za-z0-9_-]{30,}$/
388
+ const CHAT_RE = /^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{3,31})$/
389
+
390
+ function validate(input = {}, existing = {}) {
391
+ const config = { ...(input.config ?? input) }
392
+ const given = input.secrets ?? input
393
+ const secrets = {}
394
+ const token = String(given.botToken ?? '').trim()
395
+ if (token) {
396
+ if (!TOKEN_RE.test(token)) throw new ChannelError('channel/config', 'Bot Token 格式不正确,应形如 123456789:AAH…(在 @BotFather 中获取)', { field: 'botToken' })
397
+ secrets.botToken = token
398
+ } else if (!existing?.secrets?.botToken) {
399
+ throw new ChannelError('channel/config', '请填写 Bot Token', { field: 'botToken' })
400
+ }
401
+ const out = {}
402
+ const chatId = String(config.chatId ?? '').trim()
403
+ if (chatId) {
404
+ if (!CHAT_RE.test(chatId)) throw new ChannelError('channel/config', 'Chat ID 格式不正确:应为数字(群组通常以 -100 开头)或 @频道用户名', { field: 'chatId' })
405
+ out.chatId = chatId
406
+ }
407
+ const base = String(config.apiBase ?? '').trim()
408
+ if (base) {
409
+ let u
410
+ try { u = new URL(base) } catch { throw new ChannelError('channel/config', 'API 地址格式不正确,应为 http(s):// 开头的 URL', { field: 'apiBase' }) }
411
+ if (!/^https?:$/.test(u.protocol)) throw new ChannelError('channel/config', 'API 地址必须以 http:// 或 https:// 开头', { field: 'apiBase' })
412
+ if (u.search || u.hash) throw new ChannelError('channel/config', 'API 地址不能包含查询参数', { field: 'apiBase' })
413
+ out.apiBase = base.replace(/\/+$/, '')
414
+ }
415
+ return { config: out, secrets }
416
+ }
417
+
418
+ function apiFor(channel, secrets, ctx = {}) {
419
+ const conn = { base: apiBaseOf(channel), token: secrets?.botToken, fetch: ctx.fetch ?? globalThis.fetch }
420
+ return { call: (method, params, opts) => callApi(conn, method, params, opts) }
421
+ }
422
+
423
+ async function send(channel, secrets, message, ctx = {}) {
424
+ const chatId = cfg(channel, 'chatId')
425
+ if (!chatId) throw new ChannelError('channel/config', '缺少 Chat ID:请填写推送目标(数字 ID 或 @频道用户名)')
426
+ const sender = createSender(apiFor(channel, secrets, ctx))
427
+ return sender.deliver(chatId, message, { renderChartPng: ctx.renderChartPng ?? defaultRenderChartPng, logger: ctx.logger, buttons: true })
428
+ }
429
+
430
+ async function test(channel, secrets, ctx = {}) {
431
+ const me = await apiFor(channel, secrets, ctx).call('getMe', {})
432
+ return { ok: true, detail: `已连接机器人 @${me?.username ?? '?'}(${me?.first_name ?? ''})` }
433
+ }
434
+
435
+ /**
436
+ * Helper (capabilities.listTargets is false): chats seen in the bot's pending
437
+ * updates. Only works while the bot is NOT long-polling and no webhook is set;
438
+ * getUpdates without an offset does not confirm (consume) updates.
439
+ */
440
+ export async function recentChats(channel, secrets, ctx = {}) {
441
+ let updates
442
+ try {
443
+ updates = await apiFor(channel, secrets, ctx).call('getUpdates', { timeout: 0, limit: 100, allowed_updates: ['message', 'channel_post', 'my_chat_member'] })
444
+ } catch (error) {
445
+ if (error.platformCode === 409) throw new ChannelError('channel/rejected', '机器人正在运行(或设置了 Webhook),无法读取最近会话;请暂停机器人后重试,或直接填写 Chat ID')
446
+ throw error
447
+ }
448
+ const seen = new Map()
449
+ for (const u of updates ?? []) {
450
+ const chat = u.message?.chat ?? u.channel_post?.chat ?? u.my_chat_member?.chat
451
+ if (!chat) continue
452
+ const name = chat.title ?? ([chat.first_name, chat.last_name].filter(Boolean).join(' ') || (chat.username ? `@${chat.username}` : String(chat.id)))
453
+ seen.set(String(chat.id), { id: String(chat.id), name, type: chat.type === 'private' ? 'user' : chat.type })
454
+ }
455
+ return [...seen.values()]
456
+ }
457
+
458
+ // ------------------------------------------------------------------ bot
459
+
460
+ /** Detect a bot mention in a group message; returns { mentioned, text } with the mention removed. */
461
+ export function detectMention(msg, me) {
462
+ const text = msg.text ?? msg.caption ?? ''
463
+ const entities = msg.entities ?? msg.caption_entities ?? []
464
+ const uname = me?.username ? `@${me.username}`.toLowerCase() : null
465
+ const cuts = []
466
+ let mentioned = false
467
+ for (const e of entities) {
468
+ const seg = text.slice(e.offset, e.offset + e.length)
469
+ if (e.type === 'mention' && uname && seg.toLowerCase() === uname) { mentioned = true; cuts.push([e.offset, e.offset + e.length]) }
470
+ else if (e.type === 'text_mention' && me?.id !== undefined && e.user?.id === me.id) { mentioned = true; cuts.push([e.offset, e.offset + e.length]) }
471
+ else if (e.type === 'bot_command' && uname && seg.toLowerCase().endsWith(uname)) { mentioned = true; cuts.push([e.offset + e.length - uname.length, e.offset + e.length]) }
472
+ }
473
+ if (me?.id !== undefined && msg.reply_to_message?.from?.id === me.id) mentioned = true
474
+ let clean = text
475
+ for (const [a, b] of cuts.sort((x, y) => y[0] - x[0])) clean = clean.slice(0, a) + clean.slice(b)
476
+ return { mentioned, text: clean.replace(/[ \t]{2,}/g, ' ').trim() }
477
+ }
478
+
479
+ function createBot(channel, secrets, handlers = {}, opts = {}) {
480
+ const logger = opts.logger
481
+ const api = apiFor(channel, secrets, opts)
482
+ const sender = createSender(api)
483
+ const pollTimeout = opts.pollTimeoutSec ?? 30
484
+ const backoffBase = opts.backoffMs ?? 1000
485
+ const backoffMax = opts.maxBackoffMs ?? 60000
486
+ const actionWaitMs = opts.actionWaitMs ?? 8000
487
+ const renderChartPng = opts.renderChartPng ?? defaultRenderChartPng
488
+ let state = 'idle'
489
+ let error
490
+ let since = Date.now()
491
+ let me = null
492
+ let controller = null
493
+ let loop = null
494
+ const set = (s, e) => { state = s; error = e; since = Date.now() }
495
+
496
+ const deliverTo = (chatId, om, extra = {}) => sender.deliver(chatId, om, { renderChartPng, logger, buttons: true, ...extra })
497
+
498
+ function handleMessage(msg) {
499
+ if (!msg || msg.from?.is_bot) return
500
+ const raw = msg.text ?? msg.caption
501
+ if (!raw) return
502
+ const t = msg.chat?.type
503
+ const chatType = t === 'private' ? 'p2p' : (t === 'group' || t === 'supergroup') ? 'group' : null
504
+ if (!chatType) return
505
+ const { mentioned, text } = detectMention(msg, me)
506
+ if (chatType === 'group' && !mentioned) return
507
+ if (chatType === 'p2p' && channel?.bot?.allowP2p === false) return
508
+ const chatId = String(msg.chat.id)
509
+ const threadId = msg.is_topic_message ? msg.message_thread_id : undefined
510
+ const from = msg.from ?? {}
511
+ const inbound = {
512
+ platform: 'telegram',
513
+ channelId: channel?.id,
514
+ messageId: String(msg.message_id),
515
+ chatId,
516
+ chatType,
517
+ text,
518
+ mentionedBot: mentioned,
519
+ sender: { id: String(from.id ?? ''), name: [from.first_name, from.last_name].filter(Boolean).join(' ') || (from.username ? `@${from.username}` : undefined), ...(from.username ? { username: from.username } : {}) },
520
+ raw: msg,
521
+ reply: (om) => deliverTo(chatId, om, { replyTo: msg.message_id, threadId }),
522
+ replyText: async (s) => { await sender.sendText(chatId, packBlocks([{ html: escapeHtml(s), text: String(s ?? '') }]), { replyTo: msg.message_id, threadId }) },
523
+ react: async (kind) => {
524
+ const emoji = REACTIONS[kind]
525
+ if (!emoji) return
526
+ try { await api.call('setMessageReaction', { chat_id: chatId, message_id: msg.message_id, reaction: [{ type: 'emoji', emoji }] }) } catch (e) { logger?.debug?.(`telegram: reaction failed: ${e.message}`) }
527
+ },
528
+ }
529
+ Promise.resolve(handlers.onMessage?.(inbound)).catch(e => logger?.warn?.(`telegram: onMessage failed: ${e?.message ?? e}`))
530
+ }
531
+
532
+ async function handleCallback(cq) {
533
+ const decoded = decodeCallback(cq.data)
534
+ let toast
535
+ if (!decoded) toast = { type: 'error', content: '按钮已过期,请重新提问。' }
536
+ else if (handlers.onAction) {
537
+ const chatId = cq.message?.chat?.id !== undefined ? String(cq.message.chat.id) : ''
538
+ const messageId = cq.message?.message_id
539
+ const threadId = cq.message?.is_topic_message ? cq.message.message_thread_id : undefined
540
+ const action = {
541
+ platform: 'telegram',
542
+ channelId: channel?.id,
543
+ operator: { id: String(cq.from?.id ?? ''), name: [cq.from?.first_name, cq.from?.last_name].filter(Boolean).join(' ') || undefined },
544
+ chatId,
545
+ messageId: messageId === undefined ? '' : String(messageId),
546
+ value: decoded.value,
547
+ ...(decoded.option !== undefined ? { option: decoded.option } : {}),
548
+ raw: cq,
549
+ reply: (om) => deliverTo(chatId, om, { replyTo: messageId, threadId }),
550
+ }
551
+ const work = Promise.resolve().then(() => handlers.onAction(action)).catch(e => ({ toast: { type: 'error', content: e?.message ?? String(e) } }))
552
+ let timer
553
+ const res = await Promise.race([work, new Promise(r => { timer = setTimeout(() => r(null), actionWaitMs) })])
554
+ clearTimeout(timer)
555
+ toast = res?.toast
556
+ }
557
+ try {
558
+ await api.call('answerCallbackQuery', { callback_query_id: cq.id, ...(toast?.content ? { text: String(toast.content).slice(0, 200), show_alert: toast.type === 'error' } : {}) })
559
+ } catch (e) { logger?.debug?.(`telegram: answerCallbackQuery failed: ${e.message}`) }
560
+ }
561
+
562
+ async function run(signal) {
563
+ let delay = backoffBase
564
+ let offset
565
+ let ready = false
566
+ while (!signal.aborted) {
567
+ try {
568
+ if (!ready) {
569
+ set('connecting')
570
+ me = await api.call('getMe', {}, { signal })
571
+ const hook = await api.call('getWebhookInfo', {}, { signal })
572
+ if (hook?.url) {
573
+ logger?.info?.('telegram: removing webhook to use long polling')
574
+ await api.call('deleteWebhook', { drop_pending_updates: false }, { signal })
575
+ }
576
+ ready = true
577
+ set('connected')
578
+ }
579
+ const updates = await api.call('getUpdates', { ...(offset !== undefined ? { offset } : {}), timeout: pollTimeout, allowed_updates: ['message', 'callback_query'] }, { signal, timeoutMs: (pollTimeout + 15) * 1000 })
580
+ if (state !== 'connected') set('connected')
581
+ delay = backoffBase
582
+ for (const u of updates ?? []) {
583
+ if (typeof u.update_id === 'number') offset = Math.max(offset ?? 0, u.update_id + 1)
584
+ try {
585
+ if (u.message) handleMessage(u.message)
586
+ else if (u.callback_query) handleCallback(u.callback_query).catch(e => logger?.warn?.(`telegram: callback failed: ${e?.message}`))
587
+ } catch (e) { logger?.warn?.(`telegram: update ${u.update_id} failed: ${e?.message}`) }
588
+ }
589
+ } catch (e) {
590
+ if (signal.aborted) break
591
+ if (e.platformCode === 409 && /webhook/i.test(e.description ?? '')) ready = false
592
+ set('error', e.message)
593
+ logger?.warn?.(`telegram: polling error: ${e.message}`)
594
+ const wait = e.retryAfter ? e.retryAfter * 1000 : e.code === 'channel/auth' ? backoffMax : delay
595
+ await sleep(wait, signal)
596
+ delay = Math.min(delay * 2, backoffMax)
597
+ }
598
+ }
599
+ }
600
+
601
+ return {
602
+ start() {
603
+ if (controller && !controller.signal.aborted) return
604
+ controller = new AbortController()
605
+ loop = run(controller.signal).catch(e => set('error', e?.message ?? String(e)))
606
+ },
607
+ async stop() {
608
+ controller?.abort()
609
+ set('stopped')
610
+ await loop
611
+ loop = null
612
+ },
613
+ status() { return { state, ...(error ? { error } : {}), since } },
614
+ info() { return me ? { name: me.username ? `@${me.username}` : me.first_name, id: String(me.id) } : null },
615
+ }
616
+ }
617
+
618
+ // ------------------------------------------------------------------ kind
619
+
620
+ const telegramBot = {
621
+ id: 'telegram_bot',
622
+ platform: 'telegram',
623
+ label: { zh: 'Telegram 机器人', en: 'Telegram bot' },
624
+ description: { zh: '通过 Bot API 推送到群组、频道或私聊;长轮询接收提问,无需公网地址。', en: 'Push to groups, channels or private chats via the Bot API; receives questions by long polling, no public URL needed.' },
625
+ fields: [
626
+ { key: 'botToken', type: 'secret', secret: true, required: true, label: { zh: 'Bot Token', en: 'Bot token' }, placeholder: '123456789:AAH…', help: { zh: '在 @BotFather 发送 /newbot 创建机器人后获得', en: 'From @BotFather (/newbot)' } },
627
+ { key: 'chatId', type: 'text', required: false, target: true, label: { zh: 'Chat ID(推送目标)', en: 'Chat ID (push target)' }, placeholder: '-1001234567890 / @mychannel', pattern: CHAT_RE.source, help: { zh: '群组/超级群为负数(通常以 -100 开头),公开频道可填 @用户名;仅作机器人使用时可留空', en: 'Negative number for groups (usually -100…), or @username for public channels; may be empty for bot-only use' } },
628
+ { key: 'apiBase', type: 'url', required: false, label: { zh: 'API 地址(可选)', en: 'API base URL (optional)' }, placeholder: DEFAULT_API_BASE, help: { zh: '自建 Bot API 服务器或反向代理地址;留空使用官方 api.telegram.org', en: 'Self-hosted Bot API server or proxy; empty = api.telegram.org' } },
629
+ ],
630
+ capabilities: { push: true, markdown: 'basic', charts: 'image', chartModes: ['image', 'table'], tables: 'text', buttons: true, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: TEXT_LIMIT },
631
+ setupSteps: {
632
+ zh: [
633
+ '在 Telegram 中打开 @BotFather,发送 /newbot 按提示创建机器人,复制 Bot Token。',
634
+ '群聊中使用:把机器人拉入群;默认隐私模式下机器人只收到 @它 的消息、回复它的消息和命令(本插件也只响应这些)。',
635
+ '频道推送:把机器人加为频道管理员,Chat ID 填 @频道用户名 或 -100 开头的数字 ID。',
636
+ '获取 Chat ID:先在群里 @机器人 发一条消息(机器人未运行时),浏览器打开 https://api.telegram.org/bot<Token>/getUpdates,查看 message.chat.id。',
637
+ '开启机器人后使用长轮询(getUpdates)收消息,无需公网地址;若该 Token 之前设置过 Webhook,会自动调用 deleteWebhook 删除。同一 Token 只能有一个实例轮询。',
638
+ '按钮(下钻、明细等)以内联键盘显示,点击后弹出提示;图表以 PNG 图片发送(sendPhoto)。',
639
+ ],
640
+ en: [
641
+ 'Open @BotFather in Telegram, send /newbot and copy the bot token.',
642
+ 'Groups: add the bot; with privacy mode on it only receives mentions, replies to it and commands (the plugin answers only those).',
643
+ 'Channels: make the bot a channel admin; use @channelusername or the -100… numeric id as Chat ID.',
644
+ 'Finding a Chat ID: mention the bot in the chat while the bot is stopped, then open https://api.telegram.org/bot<token>/getUpdates and read message.chat.id.',
645
+ 'The bot uses long polling (getUpdates), no public URL needed; an existing webhook is removed with deleteWebhook. Only one instance may poll per token.',
646
+ 'Buttons (drill down, details…) are inline keyboards; charts are sent as PNG photos (sendPhoto).',
647
+ ],
648
+ },
649
+ validate,
650
+ send,
651
+ test,
652
+ // capabilities.listTargets stays false (the Bot API cannot list a bot's chats). This helper only
653
+ // returns chats seen in pending getUpdates, and only while the bot is not polling / no webhook is set.
654
+ listTargets: recentChats,
655
+ recentChats,
656
+ }
657
+
658
+ export default {
659
+ id: 'telegram',
660
+ name: { zh: 'Telegram', en: 'Telegram' },
661
+ kinds: [telegramBot],
662
+ createBot,
663
+ }