@wenbin_wb/dsh-bridge 2.8.7 → 2.9.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.
@@ -1,351 +1,349 @@
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
-
9
- function sleep(ms) {
10
- return new Promise((resolve) => setTimeout(resolve, ms))
11
- }
12
-
13
- function splitIntoIncremental(text, maxChunkSize = 200) {
14
- if (!text) return []
15
- const chunks = conversationBridgeHelpers.splitForIM(text, maxChunkSize)
16
- if (chunks.length <= 1) {
17
- if (text.length <= maxChunkSize) return [text]
18
- const mid = Math.ceil(text.length / 2)
19
- return [text.slice(0, mid), text]
20
- }
21
- const slices = []
22
- let acc = ''
23
- for (const s of chunks) {
24
- acc += s
25
- slices.push(acc)
26
- }
27
- return slices
28
- }
29
-
30
- function makePlatform(gateway) {
31
- return {
32
- id: 'telegram',
33
- name: 'Telegram',
34
- get accountId() { return gateway.accountId ?? '' },
35
- get capabilities() { return gateway.capabilities },
36
- async sendText(peerId, text, opts = {}) {
37
- return gateway?.sendText(peerId, text, opts)
38
- },
39
- async sendMediaFile(peerId, filePath, opts = {}) {
40
- return gateway?.sendMediaFile(peerId, filePath, opts)
41
- },
42
- async sendTyping(peerId) {
43
- return gateway?.sendTyping(peerId)
44
- },
45
- dispose() {},
46
- }
47
- }
48
-
49
- export class TelegramConversationNode extends ConversationBridge {
50
- constructor(ctx, config = {}, logger = console, { onFirstSender, onActiveSessionChange } = {}) {
51
- const gateway = ctx.telegram
52
- super({
53
- ctx,
54
- logger,
55
- config: {
56
- maxMessageChars: 4096,
57
- ...config,
58
- },
59
- platform: makePlatform(gateway),
60
- onFirstSender,
61
- onActiveSessionChange,
62
- })
63
-
64
- this.gateway = gateway
65
- this._lastPeer = null
66
- this._streamMsgId = null
67
- this._streamContent = ''
68
- this._inTurn = false
69
-
70
- // 订阅网关入站消息事件
71
- this.disposers.push(this.ctx.on('telegram/message', (event) => this._handleInbound(event)))
72
-
73
- // 订阅 Inline 按钮点击交互事件(审批确认)
74
- this.disposers.push(this.ctx.on('telegram/action', (event) => this._handleAction(event)))
75
-
76
- // 监听轮次事件:turn/start 开启流式打字机,turn/end 最终刷新并重置
77
- this.disposers.push(this.ctx.on('session/event', (session, event) => {
78
- if (session.id !== this.activeSessionId) return
79
- if (event.type === 'turn/start') {
80
- this._inTurn = true
81
- this._streamMsgId = null
82
- this._streamContent = ''
83
- } else if (event.type === 'turn/end') {
84
- this._inTurn = false
85
- if (this._streamMsgId && this._streamContent) {
86
- const peerId = this._lastPeer?.chatId || this.peerId
87
- if (peerId) {
88
- void this.gateway?.editMessageText(peerId, this._streamMsgId, this._streamContent).catch(() => {})
89
- }
90
- }
91
- this._streamMsgId = null
92
- this._streamContent = ''
93
- }
94
- }))
95
- }
96
-
97
- async _handleInbound(event) {
98
- if (this.gateway?._stopPolling) return
99
- const { chatId, senderId, senderUsername, isGroup, text, messageId, raw } = event
100
- this._lastPeer = { chatId, senderId, senderUsername, isGroup }
101
- const authId = isGroup ? chatId : senderId
102
-
103
- // 检查是否有富媒体附件(图片 / 文件 / 语音 / 音频)
104
- let mediaFiles = []
105
- if (raw) {
106
- mediaFiles = await this._processInboundMedia(raw, this.config.cwd)
107
- }
108
-
109
- if (!text && mediaFiles.length === 0) {
110
- this.logger.debug?.(`[dsh-bridge telegram] ignore empty message from ${chatId}`)
111
- return
112
- }
113
-
114
- let fullText = text || ''
115
- if (mediaFiles.length > 0) {
116
- const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
117
- fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
118
- }
119
-
120
- this.logger.debug?.(`[dsh-bridge telegram] handling inbound message from ${chatId} (group=${isGroup}): ${fullText}`)
121
-
122
- // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
123
- if (!this.activeSessionId) {
124
- await this._pickDefaultSession().catch(() => {})
125
- }
126
-
127
- // 转交通用 ConversationBridge 路由
128
- return this.handleInbound({
129
- senderId: authId,
130
- peerId: chatId,
131
- isGroup,
132
- text: fullText,
133
- })
134
- }
135
-
136
- async _processInboundMedia(rawMsg, sessionCwd) {
137
- if (!this.gateway) return []
138
- const downloaded = []
139
-
140
- try {
141
- // 1. 照片(选择最高分辨率的一张)
142
- if (Array.isArray(rawMsg.photo) && rawMsg.photo.length > 0) {
143
- const bestPhoto = rawMsg.photo[rawMsg.photo.length - 1]
144
- if (bestPhoto?.file_id) {
145
- const res = await this.gateway.downloadFile(bestPhoto.file_id, sessionCwd)
146
- if (res) downloaded.push(res)
147
- }
148
- }
149
- // 2. 文档 / 文件
150
- else if (rawMsg.document?.file_id) {
151
- const res = await this.gateway.downloadFile(rawMsg.document.file_id, sessionCwd)
152
- if (res) downloaded.push(res)
153
- }
154
- // 3. 语音 / 音频
155
- else if (rawMsg.voice?.file_id) {
156
- const res = await this.gateway.downloadFile(rawMsg.voice.file_id, sessionCwd)
157
- if (res) downloaded.push(res)
158
- } else if (rawMsg.audio?.file_id) {
159
- const res = await this.gateway.downloadFile(rawMsg.audio.file_id, sessionCwd)
160
- if (res) downloaded.push(res)
161
- }
162
- } catch (err) {
163
- this.logger.warn?.(`[dsh-bridge telegram] process media error: ${err.message}`)
164
- }
165
-
166
- return downloaded
167
- }
168
-
169
- async _handleAction(event) {
170
- const { queryId, chatId, operatorId, data } = event
171
- if (!data || !this.gateway) return
172
-
173
- // 1. 审批决议按钮 (approve:ID / reject:ID)
174
- const match = /^([a-zA-Z]+):(\d+)$/.exec(data)
175
- if (match) {
176
- const [, action, approvalIdStr] = match
177
- const approvalId = Number(approvalIdStr)
178
- const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
179
- const pending = this.pending.get(approvalId)
180
-
181
- if (pending) {
182
- this.clearApproval(approvalId)
183
- pending.resolve(outcome)
184
- await this.gateway?.answerCallbackQuery(
185
- queryId,
186
- action === 'approve' ? '✓ 操作已批准' : '✕ 操作已拒绝',
187
- false,
188
- )
189
- } else {
190
- await this.gateway?.answerCallbackQuery(queryId, '⚠️ 该审批已处理或已过期', false)
191
- }
192
- return
193
- }
194
-
195
- // 2. 快捷指令交互按键 (cmd:xxx)
196
- if (data.startsWith('cmd:')) {
197
- const cmdBody = data.slice(4)
198
- await this.gateway?.answerCallbackQuery(queryId, `⚡ 执行: /${cmdBody.replace(':', ' ')}`, false)
199
- let text = `/${cmdBody.replace(':', ' ')}`
200
- if (cmdBody.startsWith('use:')) {
201
- text = `/use ${cmdBody.slice(4)}`
202
- }
203
- return this.handleInbound({
204
- senderId: operatorId || chatId,
205
- peerId: chatId,
206
- isGroup: false,
207
- text,
208
- })
209
- }
210
- }
211
-
212
- async sendApprovalCard(approvalId, request) {
213
- const peerId = this._lastPeer?.chatId || this.peerId
214
- if (!peerId || !this.gateway) return
215
-
216
- const toolName = request?.name || request?.tool || '系统操作'
217
- const desc = request?.description || request?.summary || ''
218
- const command = request?.command || request?.cmd || ''
219
-
220
- const lines = [
221
- `⚠️ **操作权限确认** (ID: <code>${approvalId}</code>)`,
222
- '',
223
- `• **工具**:<code>${toolName}</code>`,
224
- ]
225
- if (desc) lines.push(`• **说明**:${desc}`)
226
- if (command) lines.push(`• **命令**:<code>${command}</code>`)
227
- lines.push('', '请选择审批决议(亦可直接输入 <code>1</code> 批准,<code>2</code> 拒绝):')
228
-
229
- const buttons = [
230
- [
231
- { text: '✓ 批准执行', callback_data: `approve:${approvalId}` },
232
- { text: '✕ 拒绝执行', callback_data: `reject:${approvalId}` },
233
- ],
234
- ]
235
-
236
- return this.gateway.sendKeyboard(peerId, lines.join('\n'), buttons)
237
- }
238
-
239
- async sendText(text) {
240
- const peerId = this._lastPeer?.chatId || this.peerId
241
- if (!peerId || !this.gateway) return
242
- const content = String(text || '').trim()
243
- if (!content) return
244
-
245
- // 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
246
- if (this._inTurn) {
247
- const maxChunk = 200
248
- const slices = splitIntoIncremental(content, maxChunk)
249
- const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 800, 400), 2000)
250
-
251
- if (!this._streamMsgId) {
252
- // 首片:创建消息并记录 message_id
253
- const firstSlice = slices[0] || content
254
- try {
255
- const res = await this.gateway.sendText(peerId, firstSlice)
256
- this._streamMsgId = res?.message_id || null
257
- this._streamContent = firstSlice
258
- } catch (err) {
259
- this.logger.warn?.('[dsh-bridge telegram] sendText stream initial failed:', err?.message ?? err)
260
- await this.gateway.sendText(peerId, content).catch(() => {})
261
- return
262
- }
263
-
264
- // 后续片依次 editMessageText(间隔 delayMs,呈现平滑打字机流式效果)
265
- for (let i = 1; i < slices.length; i++) {
266
- await sleep(delayMs)
267
- this._streamContent = slices[i]
268
- if (this._streamMsgId) {
269
- await this.gateway.editMessageText(peerId, this._streamMsgId, this._streamContent).catch(() => {})
270
- }
271
- }
272
- } else {
273
- // 消息已存在(同轮次后续输出)
274
- if (content.length <= 4000) {
275
- this._streamContent = content
276
- await this.gateway.editMessageText(peerId, this._streamMsgId, this._streamContent).catch(() => {})
277
- } else {
278
- // 超出 4000 字符,另起一条新消息继续流式
279
- const res = await this.gateway.sendText(peerId, content).catch(() => null)
280
- this._streamMsgId = res?.message_id || null
281
- this._streamContent = content
282
- }
283
- }
284
- return
285
- }
286
-
287
- // turn 期间(指令响应、系统提示、单条通知等):
288
- // 若命中帮助/状态/会话列表等特定系统命令响应,挂载原生快捷交互按钮
289
- if (content.includes('命令帮助') || content.includes('/new <提示词>')) {
290
- const buttons = [
291
- [
292
- { text: '📋 会话列表', callback_data: 'cmd:sessions' },
293
- { text: '📁 工作区', callback_data: 'cmd:workspaces' },
294
- ],
295
- [
296
- { text: '📊 运行状态', callback_data: 'cmd:status' },
297
- { text: '⏹ 停止任务', callback_data: 'cmd:stop' },
298
- ],
299
- ]
300
- return this.gateway.sendKeyboard(peerId, content, buttons)
301
- }
302
-
303
- if (content.includes('Agent 状态') || content.includes('无活动会话')) {
304
- const buttons = [
305
- [
306
- { text: '🔄 刷新状态', callback_data: 'cmd:status' },
307
- { text: '⏹ 停止任务', callback_data: 'cmd:stop' },
308
- ],
309
- [
310
- { text: '📋 会话列表', callback_data: 'cmd:sessions' },
311
- { text: '🚪 结束会话', callback_data: 'cmd:end' },
312
- ],
313
- ]
314
- return this.gateway.sendKeyboard(peerId, content, buttons)
315
- }
316
-
317
- if (content.includes('会话列表') || content.includes('可用会话')) {
318
- const buttons = [
319
- [
320
- { text: '📁 可用工作区', callback_data: 'cmd:workspaces' },
321
- { text: '📊 查看状态', callback_data: 'cmd:status' },
322
- ],
323
- ]
324
- return this.gateway.sendKeyboard(peerId, content, buttons)
325
- }
326
-
327
- if (content.length <= 4000) {
328
- return this.gateway.sendText(peerId, content)
329
- }
330
- const chunks = conversationBridgeHelpers.splitForIM(content, 4000)
331
- for (const chunk of chunks) {
332
- await this.gateway.sendText(peerId, chunk)
333
- }
334
- }
335
-
336
- async sendTyping() {
337
- const peerId = this._lastPeer?.chatId || this.peerId
338
- if (!peerId || !this.gateway) return
339
- return this.gateway.sendTyping(peerId)
340
- }
341
-
342
- dispose() {
343
- this._inTurn = false
344
- this._streamMsgId = null
345
- this._streamContent = ''
346
- this._lastPeer = null
347
- super.dispose()
348
- }
349
- }
350
-
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
+
10
+ function sleep(ms) {
11
+ return new Promise((resolve) => setTimeout(resolve, ms))
12
+ }
13
+
14
+ function splitIntoIncremental(text, maxChunkSize = 200) {
15
+ if (!text) return []
16
+ const chunks = conversationBridgeHelpers.splitForIM(text, maxChunkSize)
17
+ if (chunks.length <= 1) {
18
+ if (text.length <= maxChunkSize) return [text]
19
+ const mid = Math.ceil(text.length / 2)
20
+ return [text.slice(0, mid), text]
21
+ }
22
+ return cumulativeSlices(chunks)
23
+ }
24
+
25
+ function makePlatform(gateway) {
26
+ return {
27
+ id: 'telegram',
28
+ name: 'Telegram',
29
+ get accountId() { return gateway.accountId ?? '' },
30
+ get capabilities() { return gateway.capabilities },
31
+ get status() { return gateway?.status ?? 'idle' },
32
+ async sendText(peerId, text, opts = {}) {
33
+ return gateway?.sendText(peerId, text, opts)
34
+ },
35
+ async sendMediaFile(peerId, filePath, opts = {}) {
36
+ return gateway?.sendMediaFile(peerId, filePath, opts)
37
+ },
38
+ async sendTyping(peerId) {
39
+ return gateway?.sendTyping(peerId)
40
+ },
41
+ dispose() {},
42
+ }
43
+ }
44
+
45
+ export class TelegramConversationNode extends ConversationBridge {
46
+ constructor(ctx, config = {}, logger = console, { onFirstSender, onActiveSessionChange } = {}) {
47
+ const gateway = ctx.telegram
48
+ super({
49
+ ctx,
50
+ logger,
51
+ config: {
52
+ maxMessageChars: 4096,
53
+ ...config,
54
+ },
55
+ platform: makePlatform(gateway),
56
+ onFirstSender,
57
+ onActiveSessionChange,
58
+ })
59
+
60
+ this.gateway = gateway
61
+ this._lastPeer = null
62
+ this._streamMsgId = null
63
+ this._streamContent = ''
64
+ this._inTurn = false
65
+
66
+ // 订阅网关入站消息事件
67
+ this.disposers.push(this.ctx.on('telegram/message', (event) => this._handleInbound(event)))
68
+
69
+ // 订阅 Inline 按钮点击交互事件(审批确认)
70
+ this.disposers.push(this.ctx.on('telegram/action', (event) => this._handleAction(event)))
71
+
72
+ // 监听轮次事件:turn/start 开启流式打字机,turn/end 最终刷新并重置
73
+ this.disposers.push(this.ctx.on('session/event', (session, event) => {
74
+ if (session.id !== this.activeSessionId) return
75
+ if (event.type === 'turn/start') {
76
+ this._inTurn = true
77
+ this._streamMsgId = null
78
+ this._streamContent = ''
79
+ } else if (event.type === 'turn/end') {
80
+ this._inTurn = false
81
+ if (this._streamMsgId && this._streamContent) {
82
+ const peerId = this._lastPeer?.chatId || this.peerId
83
+ if (peerId) {
84
+ void this.gateway?.editMessageText(peerId, this._streamMsgId, this._streamContent).catch(() => {})
85
+ }
86
+ }
87
+ this._streamMsgId = null
88
+ this._streamContent = ''
89
+ }
90
+ }))
91
+ }
92
+
93
+ async _handleInbound(event) {
94
+ if (this.gateway?._stopPolling) return
95
+ const { chatId, senderId, senderUsername, isGroup, text, messageId, raw } = event
96
+ this._lastPeer = { chatId, senderId, senderUsername, isGroup }
97
+ const authId = isGroup ? chatId : senderId
98
+
99
+ // 检查是否有富媒体附件(图片 / 文件 / 语音 / 音频)
100
+ let mediaFiles = []
101
+ if (raw) {
102
+ mediaFiles = await this._processInboundMedia(raw, this.config.cwd)
103
+ }
104
+
105
+ if (!text && mediaFiles.length === 0) {
106
+ this.logger.debug?.(`[dsh-bridge telegram] ignore empty message from ${chatId}`)
107
+ return
108
+ }
109
+
110
+ let fullText = text || ''
111
+ if (mediaFiles.length > 0) {
112
+ const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
113
+ fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
114
+ }
115
+
116
+ this.logger.debug?.(`[dsh-bridge telegram] handling inbound message from ${chatId} (group=${isGroup}): ${fullText}`)
117
+
118
+ // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
119
+ if (!this.activeSessionId) {
120
+ await this._pickDefaultSession().catch(() => {})
121
+ }
122
+
123
+ // 转交通用 ConversationBridge 路由
124
+ // outboundPeer:本轮出站事件流绑定回发起会话(chat 而非成员),替代此前被静默丢弃的 peerId 幽灵参数
125
+ return this.handleInbound({
126
+ senderId: authId,
127
+ isGroup,
128
+ text: fullText,
129
+ outboundPeer: { peerId: chatId, isGroup },
130
+ })
131
+ }
132
+
133
+ async _processInboundMedia(rawMsg, sessionCwd) {
134
+ if (!this.gateway) return []
135
+ const downloaded = []
136
+
137
+ try {
138
+ // 1. 照片(选择最高分辨率的一张)
139
+ if (Array.isArray(rawMsg.photo) && rawMsg.photo.length > 0) {
140
+ const bestPhoto = rawMsg.photo[rawMsg.photo.length - 1]
141
+ if (bestPhoto?.file_id) {
142
+ const res = await this.gateway.downloadFile(bestPhoto.file_id, sessionCwd)
143
+ if (res) downloaded.push(res)
144
+ }
145
+ }
146
+ // 2. 文档 / 文件
147
+ else if (rawMsg.document?.file_id) {
148
+ const res = await this.gateway.downloadFile(rawMsg.document.file_id, sessionCwd)
149
+ if (res) downloaded.push(res)
150
+ }
151
+ // 3. 语音 / 音频
152
+ else if (rawMsg.voice?.file_id) {
153
+ const res = await this.gateway.downloadFile(rawMsg.voice.file_id, sessionCwd)
154
+ if (res) downloaded.push(res)
155
+ } else if (rawMsg.audio?.file_id) {
156
+ const res = await this.gateway.downloadFile(rawMsg.audio.file_id, sessionCwd)
157
+ if (res) downloaded.push(res)
158
+ }
159
+ } catch (err) {
160
+ this.logger.warn?.(`[dsh-bridge telegram] process media error: ${err.message}`)
161
+ }
162
+
163
+ return downloaded
164
+ }
165
+
166
+ async _handleAction(event) {
167
+ const { queryId, chatId, operatorId, data } = event
168
+ if (!data || !this.gateway) return
169
+
170
+ // 1. 审批决议按钮 (approve:ID / reject:ID)
171
+ const match = /^([a-zA-Z]+):(\d+)$/.exec(data)
172
+ if (match) {
173
+ const [, action, approvalIdStr] = match
174
+ const approvalId = Number(approvalIdStr)
175
+ const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
176
+ const pending = this.pending.get(approvalId)
177
+
178
+ if (pending) {
179
+ this.clearApproval(approvalId)
180
+ pending.resolve(outcome)
181
+ await this.gateway?.answerCallbackQuery(
182
+ queryId,
183
+ action === 'approve' ? '✓ 操作已批准' : '✕ 操作已拒绝',
184
+ false,
185
+ )
186
+ } else {
187
+ await this.gateway?.answerCallbackQuery(queryId, '⚠️ 该审批已处理或已过期', false)
188
+ }
189
+ return
190
+ }
191
+
192
+ // 2. 快捷指令交互按键 (cmd:xxx)
193
+ if (data.startsWith('cmd:')) {
194
+ const cmdBody = data.slice(4)
195
+ await this.gateway?.answerCallbackQuery(queryId, `⚡ 执行: /${cmdBody.replace(':', ' ')}`, false)
196
+ let text = `/${cmdBody.replace(':', ' ')}`
197
+ if (cmdBody.startsWith('use:')) {
198
+ text = `/use ${cmdBody.slice(4)}`
199
+ }
200
+ return this.handleInbound({
201
+ senderId: operatorId || chatId,
202
+ isGroup: false,
203
+ text,
204
+ outboundPeer: { peerId: chatId, isGroup: false },
205
+ })
206
+ }
207
+ }
208
+
209
+ async sendApprovalCard(approvalId, request) {
210
+ const peerId = this._lastPeer?.chatId || this.peerId
211
+ if (!peerId || !this.gateway) return
212
+
213
+ const toolName = request?.name || request?.tool || '系统操作'
214
+ const desc = request?.description || request?.summary || ''
215
+ const command = request?.command || request?.cmd || ''
216
+
217
+ const lines = [
218
+ `⚠️ **操作权限确认** (ID: <code>${approvalId}</code>)`,
219
+ '',
220
+ `• **工具**:<code>${toolName}</code>`,
221
+ ]
222
+ if (desc) lines.push(`• **说明**:${desc}`)
223
+ if (command) lines.push(`• **命令**:<code>${command}</code>`)
224
+ lines.push('', '请选择审批决议(亦可直接输入 <code>1</code> 批准,<code>2</code> 拒绝):')
225
+
226
+ const buttons = [
227
+ [
228
+ { text: '✓ 批准执行', callback_data: `approve:${approvalId}` },
229
+ { text: '✕ 拒绝执行', callback_data: `reject:${approvalId}` },
230
+ ],
231
+ ]
232
+
233
+ return this.gateway.sendKeyboard(peerId, lines.join('\n'), buttons)
234
+ }
235
+
236
+ async _sendTextNow(text, opts = {}) {
237
+ // T2.3:轮次绑定的 outboundPeer(chat 级目标)优先生效
238
+ const peerId = opts?.outboundPeer?.peerId || this._lastPeer?.chatId || this.peerId
239
+ if (!peerId || !this.gateway) return
240
+ const content = String(text || '').trim()
241
+ if (!content) return
242
+
243
+ // 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
244
+ if (this._inTurn) {
245
+ const maxChunk = 200
246
+ const slices = splitIntoIncremental(content, maxChunk)
247
+ const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 800, 400), 2000)
248
+
249
+ if (!this._streamMsgId) {
250
+ // 首片:创建消息并记录 message_id
251
+ const firstSlice = slices[0] || content
252
+ try {
253
+ const res = await this.gateway.sendText(peerId, firstSlice)
254
+ this._streamMsgId = res?.message_id || null
255
+ this._streamContent = firstSlice
256
+ } catch (err) {
257
+ this.logger.warn?.('[dsh-bridge telegram] sendText stream initial failed:', err?.message ?? err)
258
+ await this.gateway.sendText(peerId, content).catch(() => {})
259
+ return
260
+ }
261
+
262
+ // 后续片依次 editMessageText(间隔 delayMs,呈现平滑打字机流式效果)
263
+ for (let i = 1; i < slices.length; i++) {
264
+ await sleep(delayMs)
265
+ this._streamContent = slices[i]
266
+ if (this._streamMsgId) {
267
+ await this.gateway.editMessageText(peerId, this._streamMsgId, this._streamContent).catch(() => {})
268
+ }
269
+ }
270
+ } else {
271
+ // 消息已存在(同轮次后续输出)
272
+ if (content.length <= 4000) {
273
+ this._streamContent = content
274
+ await this.gateway.editMessageText(peerId, this._streamMsgId, this._streamContent).catch(() => {})
275
+ } else {
276
+ // 超出 4000 字符,另起一条新消息继续流式
277
+ const res = await this.gateway.sendText(peerId, content).catch(() => null)
278
+ this._streamMsgId = res?.message_id || null
279
+ this._streamContent = content
280
+ }
281
+ }
282
+ return
283
+ }
284
+
285
+ // 非 turn 期间(指令响应、系统提示、单条通知等):
286
+ // 若命中帮助/状态/会话列表等特定系统命令响应,挂载原生快捷交互按钮
287
+ if (content.includes('命令帮助') || content.includes('/new <提示词>')) {
288
+ const buttons = [
289
+ [
290
+ { text: '📋 会话列表', callback_data: 'cmd:sessions' },
291
+ { text: '📁 工作区', callback_data: 'cmd:workspaces' },
292
+ ],
293
+ [
294
+ { text: '📊 运行状态', callback_data: 'cmd:status' },
295
+ { text: '⏹ 停止任务', callback_data: 'cmd:stop' },
296
+ ],
297
+ ]
298
+ return this.gateway.sendKeyboard(peerId, content, buttons)
299
+ }
300
+
301
+ if (content.includes('Agent 状态') || content.includes('无活动会话')) {
302
+ const buttons = [
303
+ [
304
+ { text: '🔄 刷新状态', callback_data: 'cmd:status' },
305
+ { text: '⏹ 停止任务', callback_data: 'cmd:stop' },
306
+ ],
307
+ [
308
+ { text: '📋 会话列表', callback_data: 'cmd:sessions' },
309
+ { text: '🚪 结束会话', callback_data: 'cmd:end' },
310
+ ],
311
+ ]
312
+ return this.gateway.sendKeyboard(peerId, content, buttons)
313
+ }
314
+
315
+ if (content.includes('会话列表') || content.includes('可用会话')) {
316
+ const buttons = [
317
+ [
318
+ { text: '📁 可用工作区', callback_data: 'cmd:workspaces' },
319
+ { text: '📊 查看状态', callback_data: 'cmd:status' },
320
+ ],
321
+ ]
322
+ return this.gateway.sendKeyboard(peerId, content, buttons)
323
+ }
324
+
325
+ if (content.length <= 4000) {
326
+ return this.gateway.sendText(peerId, content)
327
+ }
328
+ const chunks = conversationBridgeHelpers.splitForIM(content, 4000)
329
+ for (const chunk of chunks) {
330
+ await this.gateway.sendText(peerId, chunk)
331
+ }
332
+ }
333
+
334
+ async sendTyping() {
335
+ const peerId = this._lastPeer?.chatId || this.peerId
336
+ if (!peerId || !this.gateway) return
337
+ return this.gateway.sendTyping(peerId)
338
+ }
339
+
340
+ dispose() {
341
+ this._inTurn = false
342
+ this._streamMsgId = null
343
+ this._streamContent = ''
344
+ this._lastPeer = null
345
+ super.dispose()
346
+ }
347
+ }
348
+
351
349
  export const telegramNodeHelpers = { makePlatform }