@hedeqiang/dsh-nl2sql 0.10.0 → 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.
- package/CHANGELOG.md +22 -2
- package/README.en.md +2 -1
- package/README.md +2 -2
- package/channels/dingtalk.js +574 -0
- package/channels/http.js +63 -0
- package/channels/index.js +138 -0
- package/channels/lark.js +1180 -0
- package/channels/message.js +267 -0
- package/channels/telegram.js +663 -0
- package/channels/wecom.js +585 -0
- package/cordis.patch.yml +3 -3
- package/docs/CHANNELS.md +184 -0
- package/im.js +6 -509
- package/index.js +25 -17
- package/lark-bot.js +7 -604
- package/lib/client.js +1097 -185
- package/package.json +14 -9
- package/service/access.js +116 -22
- package/service/admin.js +29 -13
- package/service/bots.js +665 -0
- package/service/channels.js +363 -0
- package/service/datasources.js +31 -8
- package/service/push.js +5 -156
- package/service/shared.js +1 -1
- package/service/lark-actions.js +0 -223
- package/service/lark.js +0 -250
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
90
|
+
const escapeAttr = (s) => escapeHtml(s).replace(/"/g, '"')
|
|
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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/&/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
|
+
}
|