@wenbin_wb/dsh-bridge 2.3.2 → 2.4.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/README.en.md +41 -1
- package/README.md +41 -1
- package/client/client.js +102 -45
- package/client/index.js +103 -46
- package/docs/telegram-usage.md +89 -0
- package/lib/feishu/gateway.js +166 -7
- package/lib/feishu/node.js +55 -10
- package/lib/index.js +56 -1
- package/lib/platform/base.js +13 -0
- package/lib/platform/conversation-bridge.js +32 -4
- package/lib/qq/gateway.js +94 -14
- package/lib/qq/node.js +49 -9
- package/lib/telegram/gateway.js +619 -0
- package/lib/telegram/index.js +210 -0
- package/lib/telegram/node.js +342 -0
- package/lib/wechat/gateway.js +54 -0
- package/lib/wechat/node.js +1 -0
- package/package.json +4 -3
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
// Telegram Bot Gateway
|
|
2
|
+
// Official API: https://core.telegram.org/bots/api
|
|
3
|
+
//
|
|
4
|
+
// 纯拉取式长轮询(getUpdates),免公网 IP,免 Webhook 域名。
|
|
5
|
+
// 零第三方依赖:
|
|
6
|
+
// - 标准 HTTP/HTTPS CONNECT 隧道代理(支持国内 Clash/v2ray/Squid 等 HTTP/HTTPS 代理)
|
|
7
|
+
// - 原生 fetch / multipart 表单文件上传
|
|
8
|
+
// - 健壮的 HTML 安全格式化与纯文本自动降级重试
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import http from 'node:http'
|
|
13
|
+
import https from 'node:https'
|
|
14
|
+
import tls from 'node:tls'
|
|
15
|
+
import { Service } from '@deepseek-ai/cordis'
|
|
16
|
+
|
|
17
|
+
const API_HOST = 'api.telegram.org'
|
|
18
|
+
const TELEGRAM_API_BASE = 'https://api.telegram.org'
|
|
19
|
+
const POLL_TIMEOUT_SEC = 30
|
|
20
|
+
const MAX_MESSAGE_CHARS = 4096
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 构造标准 HTTP CONNECT 代理 Agent(零依赖,适用于 https 请求)
|
|
24
|
+
*/
|
|
25
|
+
export function createConnectProxyAgent(proxyUrl) {
|
|
26
|
+
if (!proxyUrl || typeof proxyUrl !== 'string') return undefined
|
|
27
|
+
let parsed
|
|
28
|
+
try {
|
|
29
|
+
parsed = new URL(proxyUrl)
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const proxyHost = parsed.hostname
|
|
35
|
+
const proxyPort = Number(parsed.port) || 8080
|
|
36
|
+
const authHeader = parsed.username
|
|
37
|
+
? 'Basic ' + Buffer.from(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`).toString('base64')
|
|
38
|
+
: undefined
|
|
39
|
+
|
|
40
|
+
return new https.Agent({
|
|
41
|
+
keepAlive: true,
|
|
42
|
+
createConnection(opts, callback) {
|
|
43
|
+
const connectReq = http.request({
|
|
44
|
+
host: proxyHost,
|
|
45
|
+
port: proxyPort,
|
|
46
|
+
method: 'CONNECT',
|
|
47
|
+
path: `${opts.host}:${opts.port || 443}`,
|
|
48
|
+
headers: authHeader ? { 'Proxy-Authorization': authHeader } : {},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
connectReq.on('connect', (res, socket) => {
|
|
52
|
+
if (res.statusCode !== 200) {
|
|
53
|
+
socket.destroy()
|
|
54
|
+
return callback(new Error(`Proxy CONNECT failed with HTTP ${res.statusCode}`))
|
|
55
|
+
}
|
|
56
|
+
const tlsSocket = tls.connect({
|
|
57
|
+
host: opts.host,
|
|
58
|
+
socket,
|
|
59
|
+
servername: opts.servername || opts.host,
|
|
60
|
+
})
|
|
61
|
+
callback(null, tlsSocket)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
connectReq.on('error', (err) => callback(err))
|
|
65
|
+
connectReq.end()
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 转换普通 Markdown / 纯文本为安全的 Telegram HTML,避免 MarkdownV2 转义地狱
|
|
72
|
+
*/
|
|
73
|
+
export function formatTelegramHtml(text) {
|
|
74
|
+
if (!text || typeof text !== 'string') return ''
|
|
75
|
+
|
|
76
|
+
// 1. 提取并保护代码块 ```lang ... ```
|
|
77
|
+
const codeBlocks = []
|
|
78
|
+
let safeText = text.replace(/```([a-zA-Z0-9_-]*)\r?\n?([\s\S]*?)```/g, (_, lang, code) => {
|
|
79
|
+
const idx = codeBlocks.length
|
|
80
|
+
codeBlocks.push({ lang: lang ? lang.trim() : '', code })
|
|
81
|
+
return `%%TG_CODE_${idx}%%`
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// 2. 提取并保护行内代码
|
|
85
|
+
const inlineCodes = []
|
|
86
|
+
safeText = safeText.replace(/`([^`\r\n]+)`/g, (_, code) => {
|
|
87
|
+
const idx = inlineCodes.length
|
|
88
|
+
inlineCodes.push(code)
|
|
89
|
+
return `%%TG_INLINE_${idx}%%`
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// 3. 提取并保护 Markdown 链接 [text](url)
|
|
93
|
+
const links = []
|
|
94
|
+
safeText = safeText.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_, linkText, url) => {
|
|
95
|
+
const idx = links.length
|
|
96
|
+
links.push({ text: linkText, url })
|
|
97
|
+
return `%%TG_LINK_${idx}%%`
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// 4. 全局 HTML 实体转义
|
|
101
|
+
safeText = safeText
|
|
102
|
+
.replace(/&/g, '&')
|
|
103
|
+
.replace(/</g, '<')
|
|
104
|
+
.replace(/>/g, '>')
|
|
105
|
+
|
|
106
|
+
// 5. 转换 Markdown 标题 (#, ##, ###, ####, etc.) 为加粗标题
|
|
107
|
+
safeText = safeText.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>')
|
|
108
|
+
|
|
109
|
+
// 6. 转换 Markdown 引用块 (> text 或 > text) 为 <blockquote>
|
|
110
|
+
safeText = safeText.replace(/^(?:>|>)\s*(.+)$/gm, '<blockquote>$1</blockquote>')
|
|
111
|
+
|
|
112
|
+
// 7. 转换无序列表符号 (* item, - item) 为友好圆点 •
|
|
113
|
+
safeText = safeText.replace(/^(\s*)[*-]\s+(.+)$/gm, '$1• $2')
|
|
114
|
+
|
|
115
|
+
// 8. 转换水平分割线 (---, ***, ___)
|
|
116
|
+
safeText = safeText.replace(/^([-*_]){3,}$/gm, '───────────────')
|
|
117
|
+
|
|
118
|
+
// 9. Markdown 加粗与斜体转换
|
|
119
|
+
safeText = safeText
|
|
120
|
+
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
|
121
|
+
.replace(/__(.+?)__/g, '<b>$1</b>')
|
|
122
|
+
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
|
123
|
+
.replace(/(?<!\*)\*([^*\r\n]+)\*(?!\*)/g, '<i>$1</i>')
|
|
124
|
+
|
|
125
|
+
// 10. 还原 Markdown 链接
|
|
126
|
+
safeText = safeText.replace(/%%TG_LINK_(\d+)%%/g, (_, idx) => {
|
|
127
|
+
const link = links[Number(idx)]
|
|
128
|
+
if (!link) return ''
|
|
129
|
+
const escapedText = link.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
130
|
+
return `<a href="${link.url}">${escapedText}</a>`
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// 11. 还原行内代码(HTML 转义其内容)
|
|
134
|
+
safeText = safeText.replace(/%%TG_INLINE_(\d+)%%/g, (_, idx) => {
|
|
135
|
+
const raw = inlineCodes[Number(idx)] || ''
|
|
136
|
+
const escaped = raw.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
137
|
+
return `<code>${escaped}</code>`
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// 12. 还原代码块
|
|
141
|
+
safeText = safeText.replace(/%%TG_CODE_(\d+)%%/g, (_, idx) => {
|
|
142
|
+
const item = codeBlocks[Number(idx)] || { lang: '', code: '' }
|
|
143
|
+
const rawCode = item.code.replace(/^\r?\n/, '').replace(/\r?\n$/, '')
|
|
144
|
+
const escaped = rawCode.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
145
|
+
if (item.lang) {
|
|
146
|
+
return `<pre><code class="language-${item.lang}">${escaped}</code></pre>`
|
|
147
|
+
}
|
|
148
|
+
return `<pre><code>${escaped}</code></pre>`
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
return safeText
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class TelegramGateway extends Service {
|
|
155
|
+
static name = 'telegram'
|
|
156
|
+
|
|
157
|
+
constructor(ctx, config = {}) {
|
|
158
|
+
super(ctx, 'telegram', true)
|
|
159
|
+
this.ctx = ctx
|
|
160
|
+
this.logger = ctx.logger?.('telegram') ?? console
|
|
161
|
+
this.config = {
|
|
162
|
+
botToken: config.botToken ?? process.env.TELEGRAM_BOT_TOKEN ?? '',
|
|
163
|
+
proxy: config.proxy ?? process.env.HTTPS_PROXY ?? process.env.ALL_PROXY ?? process.env.http_proxy ?? '',
|
|
164
|
+
apiBase: config.apiBase ?? TELEGRAM_API_BASE,
|
|
165
|
+
pollTimeoutSec: config.pollTimeoutSec ?? POLL_TIMEOUT_SEC,
|
|
166
|
+
...config,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.botInfo = null
|
|
170
|
+
this.status = 'idle'
|
|
171
|
+
this._polling = false
|
|
172
|
+
this._stopPolling = false
|
|
173
|
+
this._offset = 0
|
|
174
|
+
this._seenUpdates = new Set()
|
|
175
|
+
this._agent = undefined
|
|
176
|
+
|
|
177
|
+
if (this.config.proxy) {
|
|
178
|
+
this._agent = createConnectProxyAgent(this.config.proxy)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
get configured() {
|
|
183
|
+
return Boolean(this.config.botToken && String(this.config.botToken).includes(':'))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
get accountId() {
|
|
187
|
+
return this.botInfo?.username ? `@${this.botInfo.username}` : (this.botInfo?.id ? String(this.botInfo.id) : '')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
get capabilities() {
|
|
191
|
+
return {
|
|
192
|
+
supportsGroup: true,
|
|
193
|
+
group: true,
|
|
194
|
+
media: true,
|
|
195
|
+
approvals: true,
|
|
196
|
+
maxMessageChars: MAX_MESSAGE_CHARS,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
setStatus(status, detail = null) {
|
|
201
|
+
this.status = status
|
|
202
|
+
this.ctx.emit('telegram/status', {
|
|
203
|
+
status,
|
|
204
|
+
configured: this.configured,
|
|
205
|
+
botInfo: this.botInfo,
|
|
206
|
+
detail,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 底层 HTTP 请求封装,支持代理 Agent 与统一错误解析
|
|
212
|
+
*/
|
|
213
|
+
async request(method, params = {}, { isMultipart = false, formData = null } = {}) {
|
|
214
|
+
if (!this.config.botToken) throw new Error('Telegram botToken is required')
|
|
215
|
+
|
|
216
|
+
const url = `${this.config.apiBase}/bot${this.config.botToken}/${method}`
|
|
217
|
+
|
|
218
|
+
if (isMultipart && formData) {
|
|
219
|
+
const resp = await fetch(url, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
body: formData,
|
|
222
|
+
dispatcher: this._agent,
|
|
223
|
+
})
|
|
224
|
+
const data = await resp.json()
|
|
225
|
+
if (!data.ok) {
|
|
226
|
+
throw new Error(data.description || `Telegram API error ${data.error_code}`)
|
|
227
|
+
}
|
|
228
|
+
return data.result
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
const bodyStr = JSON.stringify(params)
|
|
233
|
+
const parsedUrl = new URL(url)
|
|
234
|
+
|
|
235
|
+
const reqOpts = {
|
|
236
|
+
protocol: parsedUrl.protocol,
|
|
237
|
+
hostname: parsedUrl.hostname,
|
|
238
|
+
port: parsedUrl.port || 443,
|
|
239
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: {
|
|
242
|
+
'Content-Type': 'application/json',
|
|
243
|
+
'Content-Length': Buffer.byteLength(bodyStr),
|
|
244
|
+
},
|
|
245
|
+
agent: this._agent,
|
|
246
|
+
timeout: (this.config.pollTimeoutSec + 15) * 1000,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const req = https.request(reqOpts, (res) => {
|
|
250
|
+
const chunks = []
|
|
251
|
+
res.on('data', (c) => chunks.push(c))
|
|
252
|
+
res.on('end', () => {
|
|
253
|
+
try {
|
|
254
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
255
|
+
const data = JSON.parse(raw)
|
|
256
|
+
if (!data.ok) {
|
|
257
|
+
const err = new Error(data.description || `Telegram API error ${data.error_code}`)
|
|
258
|
+
err.errorCode = data.error_code
|
|
259
|
+
return reject(err)
|
|
260
|
+
}
|
|
261
|
+
resolve(data.result)
|
|
262
|
+
} catch (e) {
|
|
263
|
+
reject(new Error(`Failed to parse Telegram API response: ${e.message}`))
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
req.on('error', (err) => reject(err))
|
|
269
|
+
req.on('timeout', () => {
|
|
270
|
+
req.destroy(new Error('Telegram API request timed out'))
|
|
271
|
+
})
|
|
272
|
+
req.write(bodyStr)
|
|
273
|
+
req.end()
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- 生命周期 ----
|
|
278
|
+
|
|
279
|
+
async start() {
|
|
280
|
+
if (!this.configured) {
|
|
281
|
+
this.logger.info?.('[dsh-bridge telegram] not configured (missing botToken); staying idle')
|
|
282
|
+
this.setStatus('idle')
|
|
283
|
+
return false
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this._stopPolling = false
|
|
287
|
+
this.setStatus('connecting')
|
|
288
|
+
if (this.config.proxy) {
|
|
289
|
+
this._agent = createConnectProxyAgent(this.config.proxy)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
this.botInfo = await this.request('getMe')
|
|
294
|
+
this.logger.info?.(`[dsh-bridge telegram] authenticated as @${this.botInfo.username} (id=${this.botInfo.id})`)
|
|
295
|
+
this.setStatus('online')
|
|
296
|
+
void this.registerCommands().catch(() => {})
|
|
297
|
+
this._startPollLoop()
|
|
298
|
+
return true
|
|
299
|
+
} catch (err) {
|
|
300
|
+
this.logger.error?.('[dsh-bridge telegram] getMe error:', err.message)
|
|
301
|
+
this.setStatus('error', err.message)
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 自动向 Telegram 注册原生快捷指令菜单(输入 / 或点击 Menu 菜单时展示)
|
|
308
|
+
*/
|
|
309
|
+
async registerCommands() {
|
|
310
|
+
const commands = [
|
|
311
|
+
{ command: 'new', description: '新建会话并开始执行 (/new <提示词>)' },
|
|
312
|
+
{ command: 'sessions', description: '列出所有会话列表与切换' },
|
|
313
|
+
{ command: 'use', description: '切换活动会话 (/use <N>)' },
|
|
314
|
+
{ command: 'workspaces', description: '列出本地所有可用工作区' },
|
|
315
|
+
{ command: 'status', description: '查看 Agent 状态与会话摘要' },
|
|
316
|
+
{ command: 'stop', description: '停止当前正在运行的任务' },
|
|
317
|
+
{ command: 'end', description: '结束当前活动会话' },
|
|
318
|
+
{ command: 'help', description: '显示快捷按键与完整帮助' },
|
|
319
|
+
]
|
|
320
|
+
try {
|
|
321
|
+
// 1. 设置全局默认范围
|
|
322
|
+
await this.request('setMyCommands', { commands, scope: { type: 'default' } })
|
|
323
|
+
// 2. 设置单聊私聊范围(确保私聊立即生效)
|
|
324
|
+
await this.request('setMyCommands', { commands, scope: { type: 'all_private_chats' } })
|
|
325
|
+
// 3. 设置群聊范围
|
|
326
|
+
await this.request('setMyCommands', { commands, scope: { type: 'all_group_chats' } })
|
|
327
|
+
// 4. 设置左下角菜单按键为命令列表
|
|
328
|
+
await this.request('setChatMenuButton', { menu_button: { type: 'commands' } }).catch(() => {})
|
|
329
|
+
this.logger.info?.('[dsh-bridge telegram] bot command menu & menu button registered successfully')
|
|
330
|
+
} catch (err) {
|
|
331
|
+
this.logger.warn?.('[dsh-bridge telegram] setMyCommands failed: %s', err?.message ?? err)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async stop() {
|
|
336
|
+
this._stopPolling = true
|
|
337
|
+
this._polling = false
|
|
338
|
+
this.setStatus('idle')
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---- 长轮询 Poll 循环 ----
|
|
342
|
+
|
|
343
|
+
_startPollLoop() {
|
|
344
|
+
if (this._polling) return
|
|
345
|
+
this._polling = true
|
|
346
|
+
|
|
347
|
+
;(async () => {
|
|
348
|
+
let consecutiveErrors = 0
|
|
349
|
+
while (!this._stopPolling && this.configured) {
|
|
350
|
+
try {
|
|
351
|
+
const updates = await this.request('getUpdates', {
|
|
352
|
+
offset: this._offset,
|
|
353
|
+
timeout: this.config.pollTimeoutSec,
|
|
354
|
+
allowed_updates: ['message', 'callback_query'],
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
consecutiveErrors = 0
|
|
358
|
+
if (this.status !== 'online') this.setStatus('online')
|
|
359
|
+
|
|
360
|
+
if (Array.isArray(updates) && updates.length > 0) {
|
|
361
|
+
for (const update of updates) {
|
|
362
|
+
if (this._seenUpdates.has(update.update_id)) continue
|
|
363
|
+
this._seenUpdates.add(update.update_id)
|
|
364
|
+
if (this._seenUpdates.size > 2000) {
|
|
365
|
+
const first = this._seenUpdates.values().next().value
|
|
366
|
+
this._seenUpdates.delete(first)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
this._offset = Math.max(this._offset, update.update_id + 1)
|
|
370
|
+
this._dispatchUpdate(update)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (this._stopPolling) break
|
|
375
|
+
consecutiveErrors += 1
|
|
376
|
+
const backoffSec = Math.min(30, 2 ** Math.min(consecutiveErrors, 5))
|
|
377
|
+
this.logger.warn?.(`[dsh-bridge telegram] poll error: ${err.message}, retrying in ${backoffSec}s...`)
|
|
378
|
+
this.setStatus('reconnecting', err.message)
|
|
379
|
+
await new Promise((r) => setTimeout(r, backoffSec * 1000))
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
this._polling = false
|
|
383
|
+
})()
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
_dispatchUpdate(update) {
|
|
387
|
+
if (update.message) {
|
|
388
|
+
this._handleInboundMessage(update.message)
|
|
389
|
+
} else if (update.callback_query) {
|
|
390
|
+
this._handleCallbackQuery(update.callback_query)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
_handleInboundMessage(msg) {
|
|
395
|
+
const chatId = msg.chat?.id
|
|
396
|
+
if (!chatId) return
|
|
397
|
+
|
|
398
|
+
const isGroup = msg.chat.type === 'group' || msg.chat.type === 'supergroup'
|
|
399
|
+
const senderId = msg.from?.id ? String(msg.from.id) : String(chatId)
|
|
400
|
+
const senderUsername = msg.from?.username ? `@${msg.from.username}` : senderId
|
|
401
|
+
|
|
402
|
+
let text = msg.text || msg.caption || ''
|
|
403
|
+
if (isGroup && this.botInfo?.username) {
|
|
404
|
+
const atMention = `@${this.botInfo.username}`
|
|
405
|
+
if (text.includes(atMention)) {
|
|
406
|
+
text = text.replace(new RegExp(atMention, 'gi'), '').trim()
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
this.ctx.emit('telegram/message', {
|
|
411
|
+
chatId: String(chatId),
|
|
412
|
+
senderId: String(senderId),
|
|
413
|
+
senderUsername,
|
|
414
|
+
isGroup,
|
|
415
|
+
messageId: msg.message_id,
|
|
416
|
+
text,
|
|
417
|
+
raw: msg,
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
_handleCallbackQuery(query) {
|
|
422
|
+
const queryId = query.id
|
|
423
|
+
const chatId = query.message?.chat?.id
|
|
424
|
+
const messageId = query.message?.message_id
|
|
425
|
+
const operatorId = query.from?.id ? String(query.from.id) : ''
|
|
426
|
+
const data = query.data || ''
|
|
427
|
+
|
|
428
|
+
this.ctx.emit('telegram/action', {
|
|
429
|
+
queryId,
|
|
430
|
+
chatId: String(chatId),
|
|
431
|
+
messageId,
|
|
432
|
+
operatorId,
|
|
433
|
+
data,
|
|
434
|
+
raw: query,
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---- 出站 API ----
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 发送文本/Markdown 消息(自动尝试 HTML 模式,失败自动降级为无格式纯文本重试)
|
|
442
|
+
*/
|
|
443
|
+
async sendText(chatId, text, opts = {}) {
|
|
444
|
+
if (!chatId || !text) return null
|
|
445
|
+
const safeHtml = formatTelegramHtml(text)
|
|
446
|
+
|
|
447
|
+
const params = {
|
|
448
|
+
chat_id: chatId,
|
|
449
|
+
text: safeHtml,
|
|
450
|
+
parse_mode: 'HTML',
|
|
451
|
+
disable_web_page_preview: true,
|
|
452
|
+
...opts,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
return await this.request('sendMessage', params)
|
|
457
|
+
} catch (err) {
|
|
458
|
+
if (err.errorCode === 400 || String(err.message).includes('can\'t parse')) {
|
|
459
|
+
this.logger.warn?.(`[dsh-bridge telegram] HTML parse failed, falling back to plain text for chat ${chatId}`)
|
|
460
|
+
return await this.request('sendMessage', {
|
|
461
|
+
chat_id: chatId,
|
|
462
|
+
text: String(text).slice(0, MAX_MESSAGE_CHARS),
|
|
463
|
+
disable_web_page_preview: true,
|
|
464
|
+
...opts,
|
|
465
|
+
parse_mode: undefined,
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
throw err
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* 发送带 Inline Keyboard 按钮的消息(常用于操作审批)
|
|
474
|
+
*/
|
|
475
|
+
async sendKeyboard(chatId, text, buttons = []) {
|
|
476
|
+
const inline_keyboard = buttons.map((row) =>
|
|
477
|
+
row.map((btn) => ({
|
|
478
|
+
text: btn.text,
|
|
479
|
+
callback_data: btn.callback_data || btn.action || btn.text,
|
|
480
|
+
})),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
return this.sendText(chatId, text, {
|
|
484
|
+
reply_markup: { inline_keyboard },
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 原地编辑已发送消息
|
|
490
|
+
*/
|
|
491
|
+
async editMessageText(chatId, messageId, text, opts = {}) {
|
|
492
|
+
if (!chatId || !messageId || !text) return null
|
|
493
|
+
const safeHtml = formatTelegramHtml(text)
|
|
494
|
+
|
|
495
|
+
const params = {
|
|
496
|
+
chat_id: chatId,
|
|
497
|
+
message_id: messageId,
|
|
498
|
+
text: safeHtml,
|
|
499
|
+
parse_mode: 'HTML',
|
|
500
|
+
disable_web_page_preview: true,
|
|
501
|
+
...opts,
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
return await this.request('editMessageText', params)
|
|
506
|
+
} catch (err) {
|
|
507
|
+
if (err.errorCode === 400 || String(err.message).includes('can\'t parse')) {
|
|
508
|
+
return await this.request('editMessageText', {
|
|
509
|
+
chat_id: chatId,
|
|
510
|
+
message_id: messageId,
|
|
511
|
+
text: String(text).slice(0, MAX_MESSAGE_CHARS),
|
|
512
|
+
disable_web_page_preview: true,
|
|
513
|
+
...opts,
|
|
514
|
+
parse_mode: undefined,
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
if (String(err.message).includes('message is not modified')) return null
|
|
518
|
+
throw err
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 回应 CallbackQuery(关闭加载圈并弹窗提示)
|
|
524
|
+
*/
|
|
525
|
+
async answerCallbackQuery(queryId, text = '', showAlert = false) {
|
|
526
|
+
if (!queryId) return
|
|
527
|
+
try {
|
|
528
|
+
return await this.request('answerCallbackQuery', {
|
|
529
|
+
callback_query_id: queryId,
|
|
530
|
+
text,
|
|
531
|
+
show_alert: showAlert,
|
|
532
|
+
})
|
|
533
|
+
} catch (err) {
|
|
534
|
+
this.logger.warn?.('[dsh-bridge telegram] answerCallbackQuery error:', err.message)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 发送打字指示
|
|
540
|
+
*/
|
|
541
|
+
async sendTyping(chatId) {
|
|
542
|
+
if (!chatId) return
|
|
543
|
+
try {
|
|
544
|
+
return await this.request('sendChatAction', {
|
|
545
|
+
chat_id: chatId,
|
|
546
|
+
action: 'typing',
|
|
547
|
+
})
|
|
548
|
+
} catch {}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 发送本地媒体文件(自动识别图片通过 sendPhoto,其他通过 sendDocument)
|
|
553
|
+
*/
|
|
554
|
+
async sendMediaFile(chatId, filePath, opts = {}) {
|
|
555
|
+
if (!chatId || !filePath || !fs.existsSync(filePath)) return null
|
|
556
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
557
|
+
const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
|
|
558
|
+
const method = isImage ? 'sendPhoto' : 'sendDocument'
|
|
559
|
+
const fieldName = isImage ? 'photo' : 'document'
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const buffer = await fs.promises.readFile(filePath)
|
|
563
|
+
const fileName = path.basename(filePath)
|
|
564
|
+
const blob = new Blob([buffer])
|
|
565
|
+
const formData = new FormData()
|
|
566
|
+
formData.append('chat_id', String(chatId))
|
|
567
|
+
formData.append(fieldName, blob, fileName)
|
|
568
|
+
if (opts.caption) formData.append('caption', opts.caption)
|
|
569
|
+
|
|
570
|
+
return await this.request(method, {}, { isMultipart: true, formData })
|
|
571
|
+
} catch (err) {
|
|
572
|
+
this.logger.warn?.(`[dsh-bridge telegram] sendMediaFile (${filePath}) error: ${err.message}`)
|
|
573
|
+
return null
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* 下载 Telegram 文件
|
|
579
|
+
*/
|
|
580
|
+
async downloadFile(fileId, sessionCwd) {
|
|
581
|
+
if (!fileId) return null
|
|
582
|
+
try {
|
|
583
|
+
const fileInfo = await this.request('getFile', { file_id: fileId })
|
|
584
|
+
if (!fileInfo?.file_path) return null
|
|
585
|
+
|
|
586
|
+
const downloadUrl = `${this.config.apiBase}/file/bot${this.config.botToken}/${fileInfo.file_path}`
|
|
587
|
+
const resp = await fetch(downloadUrl, { dispatcher: this._agent })
|
|
588
|
+
if (!resp.ok) throw new Error(`Download HTTP ${resp.status}`)
|
|
589
|
+
|
|
590
|
+
const buf = Buffer.from(await resp.arrayBuffer())
|
|
591
|
+
const mediaDir = path.join(sessionCwd || process.cwd(), '.telegram-media')
|
|
592
|
+
await fs.promises.mkdir(mediaDir, { recursive: true })
|
|
593
|
+
|
|
594
|
+
const ext = path.extname(fileInfo.file_path) || '.bin'
|
|
595
|
+
const safeName = `tg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
|
|
596
|
+
const fullPath = path.join(mediaDir, safeName)
|
|
597
|
+
await fs.promises.writeFile(fullPath, buf)
|
|
598
|
+
return { path: fullPath, filename: safeName, size: buf.length }
|
|
599
|
+
} catch (err) {
|
|
600
|
+
this.logger.warn?.(`[dsh-bridge telegram] downloadFile error: ${err.message}`)
|
|
601
|
+
return null
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
setCredentials(values = {}) {
|
|
606
|
+
for (const key of ['botToken', 'proxy', 'apiBase', 'pollTimeoutSec']) {
|
|
607
|
+
if (values[key] !== undefined) this.config[key] = values[key]
|
|
608
|
+
}
|
|
609
|
+
if (this.config.proxy) {
|
|
610
|
+
this._agent = createConnectProxyAgent(this.config.proxy)
|
|
611
|
+
} else {
|
|
612
|
+
this._agent = undefined
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
dispose() {
|
|
617
|
+
void this.stop()
|
|
618
|
+
}
|
|
619
|
+
}
|