@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.
@@ -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, '&lt;')
104
+ .replace(/>/g, '&gt;')
105
+
106
+ // 5. 转换 Markdown 标题 (#, ##, ###, ####, etc.) 为加粗标题
107
+ safeText = safeText.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>')
108
+
109
+ // 6. 转换 Markdown 引用块 (> text 或 &gt; text) 为 <blockquote>
110
+ safeText = safeText.replace(/^(?:&gt;|>)\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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
+ }