@wenbin_wb/dsh-bridge 2.3.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
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) this.node.config.maxMessageChars = Number(maxMessageChars)
179
+ if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
180
+
181
+ if (botToken !== undefined || proxy !== undefined) {
182
+ const patch = {}
183
+ if (botToken !== undefined) patch.botToken = botToken.trim()
184
+ if (proxy !== undefined) patch.proxy = proxy.trim()
185
+ this.gateway.setCredentials(patch)
186
+ }
187
+
188
+ const patch = {
189
+ digestIntervalSec: this.node.config.digestIntervalSec,
190
+ approvalTimeoutSec: this.node.config.approvalTimeoutSec,
191
+ maxMessageChars: this.node.config.maxMessageChars,
192
+ sendChunkDelayMs: this.node.config.sendChunkDelayMs,
193
+ }
194
+ if (botToken !== undefined) patch.botToken = this.gateway.config.botToken
195
+ if (proxy !== undefined) patch.proxy = this.gateway.config.proxy
196
+ await this.persist(patch)
197
+
198
+ if (this.gateway.configured && (botToken !== undefined || proxy !== undefined)) {
199
+ await this.start().catch((err) => {
200
+ this.logger?.warn?.('[dsh-bridge telegram] auto-start failed: %s', err?.message ?? err)
201
+ })
202
+ }
203
+ return { success: true }
204
+ }
205
+
206
+ dispose() {
207
+ super.dispose()
208
+ this.gateway?.dispose?.()
209
+ }
210
+ }
@@ -0,0 +1,342 @@
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.ctx.on('telegram/message', (event) => this._handleInbound(event))
72
+
73
+ // 订阅 Inline 按钮点击交互事件(审批确认)
74
+ this.ctx.on('telegram/action', (event) => this._handleAction(event))
75
+
76
+ // 监听轮次事件:turn/start 开启流式打字机,turn/end 最终刷新并重置
77
+ 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
+ const { chatId, senderId, senderUsername, isGroup, text, messageId, raw } = event
99
+ this._lastPeer = { chatId, senderId, senderUsername, isGroup }
100
+ const authId = isGroup ? chatId : senderId
101
+
102
+ // 检查是否有富媒体附件(图片 / 文件 / 语音 / 音频)
103
+ let mediaFiles = []
104
+ if (raw) {
105
+ mediaFiles = await this._processInboundMedia(raw, this.config.cwd)
106
+ }
107
+
108
+ if (!text && mediaFiles.length === 0) {
109
+ this.logger.debug?.(`[dsh-bridge telegram] ignore empty message from ${chatId}`)
110
+ return
111
+ }
112
+
113
+ let fullText = text || ''
114
+ if (mediaFiles.length > 0) {
115
+ const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
116
+ fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
117
+ }
118
+
119
+ this.logger.debug?.(`[dsh-bridge telegram] handling inbound message from ${chatId} (group=${isGroup}): ${fullText}`)
120
+
121
+ // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
122
+ if (!this.activeSessionId) {
123
+ await this._pickDefaultSession().catch(() => {})
124
+ }
125
+
126
+ // 转交通用 ConversationBridge 路由
127
+ return this.handleInbound({
128
+ senderId: authId,
129
+ peerId: chatId,
130
+ isGroup,
131
+ text: fullText,
132
+ })
133
+ }
134
+
135
+ async _processInboundMedia(rawMsg, sessionCwd) {
136
+ if (!this.gateway) return []
137
+ const downloaded = []
138
+
139
+ try {
140
+ // 1. 照片(选择最高分辨率的一张)
141
+ if (Array.isArray(rawMsg.photo) && rawMsg.photo.length > 0) {
142
+ const bestPhoto = rawMsg.photo[rawMsg.photo.length - 1]
143
+ if (bestPhoto?.file_id) {
144
+ const res = await this.gateway.downloadFile(bestPhoto.file_id, sessionCwd)
145
+ if (res) downloaded.push(res)
146
+ }
147
+ }
148
+ // 2. 文档 / 文件
149
+ else if (rawMsg.document?.file_id) {
150
+ const res = await this.gateway.downloadFile(rawMsg.document.file_id, sessionCwd)
151
+ if (res) downloaded.push(res)
152
+ }
153
+ // 3. 语音 / 音频
154
+ else if (rawMsg.voice?.file_id) {
155
+ const res = await this.gateway.downloadFile(rawMsg.voice.file_id, sessionCwd)
156
+ if (res) downloaded.push(res)
157
+ } else if (rawMsg.audio?.file_id) {
158
+ const res = await this.gateway.downloadFile(rawMsg.audio.file_id, sessionCwd)
159
+ if (res) downloaded.push(res)
160
+ }
161
+ } catch (err) {
162
+ this.logger.warn?.(`[dsh-bridge telegram] process media error: ${err.message}`)
163
+ }
164
+
165
+ return downloaded
166
+ }
167
+
168
+ async _handleAction(event) {
169
+ const { queryId, chatId, operatorId, data } = event
170
+ if (!data || !this.gateway) return
171
+
172
+ // 1. 审批决议按钮 (approve:ID / reject:ID)
173
+ const match = /^([a-zA-Z]+):(\d+)$/.exec(data)
174
+ if (match) {
175
+ const [, action, approvalIdStr] = match
176
+ const approvalId = Number(approvalIdStr)
177
+ const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
178
+ const pending = this.pending.get(approvalId)
179
+
180
+ if (pending) {
181
+ this.clearApproval(approvalId)
182
+ pending.resolve(outcome)
183
+ await this.gateway?.answerCallbackQuery(
184
+ queryId,
185
+ action === 'approve' ? '✓ 操作已批准' : '✕ 操作已拒绝',
186
+ false,
187
+ )
188
+ } else {
189
+ await this.gateway?.answerCallbackQuery(queryId, '⚠️ 该审批已处理或已过期', false)
190
+ }
191
+ return
192
+ }
193
+
194
+ // 2. 快捷指令交互按键 (cmd:xxx)
195
+ if (data.startsWith('cmd:')) {
196
+ const cmdBody = data.slice(4)
197
+ await this.gateway?.answerCallbackQuery(queryId, `⚡ 执行: /${cmdBody.replace(':', ' ')}`, false)
198
+ let text = `/${cmdBody.replace(':', ' ')}`
199
+ if (cmdBody.startsWith('use:')) {
200
+ text = `/use ${cmdBody.slice(4)}`
201
+ }
202
+ return this.handleInbound({
203
+ senderId: operatorId || chatId,
204
+ peerId: chatId,
205
+ isGroup: false,
206
+ text,
207
+ })
208
+ }
209
+ }
210
+
211
+ async sendApprovalCard(approvalId, request) {
212
+ const peerId = this._lastPeer?.chatId || this.peerId
213
+ if (!peerId || !this.gateway) return
214
+
215
+ const toolName = request?.name || request?.tool || '系统操作'
216
+ const desc = request?.description || request?.summary || ''
217
+ const command = request?.command || request?.cmd || ''
218
+
219
+ const lines = [
220
+ `⚠️ **操作权限确认** (ID: <code>${approvalId}</code>)`,
221
+ '',
222
+ `• **工具**:<code>${toolName}</code>`,
223
+ ]
224
+ if (desc) lines.push(`• **说明**:${desc}`)
225
+ if (command) lines.push(`• **命令**:<code>${command}</code>`)
226
+ lines.push('', '请选择审批决议(亦可直接输入 <code>1</code> 批准,<code>2</code> 拒绝):')
227
+
228
+ const buttons = [
229
+ [
230
+ { text: '✓ 批准执行', callback_data: `approve:${approvalId}` },
231
+ { text: '✕ 拒绝执行', callback_data: `reject:${approvalId}` },
232
+ ],
233
+ ]
234
+
235
+ return this.gateway.sendKeyboard(peerId, lines.join('\n'), buttons)
236
+ }
237
+
238
+ async sendText(text) {
239
+ const peerId = this._lastPeer?.chatId || this.peerId
240
+ if (!peerId || !this.gateway) return
241
+ const content = String(text || '').trim()
242
+ if (!content) return
243
+
244
+ // 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
245
+ if (this._inTurn) {
246
+ const maxChunk = 200
247
+ const slices = splitIntoIncremental(content, maxChunk)
248
+ const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 800, 400), 2000)
249
+
250
+ if (!this._streamMsgId) {
251
+ // 首片:创建消息并记录 message_id
252
+ const firstSlice = slices[0] || content
253
+ try {
254
+ const res = await this.gateway.sendText(peerId, firstSlice)
255
+ this._streamMsgId = res?.message_id || null
256
+ this._streamContent = firstSlice
257
+ } catch (err) {
258
+ this.logger.warn?.('[dsh-bridge telegram] sendText stream initial failed:', err?.message ?? err)
259
+ await this.gateway.sendText(peerId, content).catch(() => {})
260
+ return
261
+ }
262
+
263
+ // 后续片依次 editMessageText(间隔 delayMs,呈现平滑打字机流式效果)
264
+ for (let i = 1; i < slices.length; i++) {
265
+ await sleep(delayMs)
266
+ this._streamContent = slices[i]
267
+ if (this._streamMsgId) {
268
+ await this.gateway.editMessageText(peerId, this._streamMsgId, this._streamContent).catch(() => {})
269
+ }
270
+ }
271
+ } else {
272
+ // 消息已存在(同轮次后续输出)
273
+ if (content.length <= 4000) {
274
+ this._streamContent = content
275
+ await this.gateway.editMessageText(peerId, this._streamMsgId, this._streamContent).catch(() => {})
276
+ } else {
277
+ // 超出 4000 字符,另起一条新消息继续流式
278
+ const res = await this.gateway.sendText(peerId, content).catch(() => null)
279
+ this._streamMsgId = res?.message_id || null
280
+ this._streamContent = content
281
+ }
282
+ }
283
+ return
284
+ }
285
+
286
+ // 非 turn 期间(指令响应、系统提示、单条通知等):
287
+ // 若命中帮助/状态/会话列表等特定系统命令响应,挂载原生快捷交互按钮
288
+ if (content.includes('命令帮助') || content.includes('/new <提示词>')) {
289
+ const buttons = [
290
+ [
291
+ { text: '📋 会话列表', callback_data: 'cmd:sessions' },
292
+ { text: '📁 工作区', callback_data: 'cmd:workspaces' },
293
+ ],
294
+ [
295
+ { text: '📊 运行状态', callback_data: 'cmd:status' },
296
+ { text: '⏹ 停止任务', callback_data: 'cmd:stop' },
297
+ ],
298
+ ]
299
+ return this.gateway.sendKeyboard(peerId, content, buttons)
300
+ }
301
+
302
+ if (content.includes('Agent 状态') || content.includes('无活动会话')) {
303
+ const buttons = [
304
+ [
305
+ { text: '🔄 刷新状态', callback_data: 'cmd:status' },
306
+ { text: '⏹ 停止任务', callback_data: 'cmd:stop' },
307
+ ],
308
+ [
309
+ { text: '📋 会话列表', callback_data: 'cmd:sessions' },
310
+ { text: '🚪 结束会话', callback_data: 'cmd:end' },
311
+ ],
312
+ ]
313
+ return this.gateway.sendKeyboard(peerId, content, buttons)
314
+ }
315
+
316
+ if (content.includes('会话列表') || content.includes('可用会话')) {
317
+ const buttons = [
318
+ [
319
+ { text: '📁 可用工作区', callback_data: 'cmd:workspaces' },
320
+ { text: '📊 查看状态', callback_data: 'cmd:status' },
321
+ ],
322
+ ]
323
+ return this.gateway.sendKeyboard(peerId, content, buttons)
324
+ }
325
+
326
+ if (content.length <= 4000) {
327
+ return this.gateway.sendText(peerId, content)
328
+ }
329
+ const chunks = conversationBridgeHelpers.splitForIM(content, 4000)
330
+ for (const chunk of chunks) {
331
+ await this.gateway.sendText(peerId, chunk)
332
+ }
333
+ }
334
+
335
+ async sendTyping() {
336
+ const peerId = this._lastPeer?.chatId || this.peerId
337
+ if (!peerId || !this.gateway) return
338
+ return this.gateway.sendTyping(peerId)
339
+ }
340
+ }
341
+
342
+ export const telegramNodeHelpers = { makePlatform }
@@ -14,8 +14,11 @@
14
14
  // 依赖注入:通过 `ctx.wechat` 服务提供(sendText/sendTyping/accountId/status),
15
15
  // 并通过 ctx 事件 'wechat/message' / 'wechat/status' 派发。runInService 由主插件调用。
16
16
 
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
17
19
  import { randomBytes } from 'node:crypto'
18
20
  import { Service } from '@deepseek-ai/cordis'
21
+ import { uploadMedia, md5, generateFilekey, generateAesKey, encodeAesKeyForApi, aes128PaddedSize } from './media.js'
19
22
 
20
23
  // ---------------------------------------------------------------------------
21
24
  // 常量
@@ -630,6 +633,57 @@ export class WechatGateway extends Service {
630
633
  }
631
634
  }
632
635
 
636
+ /**
637
+ * 加密并发送本地媒体文件(图片/文档)到微信
638
+ */
639
+ async sendMediaFile(to, filePath) {
640
+ if (!this.configured || !fs.existsSync(filePath)) return { success: false, error: 'not configured or file not found' }
641
+ try {
642
+ const buf = await fs.promises.readFile(filePath)
643
+ const ext = path.extname(filePath).toLowerCase()
644
+ const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
645
+ const mediaType = isImage ? 2 : 4
646
+ const filename = path.basename(filePath)
647
+ const rawFileMd5 = md5(buf)
648
+ const aesKey = generateAesKey()
649
+ const aesKeyHex = aesKey.toString('hex')
650
+ const aesKeyBase64 = encodeAesKeyForApi(aesKey)
651
+ const filekey = generateFilekey()
652
+ const rawSize = buf.length
653
+ const fileSize = aes128PaddedSize(rawSize)
654
+
655
+ const uploadInfo = await this.getUploadUrl({
656
+ to,
657
+ filekey,
658
+ mediaType,
659
+ rawSize,
660
+ rawFileMd5,
661
+ fileSize,
662
+ aesKeyHex,
663
+ })
664
+
665
+ const encryptedParam = await uploadMedia({
666
+ plaintext: buf,
667
+ uploadUrl: uploadInfo.uploadFullUrl || `${WEIXIN_CDN_BASE_URL}?upload_param=${encodeURIComponent(uploadInfo.uploadParam)}`,
668
+ aesKey,
669
+ })
670
+
671
+ return await this.sendMedia({
672
+ to,
673
+ mediaType,
674
+ encryptedQueryParam: encryptedParam,
675
+ aesKeyBase64,
676
+ ciphertextSize: fileSize,
677
+ plaintextSize: rawSize,
678
+ filename,
679
+ rawFileMd5,
680
+ })
681
+ } catch (err) {
682
+ this.logger?.warn?.('[dsh-bridge wechat] sendMediaFile failed: %s', err?.message ?? err)
683
+ return { success: false, error: err?.message }
684
+ }
685
+ }
686
+
633
687
  /** 显示/隐藏 typing 指示(尽力而为,失败不致命)。 */
634
688
  async sendTyping(to, status) {
635
689
  if (!this.configured) return
@@ -35,6 +35,7 @@ function makePlatform(ctx) {
35
35
  }
36
36
  },
37
37
  sendText: (peer, text) => ctx.wechat.sendText(peer, text),
38
+ sendMediaFile: (peer, filePath) => ctx.wechat.sendMediaFile(peer, filePath),
38
39
  sendTyping: (peer, state) => ctx.wechat.sendTyping(peer, state),
39
40
  }
40
41
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.3.2",
4
- "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
3
+ "version": "2.4.0",
4
+ "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -22,7 +22,8 @@
22
22
  "docs/custom-tunnel.md",
23
23
  "docs/wechat-usage.md",
24
24
  "docs/qq-usage.md",
25
- "docs/feishu-usage.md"
25
+ "docs/feishu-usage.md",
26
+ "docs/telegram-usage.md"
26
27
  ],
27
28
  "scripts": {
28
29
  "build:client": "node client/build.mjs",