@qzsy/dsh-bridge 0.0.0-stage → 2.18.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 (77) hide show
  1. package/CHANGELOG.md +1107 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +488 -0
  4. package/README.md +509 -2
  5. package/client/build.mjs +43 -0
  6. package/client/client.js +9864 -0
  7. package/client/host-families.js +442 -0
  8. package/client/index.js +6883 -0
  9. package/client/locales.mjs +242 -0
  10. package/client/mobile-styles.js +1299 -0
  11. package/client/mobile-yield.js +219 -0
  12. package/client/panel-locales.mjs +777 -0
  13. package/client/picker-yield.js +77 -0
  14. package/client/resource-url-compat.js +90 -0
  15. package/client/settings-nav-icon.js +105 -0
  16. package/client/theme-tokens.js +49 -0
  17. package/client/tunnel-guard.js +46 -0
  18. package/client/unlock-manager.js +144 -0
  19. package/cordis.patch.yml +4 -0
  20. package/lib/auth/dsh-native-cookie.js +148 -0
  21. package/lib/auth/login-template.js +516 -0
  22. package/lib/auth/manager.js +596 -0
  23. package/lib/bridge-rpc-constants.js +66 -0
  24. package/lib/bridge-rpc.js +722 -0
  25. package/lib/cloudflared-manager.mjs +1277 -0
  26. package/lib/cloudflared-network-args.js +99 -0
  27. package/lib/cloudflared-process-scan.js +153 -0
  28. package/lib/compat.js +172 -0
  29. package/lib/connection-compat.js +115 -0
  30. package/lib/feishu/comment.js +50 -0
  31. package/lib/feishu/gateway.js +644 -0
  32. package/lib/feishu/index.js +432 -0
  33. package/lib/feishu/lark-bundled.mjs +131929 -0
  34. package/lib/feishu/markdown.js +148 -0
  35. package/lib/feishu/node.js +1152 -0
  36. package/lib/feishu/slash-commands.js +255 -0
  37. package/lib/index.js +3554 -0
  38. package/lib/platform/account-ops.js +248 -0
  39. package/lib/platform/activity.js +169 -0
  40. package/lib/platform/ask-user.js +293 -0
  41. package/lib/platform/base.js +147 -0
  42. package/lib/platform/changes-digest.js +93 -0
  43. package/lib/platform/commands.js +267 -0
  44. package/lib/platform/config-restore.js +78 -0
  45. package/lib/platform/conversation-bridge.js +1544 -0
  46. package/lib/platform/dsh-storage.js +222 -0
  47. package/lib/platform/index.js +10 -0
  48. package/lib/platform/instances.js +458 -0
  49. package/lib/platform/manager.js +130 -0
  50. package/lib/platform/message-split.js +229 -0
  51. package/lib/platform/session-catalog.js +405 -0
  52. package/lib/platform/session-config.js +52 -0
  53. package/lib/platform/stream-slices.js +21 -0
  54. package/lib/qq/gateway.js +1107 -0
  55. package/lib/qq/index.js +561 -0
  56. package/lib/qq/node.js +1153 -0
  57. package/lib/qq/qqbot-bundled.mjs +8273 -0
  58. package/lib/restart-helper.mjs +133 -0
  59. package/lib/saas/device-auth.mjs +366 -0
  60. package/lib/saas/tunnel-client.mjs +695 -0
  61. package/lib/security/log-sanitize.js +50 -0
  62. package/lib/security/path-validator.js +195 -0
  63. package/lib/security/rate-limiter.js +93 -0
  64. package/lib/session-strip.js +100 -0
  65. package/lib/sse-keepalive.js +115 -0
  66. package/lib/telegram/gateway.js +643 -0
  67. package/lib/telegram/index.js +255 -0
  68. package/lib/telegram/node.js +410 -0
  69. package/lib/tunnel-client.mjs +537 -0
  70. package/lib/wechat/gateway.js +1000 -0
  71. package/lib/wechat/index.js +282 -0
  72. package/lib/wechat/media.js +302 -0
  73. package/lib/wechat/node.js +360 -0
  74. package/llms.txt +36 -0
  75. package/locale/en.json +6 -0
  76. package/locale/zh.json +6 -0
  77. package/package.json +135 -4
@@ -0,0 +1,255 @@
1
+ // dsh-bridge Telegram platform adapter
2
+ // 编排 TelegramGateway(长轮询 + 代理支持)+ TelegramConversationNode(Telegram⇄DSH 会话桥)。
3
+ // 作为 Platform 子类,注册进 PlatformManager 统一管理。
4
+
5
+ import QRCode from 'qrcode'
6
+ import { Platform } from '../platform/base.js'
7
+ import { applySessionConfig, readSessionConfig } from '../platform/session-config.js'
8
+ import { TelegramGateway } from './gateway.js'
9
+ import { TelegramConversationNode } from './node.js'
10
+
11
+ export class TelegramService extends Platform {
12
+ /**
13
+ * @param {object} opts
14
+ * @param {object} opts.ctx Cordis 上下文
15
+ * @param {object} opts.logger 日志器
16
+ * @param {object} [opts.config] 已持久化的 telegram 配置(凭证 + allowFrom + 间隔)
17
+ * @param {(patch: object) => (void|Promise<void>)} opts.onPersist 主插件保存回调
18
+ */
19
+ constructor({ ctx, logger, config = {}, onPersist, instanceId, serviceName }) {
20
+ super({ ctx, logger, config, onPersist })
21
+ this.id = 'telegram'
22
+ // issue #78:同类型可并行多实例;缺省回退类型 id 保持单实例旧行为
23
+ this.instanceId = instanceId || this.id
24
+ this.displayName = null
25
+ this.name = 'Telegram'
26
+ this._botQrCache = { username: '', qr: '' }
27
+
28
+ this.gateway = new TelegramGateway(ctx, {
29
+ // issue #78:instanceId 用于事件隔离;serviceName 是唯一 cordis 服务名(D2)
30
+ instanceId: this.instanceId,
31
+ serviceName,
32
+ botToken: config.botToken ?? '',
33
+ proxy: config.proxy ?? '',
34
+ })
35
+
36
+ // 仅首个实例占用 ctx.telegram 槽位(兼容外部读取方);节点已改为显式接收网关
37
+ try { if (!ctx.telegram) ctx.telegram = this.gateway } catch {}
38
+
39
+ this.node = new TelegramConversationNode(ctx, {
40
+ allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
41
+ groupAutoApprove: config.groupAutoApprove,
42
+ changesDigest: config.changesDigest,
43
+ showActivity: config.showActivity,
44
+ steerMode: config.steerMode,
45
+ digestIntervalSec: config.digestIntervalSec,
46
+ approvalTimeoutSec: config.approvalTimeoutSec,
47
+ maxMessageChars: config.maxMessageChars || 4096,
48
+ sendChunkDelayMs: config.sendChunkDelayMs,
49
+ activeSessionId: config.activeSessionId,
50
+ // 会话级配置:工作区 / Agent 预设 / 模型路由(构造期来自 cordis 配置,运行期由设置页写入)
51
+ cwd: config.cwd,
52
+ agentPreset: config.agentPreset,
53
+ agentProvider: config.agentProvider,
54
+ agentModel: config.agentModel,
55
+ }, logger, {
56
+ onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
57
+ onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }),
58
+ gateway: this.gateway,
59
+ instanceId: this.instanceId,
60
+ })
61
+ this.bridge = this.node
62
+
63
+ if (this.gateway.configured) {
64
+ void this.start().catch((err) => {
65
+ this.logger.error?.('[dsh-bridge telegram] start failed:', err?.message ?? err)
66
+ })
67
+ }
68
+ }
69
+
70
+ // ---- Platform 接口 ----
71
+
72
+ get configured() { return this.gateway.configured }
73
+ get accountId() { return this.gateway.accountId }
74
+
75
+ get capabilities() {
76
+ return {
77
+ group: true,
78
+ media: true,
79
+ approvals: true,
80
+ maxMessageChars: this.node.config.maxMessageChars || 4096,
81
+ }
82
+ }
83
+
84
+ async sendText(peerId, text, opts = {}) {
85
+ return this.gateway.sendText(peerId, text, opts)
86
+ }
87
+
88
+ async sendTyping(peerId, opts = {}) {
89
+ return this.gateway.sendTyping?.(peerId, opts)
90
+ }
91
+
92
+ // ---- 生命周期控制 ----
93
+
94
+ async start() {
95
+ if (!this.gateway.configured) {
96
+ this.setStatus('idle')
97
+ return { success: false, error: 'Telegram Bot Token 未配置' }
98
+ }
99
+ this.setStatus('starting')
100
+ const ok = await this.gateway.start()
101
+ if (ok) {
102
+ this.setStatus('connected')
103
+ return { success: true }
104
+ } else {
105
+ this.setStatus('error', '连接 Telegram API 失败,请检查 Bot Token 或网络代理')
106
+ return { success: false, error: '连接失败' }
107
+ }
108
+ }
109
+
110
+ async stop() {
111
+ await this.gateway.stop()
112
+ this.setStatus('offline')
113
+ return { success: true }
114
+ }
115
+
116
+ /**
117
+ * 配置或登录
118
+ * @param {object} creds - { botToken, proxy }
119
+ */
120
+ async login(creds = {}) {
121
+ const patch = {}
122
+ if (creds.botToken !== undefined) patch.botToken = String(creds.botToken).trim()
123
+ if (creds.proxy !== undefined) patch.proxy = String(creds.proxy).trim()
124
+
125
+ this.gateway.setCredentials(patch)
126
+ this.persist(patch)
127
+
128
+ if (!this.gateway.configured) {
129
+ await this.stop()
130
+ return { success: false, error: '请填写完整的 Telegram Bot Token' }
131
+ }
132
+
133
+ return this.start()
134
+ }
135
+
136
+ async unbind() {
137
+ await this.stop()
138
+ this.gateway.setCredentials({ botToken: '', proxy: '' })
139
+ this.persist({ botToken: '', proxy: '', allowFrom: [] })
140
+ if (this.node) this.node.config.allowFrom = []
141
+ return { success: true }
142
+ }
143
+
144
+ getStatus() {
145
+ const allowFrom = [...(this.node?.config?.allowFrom ?? [])]
146
+ const username = this.gateway.botInfo?.username || ''
147
+ const botLink = username ? `https://t.me/${encodeURIComponent(username)}` : null
148
+ if (botLink && this._botQrCache.username !== username) {
149
+ this._botQrCache.username = username
150
+ void QRCode.toDataURL(botLink, {
151
+ width: 260,
152
+ margin: 2,
153
+ color: { dark: '#1F2421', light: '#FFFFFF' },
154
+ }).then((qr) => {
155
+ this._botQrCache.qr = qr
156
+ }).catch(() => {})
157
+ }
158
+
159
+ return {
160
+ id: this.id,
161
+ // issue #78:实例标识(instanceId 为全局唯一键,platformId 为平台类型)
162
+ instanceId: this.instanceId,
163
+ platformId: this.id,
164
+ displayName: this.displayName ?? this.name,
165
+ name: this.name,
166
+ status: this.status === 'connected' ? 'connected' : this.gateway.status,
167
+ configured: this.gateway.configured,
168
+ accountId: this.gateway.accountId || (this.gateway.configured ? '已配置 Token' : ''),
169
+ allowFrom,
170
+ peerId: this.node?.peerId,
171
+ sessionId: this.node?.activeSessionId,
172
+ login: {
173
+ phase: this.status === 'connected' ? 'done' : this.status === 'error' ? 'error' : 'idle',
174
+ },
175
+ capabilities: { ...this.capabilities },
176
+ config: {
177
+ name: this.config.name ?? '',
178
+ digestIntervalSec: this.node?.config?.digestIntervalSec,
179
+ approvalTimeoutSec: this.node?.config?.approvalTimeoutSec,
180
+ maxMessageChars: this.node?.config?.maxMessageChars,
181
+ sendChunkDelayMs: this.node?.config?.sendChunkDelayMs,
182
+ // issue #77 ①:与飞书同缺陷——不回读这三个字段,设置页勾选会"回退"
183
+ groupAutoApprove: this.node?.config?.groupAutoApprove === true,
184
+ changesDigest: this.node?.config?.changesDigest !== false,
185
+ showActivity: this.node?.config?.showActivity === true,
186
+ steerMode: this.node?.config?.steerMode === true,
187
+ botToken: this.gateway.config.botToken ? '******' : '',
188
+ proxy: this.gateway.config.proxy || '',
189
+ ...readSessionConfig(this.node?.config),
190
+ },
191
+ botInfo: this.gateway.botInfo,
192
+ botLink,
193
+ botQr: this._botQrCache.qr,
194
+ }
195
+ }
196
+
197
+ setAllowFrom(allowFrom) {
198
+ const list = Array.isArray(allowFrom) ? allowFrom.map((s) => String(s).trim()).filter(Boolean) : []
199
+ if (this.node) this.node.config.allowFrom = list
200
+ this.persist({ allowFrom: list })
201
+ return { success: true, allowFrom: list }
202
+ }
203
+
204
+ async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, groupAutoApprove, changesDigest, showActivity, steerMode, botToken, proxy, name, agentPreset, cwd, agentProvider, agentModel } = {}) {
205
+ if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
206
+ if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
207
+ if (maxMessageChars != null) {
208
+ const val = Number(maxMessageChars)
209
+ this.node.config.maxMessageChars = (val >= 200) ? val : 4096
210
+ }
211
+ if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
212
+ if (groupAutoApprove != null) this.node.config.groupAutoApprove = groupAutoApprove === true
213
+ if (changesDigest != null) this.node.config.changesDigest = changesDigest === true
214
+ if (showActivity != null) this.node.config.showActivity = showActivity === true
215
+ if (steerMode != null) this.node.config.steerMode = steerMode === true
216
+ applySessionConfig(this.node.config, { agentPreset, cwd, agentProvider, agentModel })
217
+ // issue #78:账号备注名(面板回显 + displayName 来源),随 setConfig 持久化
218
+ if (name !== undefined) this.config.name = String(name).trim()
219
+
220
+ if (botToken !== undefined || proxy !== undefined) {
221
+ const patch = {}
222
+ if (botToken !== undefined) patch.botToken = botToken.trim()
223
+ if (proxy !== undefined) patch.proxy = proxy.trim()
224
+ this.gateway.setCredentials(patch)
225
+ }
226
+
227
+ const patch = {
228
+ digestIntervalSec: this.node.config.digestIntervalSec,
229
+ approvalTimeoutSec: this.node.config.approvalTimeoutSec,
230
+ maxMessageChars: this.node.config.maxMessageChars,
231
+ sendChunkDelayMs: this.node.config.sendChunkDelayMs,
232
+ groupAutoApprove: this.node.config.groupAutoApprove === true,
233
+ changesDigest: this.node.config.changesDigest !== false,
234
+ showActivity: this.node.config.showActivity === true,
235
+ steerMode: this.node.config.steerMode === true,
236
+ ...readSessionConfig(this.node.config),
237
+ }
238
+ if (botToken !== undefined) patch.botToken = this.gateway.config.botToken
239
+ if (proxy !== undefined) patch.proxy = this.gateway.config.proxy
240
+ if (name !== undefined) patch.name = this.config.name ?? ''
241
+ await this.persist(patch)
242
+
243
+ if (this.gateway.configured && (botToken !== undefined || proxy !== undefined)) {
244
+ await this.start().catch((err) => {
245
+ this.logger?.warn?.('[dsh-bridge telegram] auto-start failed: %s', err?.message ?? err)
246
+ })
247
+ }
248
+ return { success: true }
249
+ }
250
+
251
+ dispose() {
252
+ super.dispose()
253
+ this.gateway?.dispose?.()
254
+ }
255
+ }
@@ -0,0 +1,410 @@
1
+ // dsh-bridge Telegram conversation node
2
+ // 把 Telegram OpenAPI / 长轮询入站事件解析后交给平台无关的
3
+ // ConversationBridge 处理,出站通过 TelegramGateway 发送文本 / Inline Keyboard 按钮 / 媒体。
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
8
+ import { cumulativeSlices } from '../platform/stream-slices.js'
9
+ import { escapeMarkdown } from '../platform/activity.js'
10
+
11
+ function sleep(ms) {
12
+ return new Promise((resolve) => setTimeout(resolve, ms))
13
+ }
14
+
15
+ function splitIntoIncremental(text, maxChunkSize = 200) {
16
+ if (!text) return []
17
+ const chunks = conversationBridgeHelpers.splitForIM(text, maxChunkSize)
18
+ if (chunks.length <= 1) {
19
+ if (text.length <= maxChunkSize) return [text]
20
+ const mid = Math.ceil(text.length / 2)
21
+ return [text.slice(0, mid), text]
22
+ }
23
+ return cumulativeSlices(chunks)
24
+ }
25
+
26
+ /**
27
+ * 平台适配对象(桥通过 `this.platform` 调用各能力)。
28
+ *
29
+ * @param {object} gateway Telegram 网关
30
+ * @param {{ setActivityStatus?: Function }} [hooks] 会话桥需要的能力钩子。
31
+ * **必须挂在 platform 上**(桥调的是 this.platform.xxx,不是节点自身)——
32
+ * 飞书侧曾因挂错对象导致状态永不显示,这里遵循同一约定。
33
+ */
34
+ function makePlatform(gateway, hooks = {}) {
35
+ return {
36
+ id: 'telegram',
37
+ name: 'Telegram',
38
+ get accountId() { return gateway.accountId ?? '' },
39
+ get capabilities() { return gateway.capabilities },
40
+ get status() { return gateway?.status ?? 'idle' },
41
+ async sendText(peerId, text, opts = {}) {
42
+ return gateway?.sendText(peerId, text, opts)
43
+ },
44
+ async sendMediaFile(peerId, filePath, opts = {}) {
45
+ return gateway?.sendMediaFile(peerId, filePath, opts)
46
+ },
47
+ async sendTyping(peerId) {
48
+ return gateway?.sendTyping(peerId)
49
+ },
50
+ // 轮次中的"活动状态":桥按鸭子类型探测(与飞书同一约定)
51
+ ...(typeof hooks.setActivityStatus === 'function'
52
+ ? { setActivityStatus: (line, opts, parts) => hooks.setActivityStatus(line, opts, parts) }
53
+ : {}),
54
+ dispose() {},
55
+ }
56
+ }
57
+
58
+ export class TelegramConversationNode extends ConversationBridge {
59
+ constructor(ctx, config = {}, logger = console, { onFirstSender, onActiveSessionChange, gateway: explicitGateway, instanceId } = {}) {
60
+ // issue #78:显式接收所属网关,不再依赖 ctx.telegram 槽位(多实例时槽位会被覆盖)
61
+ const gateway = explicitGateway ?? ctx.telegram
62
+ super({
63
+ ctx,
64
+ logger,
65
+ config: {
66
+ maxMessageChars: 4096,
67
+ ...config,
68
+ },
69
+ // 惰性转发:makePlatform 在 super() 参数里构造,此刻 this 尚不可用,
70
+ // 故转发函数运行时才取 this.setActivityStatus(定义在类上)。
71
+ platform: makePlatform(gateway, {
72
+ setActivityStatus: (line, opts, parts) => this.setActivityStatus(line, opts, parts),
73
+ }),
74
+ onFirstSender,
75
+ onActiveSessionChange,
76
+ })
77
+
78
+ this.gateway = gateway
79
+ // 事件过滤标识:只处理本实例网关发出的事件,避免同类型多账号串消息
80
+ this.instanceId = instanceId ?? gateway?.instanceId ?? 'telegram'
81
+ this._lastPeer = null
82
+ this._streamMsgId = null
83
+ this._streamContent = ''
84
+ this._inTurn = false
85
+ this._activityLine = '' // 轮次中的活动状态行(收尾时清空,避免残留"进行中")
86
+
87
+ // 事件仅属于本实例(旧事件无 instanceId 时按单实例兼容放行)
88
+ const forThisInstance = (event) => !event?.instanceId || event.instanceId === this.instanceId
89
+
90
+ // 订阅网关入站消息事件
91
+ this.disposers.push(this.ctx.on('telegram/message', (event) => {
92
+ if (!forThisInstance(event)) return
93
+ return this._handleInbound(event)
94
+ }))
95
+
96
+ // 订阅 Inline 按钮点击交互事件(审批确认)
97
+ this.disposers.push(this.ctx.on('telegram/action', (event) => {
98
+ if (!forThisInstance(event)) return
99
+ return this._handleAction(event)
100
+ }))
101
+
102
+ // 监听轮次事件:turn/start 开启流式打字机,turn/end 最终刷新并重置
103
+ this.disposers.push(this.ctx.on('session/event', (session, event) => {
104
+ if (session.id !== this.activeSessionId) return
105
+ if (event.type === 'turn/start') {
106
+ this._inTurn = true
107
+ this._streamMsgId = null
108
+ this._streamContent = ''
109
+ this._activityLine = ''
110
+ } else if (event.type === 'turn/end') {
111
+ this._inTurn = false
112
+ // 清状态行并把**纯净正文**写回一次:否则消息会残留「正在运行命令…」,
113
+ // 让用户以为任务还在跑(飞书侧实测过该问题)。
114
+ this._activityLine = ''
115
+ if (this._streamMsgId && this._streamContent) {
116
+ const peerId = this._lastPeer?.chatId || this.peerId
117
+ if (peerId) {
118
+ void this.gateway?.editMessageText(peerId, this._streamMsgId, this._streamContent).catch(() => {})
119
+ }
120
+ }
121
+ this._streamMsgId = null
122
+ this._streamContent = ''
123
+ }
124
+ }))
125
+ }
126
+
127
+ async _handleInbound(event) {
128
+ if (this.gateway?._stopPolling) return
129
+ const { chatId, senderId, senderUsername, isGroup, text, messageId, raw } = event
130
+ this._lastPeer = { chatId, senderId, senderUsername, isGroup }
131
+ const authId = isGroup ? chatId : senderId
132
+
133
+ // 检查是否有富媒体附件(图片 / 文件 / 语音 / 音频)
134
+ let mediaFiles = []
135
+ if (raw) {
136
+ mediaFiles = await this._processInboundMedia(raw, this.config.cwd)
137
+ }
138
+
139
+ if (!text && mediaFiles.length === 0) {
140
+ this.logger.debug?.(`[dsh-bridge telegram] ignore empty message from ${chatId}`)
141
+ return
142
+ }
143
+
144
+ let fullText = text || ''
145
+ if (mediaFiles.length > 0) {
146
+ const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
147
+ fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
148
+ }
149
+
150
+ this.logger.debug?.(`[dsh-bridge telegram] handling inbound message from ${chatId} (group=${isGroup}): ${fullText}`)
151
+
152
+ // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
153
+ if (!this.activeSessionId) {
154
+ await this._pickDefaultSession().catch(() => {})
155
+ }
156
+
157
+ // 转交通用 ConversationBridge 路由
158
+ // outboundPeer:本轮出站事件流绑定回发起会话(chat 而非成员),替代此前被静默丢弃的 peerId 幽灵参数
159
+ return this.handleInbound({
160
+ senderId: authId,
161
+ isGroup,
162
+ text: fullText,
163
+ outboundPeer: { peerId: chatId, isGroup },
164
+ })
165
+ }
166
+
167
+ async _processInboundMedia(rawMsg, sessionCwd) {
168
+ if (!this.gateway) return []
169
+ const downloaded = []
170
+
171
+ try {
172
+ // 1. 照片(选择最高分辨率的一张)
173
+ if (Array.isArray(rawMsg.photo) && rawMsg.photo.length > 0) {
174
+ const bestPhoto = rawMsg.photo[rawMsg.photo.length - 1]
175
+ if (bestPhoto?.file_id) {
176
+ const res = await this.gateway.downloadFile(bestPhoto.file_id, sessionCwd)
177
+ if (res) downloaded.push(res)
178
+ }
179
+ }
180
+ // 2. 文档 / 文件
181
+ else if (rawMsg.document?.file_id) {
182
+ const res = await this.gateway.downloadFile(rawMsg.document.file_id, sessionCwd)
183
+ if (res) downloaded.push(res)
184
+ }
185
+ // 3. 语音 / 音频
186
+ else if (rawMsg.voice?.file_id) {
187
+ const res = await this.gateway.downloadFile(rawMsg.voice.file_id, sessionCwd)
188
+ if (res) downloaded.push(res)
189
+ } else if (rawMsg.audio?.file_id) {
190
+ const res = await this.gateway.downloadFile(rawMsg.audio.file_id, sessionCwd)
191
+ if (res) downloaded.push(res)
192
+ }
193
+ } catch (err) {
194
+ this.logger.warn?.(`[dsh-bridge telegram] process media error: ${err.message}`)
195
+ }
196
+
197
+ return downloaded
198
+ }
199
+
200
+ async _handleAction(event) {
201
+ const { queryId, chatId, operatorId, data } = event
202
+ if (!data || !this.gateway) return
203
+
204
+ // 1. 审批决议按钮 (approve:ID / reject:ID)
205
+ const match = /^([a-zA-Z]+):(\d+)$/.exec(data)
206
+ if (match) {
207
+ const [, action, approvalIdStr] = match
208
+ const approvalId = Number(approvalIdStr)
209
+ const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
210
+ const pending = this.pending.get(approvalId)
211
+
212
+ if (pending) {
213
+ this.clearApproval(approvalId)
214
+ pending.resolve(outcome)
215
+ await this.gateway?.answerCallbackQuery(
216
+ queryId,
217
+ action === 'approve' ? '✓ 操作已批准' : '✕ 操作已拒绝',
218
+ false,
219
+ )
220
+ } else {
221
+ await this.gateway?.answerCallbackQuery(queryId, '⚠️ 该审批已处理或已过期', false)
222
+ }
223
+ return
224
+ }
225
+
226
+ // 2. 快捷指令交互按键 (cmd:xxx)
227
+ if (data.startsWith('cmd:')) {
228
+ const cmdBody = data.slice(4)
229
+ await this.gateway?.answerCallbackQuery(queryId, `⚡ 执行: /${cmdBody.replace(':', ' ')}`, false)
230
+ let text = `/${cmdBody.replace(':', ' ')}`
231
+ if (cmdBody.startsWith('use:')) {
232
+ text = `/use ${cmdBody.slice(4)}`
233
+ }
234
+ return this.handleInbound({
235
+ senderId: operatorId || chatId,
236
+ isGroup: false,
237
+ text,
238
+ outboundPeer: { peerId: chatId, isGroup: false },
239
+ })
240
+ }
241
+ }
242
+
243
+ async _sendTextNow(text, opts = {}) {
244
+ // T2.3:轮次绑定的 outboundPeer(chat 级目标)优先生效
245
+ const peerId = opts?.outboundPeer?.peerId || this._lastPeer?.chatId || this.peerId
246
+ if (!peerId || !this.gateway) return
247
+ const content = String(text || '').trim()
248
+ if (!content) return
249
+
250
+ // 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
251
+ if (this._inTurn) {
252
+ const maxChunk = 200
253
+ const slices = splitIntoIncremental(content, maxChunk)
254
+ const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 800, 400), 2000)
255
+
256
+ if (!this._streamMsgId) {
257
+ // 首片:创建消息并记录 message_id
258
+ const firstSlice = slices[0] || content
259
+ try {
260
+ const res = await this.gateway.sendText(peerId, this._withStatus(firstSlice))
261
+ this._streamMsgId = res?.message_id || null
262
+ this._streamContent = firstSlice
263
+ } catch (err) {
264
+ this.logger.warn?.('[dsh-bridge telegram] sendText stream initial failed:', err?.message ?? err)
265
+ await this.gateway.sendText(peerId, content).catch(() => {})
266
+ return
267
+ }
268
+
269
+ // 后续片依次 editMessageText(间隔 delayMs,呈现平滑打字机流式效果)
270
+ for (let i = 1; i < slices.length; i++) {
271
+ await sleep(delayMs)
272
+ this._streamContent = slices[i]
273
+ if (this._streamMsgId) {
274
+ await this.gateway.editMessageText(peerId, this._streamMsgId, this._withStatus(this._streamContent)).catch(() => {})
275
+ }
276
+ }
277
+ } else {
278
+ // 消息已存在(同轮次后续输出):必须**累积**而非覆盖。
279
+ // 宿主对每个 step 发一条独立的 `assistant/message`(该步的片段,不是整轮全文),
280
+ // 直接覆盖会让用户看到"输出一半消失、又重新输出"、最终只剩最后一段
281
+ // (2026-10-16 飞书侧真机复现,Telegram 同一实现同样受影响)。
282
+ const merged = this._streamContent ? `${this._streamContent}\n\n${content}` : content
283
+ if (merged.length <= 4000) {
284
+ this._streamContent = merged
285
+ await this.gateway.editMessageText(peerId, this._streamMsgId, this._withStatus(this._streamContent)).catch(() => {})
286
+ } else {
287
+ // 超出 4000 字符,另起一条新消息继续流式(保留已输出内容在前一条里)
288
+ const res = await this.gateway.sendText(peerId, content).catch(() => null)
289
+ this._streamMsgId = res?.message_id || null
290
+ this._streamContent = content
291
+ }
292
+ }
293
+ return
294
+ }
295
+
296
+ // 非 turn 期间(指令响应、系统提示、单条通知等):
297
+ // 若命中帮助/状态/会话列表等特定系统命令响应,挂载原生快捷交互按钮
298
+ if (content.includes('命令帮助') || content.includes('/new <提示词>')) {
299
+ const buttons = [
300
+ [
301
+ { text: '📋 会话列表', callback_data: 'cmd:sessions' },
302
+ { text: '📁 工作区', callback_data: 'cmd:workspaces' },
303
+ ],
304
+ [
305
+ { text: '📊 运行状态', callback_data: 'cmd:status' },
306
+ { text: '⏹ 停止任务', callback_data: 'cmd:stop' },
307
+ ],
308
+ ]
309
+ return this.gateway.sendKeyboard(peerId, content, buttons)
310
+ }
311
+
312
+ if (content.includes('Agent 状态') || content.includes('无活动会话')) {
313
+ const buttons = [
314
+ [
315
+ { text: '🔄 刷新状态', callback_data: 'cmd:status' },
316
+ { text: '⏹ 停止任务', callback_data: 'cmd:stop' },
317
+ ],
318
+ [
319
+ { text: '📋 会话列表', callback_data: 'cmd:sessions' },
320
+ { text: '🚪 结束会话', callback_data: 'cmd:end' },
321
+ ],
322
+ ]
323
+ return this.gateway.sendKeyboard(peerId, content, buttons)
324
+ }
325
+
326
+ if (content.includes('会话列表') || content.includes('可用会话')) {
327
+ const buttons = [
328
+ [
329
+ { text: '📁 可用工作区', callback_data: 'cmd:workspaces' },
330
+ { text: '📊 查看状态', callback_data: 'cmd:status' },
331
+ ],
332
+ ]
333
+ return this.gateway.sendKeyboard(peerId, content, buttons)
334
+ }
335
+
336
+ if (content.length <= 4000) {
337
+ return this.gateway.sendText(peerId, content)
338
+ }
339
+ const chunks = conversationBridgeHelpers.splitForIM(content, 4000)
340
+ for (const chunk of chunks) {
341
+ await this.gateway.sendText(peerId, chunk)
342
+ }
343
+ }
344
+
345
+ /**
346
+ * 把"当前活动"状态行拼到正文前(与飞书同一呈现:引用块 + 斜体)。
347
+ *
348
+ * Telegram 侧由 gateway 的 HTML 格式化把 `> quote` 转成 `<blockquote>`、
349
+ * `_text_` 转成斜体(见 lib/telegram/gateway.js 的格式化步骤),因此语法可直接复用。
350
+ * detail 做 markdown 转义:命令原文里的 `*` `_` `` ` `` 会破坏样式。
351
+ */
352
+ _withStatus(text) {
353
+ const status = this._activityLine
354
+ if (!status) return text
355
+ const styled = `> _${status}_`
356
+ return text ? `${styled}\n\n${text}` : styled
357
+ }
358
+
359
+ /**
360
+ * 更新"当前活动"状态(由会话桥在 tool/call 时调用)。
361
+ *
362
+ * @param {string} line 纯文本状态行(老平台路径)
363
+ * @param {object} [_opts]
364
+ * @param {{icon:string,text:string,detail:string,suffix:string}} [parts] 结构化形式(detail 会被转义)
365
+ */
366
+ async setActivityStatus(line, _opts, parts) {
367
+ this._activityLine = parts ? this._styleActivityParts(parts) : (line || '')
368
+ // 已有流式消息时立即刷新(否则等下一片自然带上)
369
+ if (this._streamMsgId && this._streamContent !== undefined) {
370
+ const peerId = this._lastPeer?.chatId || this.peerId
371
+ if (peerId) {
372
+ try {
373
+ await this.gateway?.editMessageText(peerId, this._streamMsgId, this._withStatus(this._streamContent))
374
+ } catch {
375
+ // 此处是 debug 级的非关键失败(流式状态行刷新),日志里**不拼接任何动态值**:
376
+ // 错误对象源自 Telegram API 响应,静态分析会将其视为外部可控输入
377
+ // (js/log-injection)。固定文案即可,无需牺牲可诊断性——真正的失败会由
378
+ // 上层 turn/end / 发送失败路径以更完整的上下文记录。
379
+ this.logger?.debug?.('[dsh-bridge telegram] 状态行刷新失败(详见上层日志)')
380
+ }
381
+ }
382
+ }
383
+ }
384
+
385
+ /** 受控文案保持原样、detail 转义后组装(避免命令原文破坏样式)。 */
386
+ _styleActivityParts(parts) {
387
+ const icon = String(parts?.icon ?? '')
388
+ const text = String(parts?.text ?? '')
389
+ const suffix = String(parts?.suffix ?? '')
390
+ const detail = parts?.detail ? escapeMarkdown(parts.detail) : ''
391
+ const head = detail ? `${icon} ${text}(${detail})` : `${icon} ${text}`
392
+ return `${head}${suffix}`
393
+ }
394
+
395
+ async sendTyping() {
396
+ const peerId = this._lastPeer?.chatId || this.peerId
397
+ if (!peerId || !this.gateway) return
398
+ return this.gateway.sendTyping(peerId)
399
+ }
400
+
401
+ dispose() {
402
+ this._inTurn = false
403
+ this._streamMsgId = null
404
+ this._streamContent = ''
405
+ this._lastPeer = null
406
+ super.dispose()
407
+ }
408
+ }
409
+
410
+ export const telegramNodeHelpers = { makePlatform }