dsh-bridge-gateway 0.1.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 (68) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +408 -0
  4. package/README.md +430 -0
  5. package/client/build.mjs +43 -0
  6. package/client/client.js +5209 -0
  7. package/client/index.js +4967 -0
  8. package/cordis.patch.yml +4 -0
  9. package/docs/banner.jpg +0 -0
  10. package/docs/custom-tunnel.md +220 -0
  11. package/docs/feishu-usage.md +116 -0
  12. package/docs/fix-tunnel-sse-and-session-list.md +134 -0
  13. package/docs/platform-abstraction-design.md +473 -0
  14. package/docs/qq-usage.md +387 -0
  15. package/docs/screenshots/admin-lock-screen.jpg +0 -0
  16. package/docs/screenshots/feishu-bot-config.jpg +0 -0
  17. package/docs/screenshots/feishu-chat.jpg +0 -0
  18. package/docs/screenshots/lan-access.jpg +0 -0
  19. package/docs/screenshots/mobile-chat.jpg +0 -0
  20. package/docs/screenshots/mobile-drawer.jpg +0 -0
  21. package/docs/screenshots/mobile-remote-settings.jpg +0 -0
  22. package/docs/screenshots/mobile-settings-im.jpg +0 -0
  23. package/docs/screenshots/mobile-settings-lan.jpg +0 -0
  24. package/docs/screenshots/mobile-settings-security.jpg +0 -0
  25. package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
  26. package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
  27. package/docs/screenshots/qq-bot-config.jpg +0 -0
  28. package/docs/screenshots/qq-chat.jpg +0 -0
  29. package/docs/screenshots/qq-group.jpg +0 -0
  30. package/docs/screenshots/qr-scan.jpg +0 -0
  31. package/docs/screenshots/remote-auth-login.jpg +0 -0
  32. package/docs/screenshots/remote-web-mobile.jpg +0 -0
  33. package/docs/screenshots/security-auth-config.jpg +0 -0
  34. package/docs/screenshots/telegram-bot-config.jpg +0 -0
  35. package/docs/screenshots/tunnel-access.jpg +0 -0
  36. package/docs/screenshots/wechat-bot-config.jpg +0 -0
  37. package/docs/screenshots/wechat-chat.jpg +0 -0
  38. package/docs/telegram-usage.md +93 -0
  39. package/docs/wechat-usage.md +104 -0
  40. package/lib/auth/login-template.js +382 -0
  41. package/lib/auth/manager.js +470 -0
  42. package/lib/bridge-rpc-constants.js +55 -0
  43. package/lib/bridge-rpc.js +553 -0
  44. package/lib/cloudflared-manager.mjs +345 -0
  45. package/lib/feishu/gateway.js +550 -0
  46. package/lib/feishu/index.js +222 -0
  47. package/lib/feishu/lark-bundled.mjs +125547 -0
  48. package/lib/feishu/node.js +409 -0
  49. package/lib/gateway/cert.js +214 -0
  50. package/lib/index.js +1963 -0
  51. package/lib/platform/base.js +156 -0
  52. package/lib/platform/conversation-bridge.js +1570 -0
  53. package/lib/platform/index.js +10 -0
  54. package/lib/platform/manager.js +74 -0
  55. package/lib/qq/gateway.js +616 -0
  56. package/lib/qq/index.js +309 -0
  57. package/lib/qq/node.js +533 -0
  58. package/lib/security/path-validator.js +208 -0
  59. package/lib/security/rate-limiter.js +93 -0
  60. package/lib/telegram/gateway.js +630 -0
  61. package/lib/telegram/index.js +213 -0
  62. package/lib/telegram/node.js +351 -0
  63. package/lib/tunnel-client.mjs +402 -0
  64. package/lib/wechat/gateway.js +960 -0
  65. package/lib/wechat/index.js +241 -0
  66. package/lib/wechat/media.js +281 -0
  67. package/lib/wechat/node.js +350 -0
  68. package/package.json +102 -0
@@ -0,0 +1,213 @@
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 { TelegramGateway } from './gateway.js'
8
+ import { TelegramConversationNode } from './node.js'
9
+
10
+ export class TelegramService extends Platform {
11
+ /**
12
+ * @param {object} opts
13
+ * @param {object} opts.ctx Cordis 上下文
14
+ * @param {object} opts.logger 日志器
15
+ * @param {object} [opts.config] 已持久化的 telegram 配置(凭证 + allowFrom + 间隔)
16
+ * @param {(patch: object) => (void|Promise<void>)} opts.onPersist 主插件保存回调
17
+ */
18
+ constructor({ ctx, logger, config = {}, onPersist }) {
19
+ super({ ctx, logger, config, onPersist })
20
+ this.id = 'telegram'
21
+ this.name = 'Telegram'
22
+ this._botQrCache = { username: '', qr: '' }
23
+
24
+ this.gateway = new TelegramGateway(ctx, {
25
+ botToken: config.botToken ?? '',
26
+ proxy: config.proxy ?? '',
27
+ })
28
+
29
+ // 挂到 ctx 供会话节点读取
30
+ try { ctx.telegram = this.gateway } catch {}
31
+
32
+ this.node = new TelegramConversationNode(ctx, {
33
+ allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
34
+ digestIntervalSec: config.digestIntervalSec,
35
+ approvalTimeoutSec: config.approvalTimeoutSec,
36
+ maxMessageChars: config.maxMessageChars || 4096,
37
+ sendChunkDelayMs: config.sendChunkDelayMs,
38
+ activeSessionId: config.activeSessionId,
39
+ }, logger, {
40
+ onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
41
+ onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }),
42
+ })
43
+ this.bridge = this.node
44
+
45
+ if (this.gateway.configured) {
46
+ void this.start().catch((err) => {
47
+ this.logger.error?.('[dsh-bridge telegram] start failed:', err?.message ?? err)
48
+ })
49
+ }
50
+ }
51
+
52
+ // ---- Platform 接口 ----
53
+
54
+ get configured() { return this.gateway.configured }
55
+ get accountId() { return this.gateway.accountId }
56
+
57
+ get capabilities() {
58
+ return {
59
+ group: true,
60
+ media: true,
61
+ approvals: true,
62
+ maxMessageChars: this.node.config.maxMessageChars || 4096,
63
+ }
64
+ }
65
+
66
+ async sendText(peerId, text, opts = {}) {
67
+ return this.gateway.sendText(peerId, text, opts)
68
+ }
69
+
70
+ async sendTyping(peerId, opts = {}) {
71
+ return this.gateway.sendTyping?.(peerId, opts)
72
+ }
73
+
74
+ // ---- 生命周期控制 ----
75
+
76
+ async start() {
77
+ if (!this.gateway.configured) {
78
+ this.setStatus('idle')
79
+ return { success: false, error: 'Telegram Bot Token 未配置' }
80
+ }
81
+ this.setStatus('starting')
82
+ const ok = await this.gateway.start()
83
+ if (ok) {
84
+ this.setStatus('connected')
85
+ return { success: true }
86
+ } else {
87
+ this.setStatus('error', '连接 Telegram API 失败,请检查 Bot Token 或网络代理')
88
+ return { success: false, error: '连接失败' }
89
+ }
90
+ }
91
+
92
+ async stop() {
93
+ await this.gateway.stop()
94
+ this.setStatus('offline')
95
+ return { success: true }
96
+ }
97
+
98
+ /**
99
+ * 配置或登录
100
+ * @param {object} creds - { botToken, proxy }
101
+ */
102
+ async login(creds = {}) {
103
+ const patch = {}
104
+ if (creds.botToken !== undefined) patch.botToken = String(creds.botToken).trim()
105
+ if (creds.proxy !== undefined) patch.proxy = String(creds.proxy).trim()
106
+
107
+ this.gateway.setCredentials(patch)
108
+ this.persist(patch)
109
+
110
+ if (!this.gateway.configured) {
111
+ await this.stop()
112
+ return { success: false, error: '请填写完整的 Telegram Bot Token' }
113
+ }
114
+
115
+ return this.start()
116
+ }
117
+
118
+ async unbind() {
119
+ await this.stop()
120
+ this.gateway.setCredentials({ botToken: '', proxy: '' })
121
+ this.persist({ botToken: '', proxy: '', allowFrom: [] })
122
+ if (this.node) this.node.config.allowFrom = []
123
+ return { success: true }
124
+ }
125
+
126
+ getStatus() {
127
+ const allowFrom = [...(this.node?.config?.allowFrom ?? [])]
128
+ const username = this.gateway.botInfo?.username || ''
129
+ const botLink = username ? `https://t.me/${encodeURIComponent(username)}` : null
130
+ if (botLink && this._botQrCache.username !== username) {
131
+ this._botQrCache.username = username
132
+ void QRCode.toDataURL(botLink, {
133
+ width: 260,
134
+ margin: 2,
135
+ color: { dark: '#1F2421', light: '#FFFFFF' },
136
+ }).then((qr) => {
137
+ this._botQrCache.qr = qr
138
+ }).catch(() => {})
139
+ }
140
+
141
+ return {
142
+ id: this.id,
143
+ name: this.name,
144
+ status: this.status === 'connected' ? 'connected' : this.gateway.status,
145
+ configured: this.gateway.configured,
146
+ accountId: this.gateway.accountId || (this.gateway.configured ? '已配置 Token' : ''),
147
+ allowFrom,
148
+ peerId: this.node?.peerId,
149
+ sessionId: this.node?.activeSessionId,
150
+ login: {
151
+ phase: this.status === 'connected' ? 'done' : this.status === 'error' ? 'error' : 'idle',
152
+ },
153
+ capabilities: { ...this.capabilities },
154
+ config: {
155
+ digestIntervalSec: this.node?.config?.digestIntervalSec,
156
+ approvalTimeoutSec: this.node?.config?.approvalTimeoutSec,
157
+ maxMessageChars: this.node?.config?.maxMessageChars,
158
+ sendChunkDelayMs: this.node?.config?.sendChunkDelayMs,
159
+ botToken: this.gateway.config.botToken ? '******' : '',
160
+ proxy: this.gateway.config.proxy || '',
161
+ },
162
+ botInfo: this.gateway.botInfo,
163
+ botLink,
164
+ botQr: this._botQrCache.qr,
165
+ }
166
+ }
167
+
168
+ setAllowFrom(allowFrom) {
169
+ const list = Array.isArray(allowFrom) ? allowFrom.map((s) => String(s).trim()).filter(Boolean) : []
170
+ if (this.node) this.node.config.allowFrom = list
171
+ this.persist({ allowFrom: list })
172
+ return { success: true, allowFrom: list }
173
+ }
174
+
175
+ async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, botToken, proxy } = {}) {
176
+ if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
177
+ if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
178
+ if (maxMessageChars != null) {
179
+ const val = Number(maxMessageChars)
180
+ this.node.config.maxMessageChars = (val >= 200) ? val : 4096
181
+ }
182
+ if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
183
+
184
+ if (botToken !== undefined || proxy !== undefined) {
185
+ const patch = {}
186
+ if (botToken !== undefined) patch.botToken = botToken.trim()
187
+ if (proxy !== undefined) patch.proxy = proxy.trim()
188
+ this.gateway.setCredentials(patch)
189
+ }
190
+
191
+ const patch = {
192
+ digestIntervalSec: this.node.config.digestIntervalSec,
193
+ approvalTimeoutSec: this.node.config.approvalTimeoutSec,
194
+ maxMessageChars: this.node.config.maxMessageChars,
195
+ sendChunkDelayMs: this.node.config.sendChunkDelayMs,
196
+ }
197
+ if (botToken !== undefined) patch.botToken = this.gateway.config.botToken
198
+ if (proxy !== undefined) patch.proxy = this.gateway.config.proxy
199
+ await this.persist(patch)
200
+
201
+ if (this.gateway.configured && (botToken !== undefined || proxy !== undefined)) {
202
+ await this.start().catch((err) => {
203
+ this.logger?.warn?.('[dsh-bridge telegram] auto-start failed: %s', err?.message ?? err)
204
+ })
205
+ }
206
+ return { success: true }
207
+ }
208
+
209
+ dispose() {
210
+ super.dispose()
211
+ this.gateway?.dispose?.()
212
+ }
213
+ }
@@ -0,0 +1,351 @@
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
+
351
+ export const telegramNodeHelpers = { makePlatform }