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