@qzsy/dsh-bridge 2.18.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +15 -1094
  2. package/LICENSE +21 -21
  3. package/README.md +73 -476
  4. package/client/build.mjs +1 -3
  5. package/client/client.js +381 -9747
  6. package/client/index.js +242 -6828
  7. package/client/locales.mjs +99 -233
  8. package/cordis.patch.yml +5 -3
  9. package/lib/config.js +93 -0
  10. package/lib/data-dir.js +16 -0
  11. package/lib/dsh-cookie.js +111 -0
  12. package/lib/gate.js +414 -0
  13. package/lib/identity.js +23 -0
  14. package/lib/index.js +303 -3496
  15. package/lib/saas/tunnel-client.mjs +529 -467
  16. package/locale/en.json +3 -3
  17. package/locale/zh.json +3 -3
  18. package/package.json +15 -70
  19. package/README.en.md +0 -488
  20. package/client/host-families.js +0 -442
  21. package/client/mobile-styles.js +0 -1299
  22. package/client/mobile-yield.js +0 -219
  23. package/client/panel-locales.mjs +0 -777
  24. package/client/picker-yield.js +0 -77
  25. package/client/resource-url-compat.js +0 -90
  26. package/client/settings-nav-icon.js +0 -105
  27. package/client/theme-tokens.js +0 -49
  28. package/client/tunnel-guard.js +0 -46
  29. package/client/unlock-manager.js +0 -144
  30. package/lib/auth/dsh-native-cookie.js +0 -148
  31. package/lib/auth/login-template.js +0 -516
  32. package/lib/auth/manager.js +0 -596
  33. package/lib/bridge-rpc-constants.js +0 -66
  34. package/lib/bridge-rpc.js +0 -722
  35. package/lib/cloudflared-manager.mjs +0 -1277
  36. package/lib/cloudflared-network-args.js +0 -99
  37. package/lib/cloudflared-process-scan.js +0 -153
  38. package/lib/compat.js +0 -172
  39. package/lib/connection-compat.js +0 -115
  40. package/lib/feishu/comment.js +0 -50
  41. package/lib/feishu/gateway.js +0 -644
  42. package/lib/feishu/index.js +0 -432
  43. package/lib/feishu/lark-bundled.mjs +0 -131929
  44. package/lib/feishu/markdown.js +0 -148
  45. package/lib/feishu/node.js +0 -1152
  46. package/lib/feishu/slash-commands.js +0 -255
  47. package/lib/platform/account-ops.js +0 -248
  48. package/lib/platform/activity.js +0 -169
  49. package/lib/platform/ask-user.js +0 -293
  50. package/lib/platform/base.js +0 -147
  51. package/lib/platform/changes-digest.js +0 -93
  52. package/lib/platform/commands.js +0 -267
  53. package/lib/platform/config-restore.js +0 -78
  54. package/lib/platform/conversation-bridge.js +0 -1544
  55. package/lib/platform/dsh-storage.js +0 -222
  56. package/lib/platform/index.js +0 -10
  57. package/lib/platform/instances.js +0 -458
  58. package/lib/platform/manager.js +0 -130
  59. package/lib/platform/message-split.js +0 -229
  60. package/lib/platform/session-catalog.js +0 -405
  61. package/lib/platform/session-config.js +0 -52
  62. package/lib/platform/stream-slices.js +0 -21
  63. package/lib/qq/gateway.js +0 -1107
  64. package/lib/qq/index.js +0 -561
  65. package/lib/qq/node.js +0 -1153
  66. package/lib/qq/qqbot-bundled.mjs +0 -8273
  67. package/lib/restart-helper.mjs +0 -133
  68. package/lib/security/log-sanitize.js +0 -50
  69. package/lib/security/path-validator.js +0 -195
  70. package/lib/security/rate-limiter.js +0 -93
  71. package/lib/session-strip.js +0 -100
  72. package/lib/sse-keepalive.js +0 -115
  73. package/lib/telegram/gateway.js +0 -643
  74. package/lib/telegram/index.js +0 -255
  75. package/lib/telegram/node.js +0 -410
  76. package/lib/tunnel-client.mjs +0 -537
  77. package/lib/wechat/gateway.js +0 -1000
  78. package/lib/wechat/index.js +0 -282
  79. package/lib/wechat/media.js +0 -302
  80. package/lib/wechat/node.js +0 -360
  81. package/llms.txt +0 -36
@@ -1,1000 +0,0 @@
1
- // dsh-bridge WeChat iLink gateway
2
- //
3
- // 微信 ClawBot(iLink Bot API)网关:扫码登录 + 长轮询收消息 + 发送 + typing。
4
- // 由 Jesse-njx/dsh-chatnode-wechat(MIT)移植精简而来,协议细节与 hermes-agent
5
- // 微信通道(gateway/platforms/weixin.py)一致。纯拉取式 outbound 连接,无需公网/隧道。
6
- //
7
- // 架构约束(决定本文件形态):
8
- // - 独占锁:iLink 每条 bot token 只允许一个 poller;第二个 poller(hermes / OpenClaw /
9
- // 本插件重复)收到 HTTP 403。检测到 403 时响亮报错并停止轮询,而不是无限重试。
10
- // - context_token:每次回复必须回带 peer 提供的最新 token;过期 token 返回 -14
11
- // (会话过期),随后做一次无 token 降级重试。
12
- // - 会话过期(-14 或 -2+"unknown error")暂停轮询一段窗口,与 hermes 参考一致。
13
- //
14
- // 依赖注入:通过 `ctx.wechat` 服务提供(sendText/sendTyping/accountId/status),
15
- // 并通过 ctx 事件 'wechat/message' / 'wechat/status' 派发。runInService 由主插件调用。
16
-
17
- import fs from 'node:fs'
18
- import path from 'node:path'
19
- import { randomBytes } from 'node:crypto'
20
- import { Service } from '@deepseek-ai/cordis'
21
- import { uploadMedia, md5, generateFilekey, generateAesKey, encodeAesKeyForApi, aes128PaddedSize, stripTrailingSlashes } from './media.js'
22
-
23
- // ---------------------------------------------------------------------------
24
- // 常量
25
- // ---------------------------------------------------------------------------
26
-
27
- const ILINK_BASE_URL = 'https://ilinkai.weixin.qq.com'
28
- const WEIXIN_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
29
- const ILINK_APP_ID = 'bot'
30
- const CHANNEL_VERSION = '2.2.0'
31
- const ILINK_APP_CLIENT_VERSION = (2 << 16) | (2 << 8) | 0
32
-
33
- const EP_GET_UPDATES = 'ilink/bot/getupdates'
34
- const EP_SEND_MESSAGE = 'ilink/bot/sendmessage'
35
- const EP_SEND_TYPING = 'ilink/bot/sendtyping'
36
- const EP_GET_CONFIG = 'ilink/bot/getconfig'
37
- const EP_GET_BOT_QR = 'ilink/bot/get_bot_qrcode'
38
- const EP_GET_QR_STATUS = 'ilink/bot/get_qrcode_status'
39
-
40
- const LONG_POLL_TIMEOUT_MS = 35_000
41
- const API_TIMEOUT_MS = 15_000
42
- const CONFIG_TIMEOUT_MS = 10_000
43
- const QR_TIMEOUT_MS = 35_000
44
- const MAX_MESSAGE_CHARS = 2000
45
-
46
- const MSG_TYPE_BOT = 2
47
- const MSG_STATE_FINISH = 2
48
- const ITEM_TEXT = 1
49
-
50
- const TYPING_START = 1
51
- const TYPING_STOP = 2
52
-
53
- const SESSION_EXPIRED_ERRCODE = -14
54
- const RATE_LIMIT_ERRCODE = -2
55
- const MESSAGE_DEDUP_TTL_SECONDS = 300
56
-
57
- /** ret/errcode=-2 + "unknown error" 或 "prepare failed" 表示会话/凭证过期(而非限流)。 */
58
- function isStaleSessionRet(ret, errcode, errmsg) {
59
- if (ret !== RATE_LIMIT_ERRCODE && errcode !== RATE_LIMIT_ERRCODE) return false
60
- const msg = String(errmsg ?? '').toLowerCase()
61
- return msg === 'unknown error' || msg === 'prepare failed' || msg.includes('expired') || msg.includes('token')
62
- }
63
-
64
- // ---------------------------------------------------------------------------
65
- // 纯协议客户端(transport-light,不依赖 DSH)
66
- // ---------------------------------------------------------------------------
67
-
68
- /** 每个请求必带的头。X-WECHAT-UIN 每次随机,防重放。 */
69
- function requestHeaders(token, body) {
70
- const headers = {
71
- 'Content-Type': 'application/json',
72
- AuthorizationType: 'ilink_bot_token',
73
- 'Content-Length': String(Buffer.byteLength(body)),
74
- 'X-WECHAT-UIN': randomBytes(4).toString('base64url'),
75
- 'iLink-App-Id': ILINK_APP_ID,
76
- 'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION),
77
- }
78
- if (token) headers.Authorization = `Bearer ${token}`
79
- return headers
80
- }
81
-
82
- function baseInfo() {
83
- return { channel_version: CHANNEL_VERSION }
84
- }
85
-
86
- /** 带超时与 abort 的 POST JSON。非 2xx 抛出带 HTTP 状态的错误。 */
87
- async function postJson({ baseUrl = ILINK_BASE_URL, endpoint, payload, token, timeoutMs = API_TIMEOUT_MS, signal: externalSignal }) {
88
- const body = JSON.stringify({ ...payload, base_info: baseInfo() })
89
- const url = `${stripTrailingSlashes(baseUrl)}/${endpoint}`
90
- const controller = new AbortController()
91
- const timer = setTimeout(() => controller.abort(), timeoutMs)
92
- // 外部 signal(如轮询停止)与超时合并:任一触发即中止请求
93
- const signal = externalSignal ? AbortSignal.any([externalSignal, controller.signal]) : controller.signal
94
- try {
95
- const response = await fetch(url, {
96
- method: 'POST',
97
- headers: requestHeaders(token, body),
98
- body,
99
- signal,
100
- })
101
- const raw = await response.text()
102
- if (!response.ok) {
103
- // 403 = iLink 独占锁症状:同 token 已有别的 poller。响亮抛出。
104
- const err = new Error(`iLink POST ${endpoint} HTTP ${response.status}: ${raw.slice(0, 200)}`)
105
- err.httpStatus = response.status
106
- throw err
107
- }
108
- return JSON.parse(raw)
109
- } finally {
110
- clearTimeout(timer)
111
- }
112
- }
113
-
114
- /** GET(扫码端点是无 token 的 GET)。 */
115
- async function getJson({ baseUrl = ILINK_BASE_URL, endpoint, timeoutMs = QR_TIMEOUT_MS }) {
116
- const url = `${stripTrailingSlashes(baseUrl)}/${endpoint}`
117
- const controller = new AbortController()
118
- const timer = setTimeout(() => controller.abort(), timeoutMs)
119
- try {
120
- const response = await fetch(url, {
121
- method: 'GET',
122
- headers: {
123
- 'iLink-App-Id': ILINK_APP_ID,
124
- 'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION),
125
- },
126
- signal: controller.signal,
127
- })
128
- const raw = await response.text()
129
- if (!response.ok) {
130
- const err = new Error(`iLink GET ${endpoint} HTTP ${response.status}: ${raw.slice(0, 200)}`)
131
- err.httpStatus = response.status
132
- throw err
133
- }
134
- return JSON.parse(raw)
135
- } finally {
136
- clearTimeout(timer)
137
- }
138
- }
139
-
140
- /** 长轮询收消息;超时返回空批次(不算错误)。 */
141
- async function getUpdates({ baseUrl, token, syncBuf, timeoutMs = LONG_POLL_TIMEOUT_MS, signal } = {}) {
142
- try {
143
- const raw = await postJson({
144
- baseUrl,
145
- endpoint: EP_GET_UPDATES,
146
- payload: { get_updates_buf: syncBuf },
147
- token,
148
- timeoutMs,
149
- signal,
150
- })
151
- return {
152
- messages: Array.isArray(raw.msgs) ? raw.msgs : [],
153
- syncBuf: raw.get_updates_buf ?? syncBuf,
154
- suggestedTimeoutMs: raw.longpolling_timeout_ms,
155
- raw,
156
- }
157
- } catch (error) {
158
- if (error instanceof DOMException && error.name === 'AbortError') {
159
- return { messages: [], syncBuf, raw: { ret: 0, msgs: [] } }
160
- }
161
- throw error
162
- }
163
- }
164
-
165
- /** 发送消息(文本或媒体)。text 和 item 二选一。 */
166
- async function sendMessage({ baseUrl, token, to, text, item, contextToken, clientId, timeoutMs }) {
167
- const msg = {
168
- from_user_id: '',
169
- to_user_id: to,
170
- client_id: clientId,
171
- message_type: MSG_TYPE_BOT,
172
- message_state: MSG_STATE_FINISH,
173
- }
174
-
175
- // 构建 item_list:优先使用 item(媒体),否则用 text
176
- if (item) {
177
- msg.item_list = [item]
178
- } else if (text && text.trim()) {
179
- msg.item_list = [{ type: ITEM_TEXT, text_item: { text } }]
180
- } else {
181
- throw new Error('sendMessage: either text or item must be provided')
182
- }
183
-
184
- if (contextToken) msg.context_token = contextToken
185
- return postJson({ baseUrl, endpoint: EP_SEND_MESSAGE, payload: { msg }, token, timeoutMs })
186
- }
187
-
188
- /** 获取 peer 的 typing_ticket(600s TTL)。 */
189
- async function getConfig({ baseUrl, token, userId, contextToken }) {
190
- const payload = { ilink_user_id: userId }
191
- if (contextToken) payload.context_token = contextToken
192
- const raw = await postJson({ baseUrl, endpoint: EP_GET_CONFIG, payload, token, timeoutMs: CONFIG_TIMEOUT_MS })
193
- return { typingTicket: raw.typing_ticket }
194
- }
195
-
196
- /** 开始(1)/结束(2) "正在输入" 指示。 */
197
- async function sendTyping({ baseUrl, token, toUserId, typingTicket, status }) {
198
- await postJson({
199
- baseUrl,
200
- endpoint: EP_SEND_TYPING,
201
- payload: { ilink_user_id: toUserId, typing_ticket: typingTicket, status },
202
- token,
203
- timeoutMs: CONFIG_TIMEOUT_MS,
204
- })
205
- }
206
-
207
- /** 获取登录二维码(bot_type=3 = 个人号 bot)。 */
208
- async function getBotQrcode({ baseUrl, botType = '3' }) {
209
- return getJson({ baseUrl, endpoint: `${EP_GET_BOT_QR}?bot_type=${botType}` })
210
- }
211
-
212
- /** 轮询扫码状态。 */
213
- async function getQrcodeStatus({ baseUrl, qrcode }) {
214
- return getJson({ baseUrl, endpoint: `${EP_GET_QR_STATUS}?qrcode=${encodeURIComponent(qrcode)}` })
215
- }
216
-
217
- /** 完整扫码登录流程,返回凭据或 null。 */
218
- async function qrLogin({ baseUrl, timeoutMs = 480_000, pollIntervalMs = 1000, onQr, onStatus }) {
219
- const deadline = Date.now() + timeoutMs
220
- let currentBaseUrl = baseUrl ?? ILINK_BASE_URL
221
- let qrcodeValue = ''
222
- let qrcodeImg = ''
223
-
224
- for (let attempt = 0; attempt < 2; attempt++) {
225
- try {
226
- const qr = await getBotQrcode({ baseUrl: currentBaseUrl })
227
- qrcodeValue = qr.qrcode ?? ''
228
- qrcodeImg = qr.qrcode_img_content ?? ''
229
- break
230
- } catch {
231
- if (attempt === 1) return null
232
- }
233
- }
234
- if (!qrcodeValue) return null
235
-
236
- const scanData = qrcodeImg || qrcodeValue
237
- onQr?.({ value: qrcodeValue, scanData, imgContent: qrcodeImg })
238
-
239
- let refreshCount = 0
240
- while (Date.now() < deadline) {
241
- let status
242
- try {
243
- status = await getQrcodeStatus({ baseUrl: currentBaseUrl, qrcode: qrcodeValue })
244
- } catch {
245
- await sleep(pollIntervalMs)
246
- continue
247
- }
248
- const state = status.status ?? 'wait'
249
- onStatus?.(state, status)
250
- if (state === 'scaned_but_redirect' && status.redirect_host) {
251
- currentBaseUrl = `https://${status.redirect_host}`
252
- } else if (state === 'expired') {
253
- refreshCount += 1
254
- if (refreshCount > 3) return null
255
- const qr = await getBotQrcode({ baseUrl: currentBaseUrl }).catch(() => null)
256
- if (!qr || !qr.qrcode) return null
257
- qrcodeValue = qr.qrcode
258
- qrcodeImg = qr.qrcode_img_content ?? ''
259
- onQr?.({ value: qrcodeValue, scanData: qrcodeImg || qrcodeValue, imgContent: qrcodeImg })
260
- } else if (state === 'confirmed') {
261
- const accountId = status.ilink_bot_id ?? ''
262
- const token = status.bot_token ?? ''
263
- if (!accountId || !token) return null
264
- return {
265
- accountId,
266
- token,
267
- baseUrl: status.baseurl ?? currentBaseUrl,
268
- userId: status.ilink_user_id,
269
- }
270
- }
271
- await sleep(pollIntervalMs)
272
- }
273
- return null
274
- }
275
-
276
- function sleep(ms) {
277
- return new Promise((resolve) => setTimeout(resolve, ms))
278
- }
279
-
280
- // ---------------------------------------------------------------------------
281
- // 网关服务(生命周期 + 轮询 + 发送 + typing + 扫码)
282
- // ---------------------------------------------------------------------------
283
-
284
- /** 网关状态。 */
285
- const GATEWAY_STATUS = ['idle', 'starting', 'connected', 'reconnecting', 'paused', 'error']
286
-
287
- /**
288
- * WechatGateway — iLink 网关服务实例。
289
- * @param {object} opts
290
- * @param {object} opts.ctx Cordis 上下文(用于 emit 事件)
291
- * @param {object} opts.logger 日志器
292
- * @param {object} [opts.config] 配置(默认值见下)
293
- */
294
- export class WechatGateway extends Service {
295
- constructor({ ctx, logger, config = {}, instanceId, serviceName } = {}) {
296
- // issue #78:instanceId 用于事件隔离;serviceName 是唯一 cordis 服务名(D2:服务名不复用)
297
- const resolvedInstanceId = instanceId || 'wechat'
298
- super(ctx, serviceName || config.serviceName || resolvedInstanceId)
299
- this.instanceId = resolvedInstanceId
300
- this.logger = logger
301
- this.c = {
302
- baseUrl: config.baseUrl ?? ILINK_BASE_URL,
303
- cdnBaseUrl: config.cdnBaseUrl ?? WEIXIN_CDN_BASE_URL,
304
- token: config.token ?? '',
305
- accountId: config.accountId ?? '',
306
- longPollTimeoutMs: config.longPollTimeoutMs ?? LONG_POLL_TIMEOUT_MS,
307
- apiTimeoutMs: config.apiTimeoutMs ?? API_TIMEOUT_MS,
308
- pollIdleDelayMs: config.pollIdleDelayMs ?? 0,
309
- qrPollIntervalMs: config.qrPollIntervalMs ?? 1000,
310
- retryDelayMs: config.retryDelayMs ?? 2000,
311
- backoffDelayMs: config.backoffDelayMs ?? 30_000,
312
- maxConsecutiveFailures: config.maxConsecutiveFailures ?? 3,
313
- sessionExpiredPauseMs: config.sessionExpiredPauseMs ?? 600_000,
314
- sendChunkDelayMs: config.sendChunkDelayMs ?? 1500,
315
- sendChunkRetries: config.sendChunkRetries ?? 4,
316
- sendChunkRetryDelayMs: config.sendChunkRetryDelayMs ?? 1000,
317
- rateLimitCircuitOpenMs: config.rateLimitCircuitOpenMs ?? 30_000,
318
- rateLimitCircuitWindowMs: config.rateLimitCircuitWindowMs ?? 30_000,
319
- rateLimitCircuitThreshold: config.rateLimitCircuitThreshold ?? 1,
320
- }
321
- this.syncBuf = ''
322
- this.pollTask = null
323
- this.stopPollingLocal = false
324
- // 当前轮询循环的中止信号:stop/restart 时 abort 以立即中断 in-flight 长轮询
325
- this._pollAbort = null
326
- this.statusValue = 'idle'
327
- this.contextTokens = new Map()
328
- try {
329
- const tokenFile = path.join(process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh'), 'dsh-bridge', 'wechat-context-tokens.json')
330
- if (fs.existsSync(tokenFile)) {
331
- const data = JSON.parse(fs.readFileSync(tokenFile, 'utf8'))
332
- for (const [k, v] of Object.entries(data)) {
333
- if (v) this.contextTokens.set(k, String(v))
334
- }
335
- }
336
- } catch {}
337
- this.dedup = new Map()
338
- this.typingTickets = new Map()
339
- this.rateLimitHits = []
340
- this.rateLimitUntil = 0
341
- this._disposed = false
342
- // 内存泄漏防护:每 5 分钟清理过期缓存
343
- this.cleanupInterval = setInterval(() => this._cleanupMaps(), 300_000)
344
- this._persistTokensTimer = null
345
- }
346
-
347
- _cleanupMaps() {
348
- const now = Date.now()
349
- // 清理 contextTokens:超过 1 小时未使用的删除
350
- const contextTokenTtl = 3600_000
351
- // 清理 typingTickets:超过 30 秒的删除
352
- const typingTicketTtl = 30_000
353
-
354
- // contextTokens 没有时间戳,保守策略:如果 Map 过大才清理(超过 100 个)
355
- if (this.contextTokens.size > 100) {
356
- this.logger?.warn(`contextTokens Map 过大 (${this.contextTokens.size}),清理旧数据`)
357
- // 保留最近 50 个,删除其余
358
- const entries = Array.from(this.contextTokens.entries())
359
- this.contextTokens.clear()
360
- entries.slice(-50).forEach(([k, v]) => this.contextTokens.set(k, v))
361
- }
362
-
363
- // 清理 typingTickets
364
- for (const [peerId, ticket] of this.typingTickets) {
365
- if (now - ticket.at > typingTicketTtl) {
366
- this.typingTickets.delete(peerId)
367
- }
368
- }
369
- }
370
-
371
- // ---- 状态访问器 ----------------------------------------------------------
372
-
373
- get status() { return this.statusValue }
374
- get configured() { return Boolean(this.c.token && this.c.accountId) }
375
- get accountId() { return this.c.accountId }
376
- get baseUrl() { return this.c.baseUrl }
377
-
378
- // ---- 生命周期 ------------------------------------------------------------
379
-
380
- /** 运行中由外部持有 setTimeout 等资源;dispose 停止轮询。 */
381
- dispose() {
382
- this._disposed = true
383
- this.stopPollingLocal = true
384
- if (this.cleanupInterval) {
385
- clearInterval(this.cleanupInterval)
386
- this.cleanupInterval = null
387
- }
388
- // 退出前冲刷未落盘的 context token
389
- if (this._persistTokensTimer) {
390
- clearTimeout(this._persistTokensTimer)
391
- this._persistTokensTimer = null
392
- this._persistTokensNow()
393
- }
394
- void this.stop()
395
- }
396
-
397
- async stop() {
398
- this.stopPollingLocal = true
399
- this._pollAbort?.abort()
400
- const task = this.pollTask
401
- this.pollTask = null
402
- if (task) {
403
- try { await task } catch { /* 轮询错误通过事件暴露,不在此抛出 */ }
404
- }
405
- this.setStatus('idle')
406
- }
407
-
408
- async start() {
409
- if (this._startingPromise) return this._startingPromise
410
- if (!this.configured) {
411
- this.setStatus('idle')
412
- return
413
- }
414
- this._startingPromise = this.restart()
415
- try {
416
- await this._startingPromise
417
- } finally {
418
- this._startingPromise = null
419
- }
420
- }
421
-
422
- setCredentials({ token, accountId, baseUrl } = {}) {
423
- if (token !== undefined) this.c.token = token
424
- if (accountId !== undefined) this.c.accountId = accountId
425
- if (baseUrl !== undefined) this.c.baseUrl = baseUrl
426
- void this.restart()
427
- }
428
-
429
- // ---- 对外能力 ------------------------------------------------------------
430
-
431
- contextTokenFor(peerId) { return this.contextTokens.get(peerId) }
432
- setContextToken(peerId, token) { if (token) this.contextTokens.set(peerId, token) }
433
-
434
- /**
435
- * 扫码登录。成功即采用凭据并开始轮询。返回 { success, credentials?, error? }。
436
- * 调用方负责持久化凭据。
437
- */
438
- async loginQr({ onQr, onStatus, timeoutMs } = {}) {
439
- const credentials = await qrLogin({
440
- baseUrl: this.c.baseUrl,
441
- timeoutMs,
442
- pollIntervalMs: this.c.qrPollIntervalMs,
443
- onQr,
444
- onStatus,
445
- })
446
- if (!credentials) return { success: false, error: 'login failed or timed out' }
447
- this.setCredentials(credentials)
448
- return { success: true, credentials }
449
- }
450
-
451
- /**
452
- * 发送一条文本气泡(< maxMessageChars)。分块由上层负责。
453
- * 带逐块重试、会话过期无 token 降级、限流熔断。
454
- */
455
- async sendText(to, text, clientId) {
456
- if (!text.trim()) return { success: false, error: 'empty message' }
457
- if (!this.configured) return { success: false, error: 'not configured' }
458
- let contextToken = this.contextTokens.get(to)
459
- const id = clientId ?? `dsh-bridge-wechat-${randomId()}`
460
- let lastError
461
- let retriedWithoutToken = false
462
-
463
- for (let attempt = 0; attempt <= this.c.sendChunkRetries; attempt++) {
464
- if (this.rateLimitUntil > Date.now()) {
465
- return { success: false, error: 'iLink sendmessage rate limited; cooldown active' }
466
- }
467
- try {
468
- const resp = await sendMessage({
469
- baseUrl: this.c.baseUrl,
470
- token: this.c.token,
471
- to,
472
- text,
473
- contextToken,
474
- clientId: id,
475
- timeoutMs: this.c.apiTimeoutMs,
476
- })
477
- const ret = resp.ret
478
- const errcode = resp.errcode
479
- if ((ret !== undefined && ret !== 0) || (errcode !== undefined && errcode !== 0)) {
480
- const isSessionExpired = ret === SESSION_EXPIRED_ERRCODE || errcode === SESSION_EXPIRED_ERRCODE
481
- || isStaleSessionRet(ret, errcode, resp.errmsg)
482
- if (isSessionExpired) {
483
- if (contextToken && !retriedWithoutToken) {
484
- retriedWithoutToken = true
485
- contextToken = undefined
486
- this.contextTokens.delete(to)
487
- await sleep(this.c.sendChunkRetryDelayMs)
488
- continue
489
- }
490
- lastError = new Error(`iLink sendmessage session expired: ret=${ret} errcode=${errcode}`)
491
- break
492
- }
493
- const isRateLimited = ret === RATE_LIMIT_ERRCODE || errcode === RATE_LIMIT_ERRCODE
494
- if (isRateLimited) {
495
- lastError = new Error(`iLink sendmessage rate limited: ret=${ret} errcode=${errcode} errmsg=${resp.errmsg ?? ''}`)
496
- if (this.recordRateLimit()) break
497
- if (attempt >= this.c.sendChunkRetries) break
498
- await sleep(this.c.sendChunkRetryDelayMs * 3)
499
- continue
500
- }
501
- lastError = new Error(`iLink sendmessage error: ret=${ret} errcode=${errcode} errmsg=${resp.errmsg ?? ''}`)
502
- break
503
- }
504
- this.rateLimitHits = []
505
- return { success: true, messageId: id }
506
- } catch (error) {
507
- lastError = error instanceof Error ? error : new Error(String(error))
508
- if (attempt >= this.c.sendChunkRetries) break
509
- await sleep(this.c.sendChunkRetryDelayMs * (attempt + 1))
510
- }
511
- }
512
- return { success: false, error: lastError?.message ?? 'send failed' }
513
- }
514
-
515
- /**
516
- * 获取媒体上传 URL(v0.2)。
517
- * @param {object} opts
518
- * @param {string} opts.to 接收用户 ID
519
- * @param {number} opts.mediaType 媒体类型(2=图片 3=语音 4=文件 5=视频)
520
- * @param {string} opts.filekey 随机 hex 标识(32 字符)
521
- * @param {number} opts.rawSize 明文大小
522
- * @param {string} opts.rawFileMd5 明文 MD5
523
- * @param {number} opts.fileSize 密文大小(AES 填充后)
524
- * @param {string} opts.aesKeyHex AES key 的 hex 表示(32 字符)
525
- * @returns {Promise<{uploadParam?: string, uploadFullUrl?: string}>}
526
- */
527
- async getUploadUrl({ to, mediaType, filekey, rawSize, rawFileMd5, fileSize, aesKeyHex }) {
528
- if (!this.configured) throw new Error('not configured')
529
- // 映射 MessageItemType 到 UploadMediaType (IMAGE:1, VIDEO:2, FILE:3, VOICE:4)
530
- let uploadMediaType = mediaType
531
- if (mediaType === 2) uploadMediaType = 1 // IMAGE
532
- else if (mediaType === 4) uploadMediaType = 3 // FILE
533
- else if (mediaType === 3) uploadMediaType = 4 // VOICE
534
- else if (mediaType === 5) uploadMediaType = 2 // VIDEO
535
-
536
- const resp = await postJson({
537
- baseUrl: this.c.baseUrl,
538
- endpoint: 'ilink/bot/getuploadurl',
539
- token: this.c.token,
540
- payload: {
541
- filekey,
542
- media_type: uploadMediaType,
543
- to_user_id: to,
544
- rawsize: rawSize,
545
- rawfilemd5: rawFileMd5,
546
- filesize: fileSize,
547
- no_need_thumb: true,
548
- aeskey: aesKeyHex,
549
- },
550
- timeoutMs: this.c.apiTimeoutMs,
551
- })
552
- return {
553
- uploadParam: resp.upload_param,
554
- uploadFullUrl: resp.upload_full_url,
555
- }
556
- }
557
-
558
- /**
559
- * 发送媒体消息(图片/文件/语音/视频)。
560
- * @param {object} opts
561
- * @param {string} opts.to 接收用户 ID
562
- * @param {number} opts.mediaType 媒体类型(2=图片 3=语音 4=文件 5=视频)
563
- * @param {string} opts.encryptedQueryParam CDN 加密参数(上传后获取)
564
- * @param {string} opts.aesKeyBase64 AES key 的 base64(hex) 表示
565
- * @param {number} opts.ciphertextSize 密文大小
566
- * @param {number} opts.plaintextSize 明文大小
567
- * @param {string} opts.filename 文件名
568
- * @param {string} opts.rawFileMd5 明文 MD5
569
- * @param {string} [opts.clientId] 客户端消息 ID
570
- * @returns {Promise<{success: boolean, error?: string, messageId?: string}>}
571
- */
572
- async sendMedia({
573
- to,
574
- mediaType,
575
- encryptedQueryParam,
576
- aesKeyBase64,
577
- aesKeyHex,
578
- ciphertextSize,
579
- plaintextSize,
580
- filename,
581
- rawFileMd5,
582
- clientId,
583
- }) {
584
- if (!this.configured) return { success: false, error: 'not configured' }
585
- const contextToken = this.contextTokens.get(to)
586
- const id = clientId ?? `dsh-bridge-wechat-${randomId()}`
587
- const hexKey = aesKeyHex || (aesKeyBase64 ? Buffer.from(aesKeyBase64, 'base64').toString('hex') : '')
588
-
589
- // 构建媒体项(全字段兼容各端微信客户端解析)
590
- let item
591
- if (mediaType === 2) { // 图片
592
- item = {
593
- type: 2,
594
- image_item: {
595
- media: {
596
- encrypt_query_param: encryptedQueryParam,
597
- aes_key: aesKeyBase64,
598
- aeskey: hexKey,
599
- encrypt_type: 1,
600
- },
601
- aeskey: hexKey,
602
- aes_key: aesKeyBase64,
603
- filesize: ciphertextSize,
604
- rawsize: plaintextSize,
605
- rawfilemd5: rawFileMd5,
606
- },
607
- }
608
- } else if (mediaType === 4) { // 文件
609
- item = {
610
- type: 4,
611
- file_item: {
612
- file_name: filename,
613
- len: String(plaintextSize),
614
- media: {
615
- encrypt_query_param: encryptedQueryParam,
616
- aes_key: aesKeyBase64,
617
- encrypt_type: 1,
618
- },
619
- },
620
- }
621
- } else if (mediaType === 3) { // 语音
622
- item = {
623
- type: 3,
624
- voice_item: {
625
- media: {
626
- encrypt_query_param: encryptedQueryParam,
627
- aes_key: aesKeyBase64,
628
- aeskey: hexKey,
629
- encrypt_type: 0,
630
- },
631
- aeskey: hexKey,
632
- aes_key: aesKeyBase64,
633
- encode_type: 6, // silk
634
- sample_rate: 24000,
635
- bits_per_sample: 16,
636
- },
637
- }
638
- } else if (mediaType === 5) { // 视频
639
- item = {
640
- type: 5,
641
- video_item: {
642
- media: {
643
- encrypt_query_param: encryptedQueryParam,
644
- aes_key: aesKeyBase64,
645
- aeskey: hexKey,
646
- encrypt_type: 1,
647
- },
648
- aeskey: hexKey,
649
- aes_key: aesKeyBase64,
650
- filesize: ciphertextSize,
651
- rawsize: plaintextSize,
652
- rawfilemd5: rawFileMd5,
653
- },
654
- }
655
- } else {
656
- return { success: false, error: `unsupported media type ${mediaType}` }
657
- }
658
-
659
- try {
660
- const resp = await sendMessage({
661
- baseUrl: this.c.baseUrl,
662
- token: this.c.token,
663
- to,
664
- item,
665
- contextToken,
666
- clientId: id,
667
- timeoutMs: this.c.apiTimeoutMs,
668
- })
669
- const ret = resp.ret
670
- const errcode = resp.errcode
671
- if ((ret !== undefined && ret !== 0) || (errcode !== undefined && errcode !== 0)) {
672
- return {
673
- success: false,
674
- error: `iLink sendmessage error: ret=${ret} errcode=${errcode} errmsg=${resp.errmsg ?? ''}`,
675
- }
676
- }
677
- return { success: true, messageId: id }
678
- } catch (error) {
679
- return {
680
- success: false,
681
- error: error instanceof Error ? error.message : String(error),
682
- }
683
- }
684
- }
685
-
686
- /**
687
- * 加密并发送本地媒体文件(图片/文档)到微信
688
- */
689
- async sendMediaFile(to, filePath) {
690
- if (!this.configured || !fs.existsSync(filePath)) return { success: false, error: 'not configured or file not found' }
691
- try {
692
- const buf = await fs.promises.readFile(filePath)
693
- const ext = path.extname(filePath).toLowerCase()
694
- const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
695
- const mediaType = isImage ? 2 : 4
696
- const filename = path.basename(filePath)
697
- const rawFileMd5 = md5(buf)
698
- const aesKey = generateAesKey()
699
- const aesKeyHex = aesKey.toString('hex')
700
- const aesKeyBase64 = encodeAesKeyForApi(aesKey)
701
- const filekey = generateFilekey()
702
- const rawSize = buf.length
703
- const fileSize = aes128PaddedSize(rawSize)
704
-
705
- const uploadInfo = await this.getUploadUrl({
706
- to,
707
- filekey,
708
- mediaType,
709
- rawSize,
710
- rawFileMd5,
711
- fileSize,
712
- aesKeyHex,
713
- })
714
-
715
- const uploadUrl = uploadInfo.uploadFullUrl || `${stripTrailingSlashes(this.c.cdnBaseUrl)}/upload?encrypted_query_param=${encodeURIComponent(uploadInfo.uploadParam)}&filekey=${encodeURIComponent(filekey)}`
716
-
717
- const encryptedParam = await uploadMedia({
718
- plaintext: buf,
719
- uploadUrl,
720
- aesKey,
721
- })
722
-
723
- return await this.sendMedia({
724
- to,
725
- mediaType,
726
- encryptedQueryParam: encryptedParam,
727
- aesKeyBase64,
728
- aesKeyHex,
729
- ciphertextSize: fileSize,
730
- plaintextSize: rawSize,
731
- filename,
732
- rawFileMd5,
733
- })
734
- } catch (err) {
735
- this.logger?.warn?.('[dsh-bridge wechat] sendMediaFile failed: %s', err?.message ?? err)
736
- return { success: false, error: err?.message }
737
- }
738
- }
739
-
740
- /** 显示/隐藏 typing 指示(尽力而为,失败不致命)。 */
741
- async sendTyping(to, status) {
742
- if (!this.configured) return
743
- const ticket = await this.typingTicket(to)
744
- if (!ticket) return
745
- try {
746
- await sendTyping({
747
- baseUrl: this.c.baseUrl,
748
- token: this.c.token,
749
- toUserId: to,
750
- typingTicket: ticket,
751
- status,
752
- })
753
- } catch { /* typing 是装饰性的 */ }
754
- }
755
-
756
- async typingTicket(peerId) {
757
- const cached = this.typingTickets.get(peerId)
758
- if (cached && Date.now() - cached.at < 600_000) return cached.ticket
759
- try {
760
- const { typingTicket } = await getConfig({
761
- baseUrl: this.c.baseUrl,
762
- token: this.c.token,
763
- userId: peerId,
764
- contextToken: this.contextTokens.get(peerId),
765
- })
766
- if (typingTicket) {
767
- this.typingTickets.set(peerId, { ticket: typingTicket, at: Date.now() })
768
- return typingTicket
769
- }
770
- } catch { /* 非致命 */ }
771
- return undefined
772
- }
773
-
774
- // -------------------------------------------------------------------------
775
- // 轮询循环
776
- // -------------------------------------------------------------------------
777
-
778
- async restart() {
779
- if (this._restartingPromise) return this._restartingPromise
780
- this._restartingPromise = (async () => {
781
- this.stopPollingLocal = true
782
- // 立即中断旧循环的 in-flight 长轮询,避免等待最长 35s 才切换
783
- this._pollAbort?.abort()
784
- const previous = this.pollTask
785
- this.pollTask = null
786
- if (previous) {
787
- try { await previous } catch { /* 被替换 */ }
788
- }
789
- if (!this.configured) {
790
- this.setStatus('idle')
791
- return
792
- }
793
- this.stopPollingLocal = false
794
- this.setStatus('starting')
795
- this.pollTask = this.runPollLoop()
796
- })()
797
- try {
798
- await this._restartingPromise
799
- } finally {
800
- this._restartingPromise = null
801
- }
802
- }
803
-
804
- setStatus(status) {
805
- if (this.statusValue === status) return
806
- this.statusValue = status
807
- try {
808
- // D7:payload 统一带 instanceId,形状与其它三平台一致
809
- this.ctx.emit('wechat/status', { instanceId: this.instanceId, status })
810
- } catch { /* emit 失败不致命 */ }
811
- }
812
-
813
- /** D7:带 instanceId 的错误/致命事件;旧消费者读 event.error 即可(无 instanceId 时行为不变)。 */
814
- _emitPlatformError(event, error) {
815
- try {
816
- this.ctx.emit(event, {
817
- instanceId: this.instanceId,
818
- error: error instanceof Error ? error : new Error(String(error)),
819
- })
820
- } catch { /* emit 失败不致命 */ }
821
- }
822
-
823
- async runPollLoop() {
824
- // 每次轮询循环持有一个独立 abort:stop/restart 时中断 in-flight getUpdates(最长 35s)
825
- const pollAbort = new AbortController()
826
- this._pollAbort = pollAbort
827
- let consecutiveFailures = 0
828
- let timeoutMs = this.c.longPollTimeoutMs
829
- let fatal = false
830
- while (!this.stopPollingLocal) {
831
- try {
832
- const batch = await getUpdates({
833
- baseUrl: this.c.baseUrl,
834
- token: this.c.token,
835
- syncBuf: this.syncBuf,
836
- timeoutMs,
837
- signal: pollAbort.signal,
838
- })
839
- if (this.stopPollingLocal) break
840
-
841
- if (typeof batch.raw.longpolling_timeout_ms === 'number' && batch.raw.longpolling_timeout_ms > 0) {
842
- timeoutMs = batch.raw.longpolling_timeout_ms
843
- }
844
-
845
- const ret = batch.raw.ret
846
- const errcode = batch.raw.errcode
847
- if ((ret !== undefined && ret !== 0 && ret !== null) || (errcode !== undefined && errcode !== 0 && errcode !== null)) {
848
- if (ret === SESSION_EXPIRED_ERRCODE || errcode === SESSION_EXPIRED_ERRCODE
849
- || isStaleSessionRet(ret, errcode, batch.raw.errmsg)) {
850
- this.setStatus('paused')
851
- this._emitPlatformError('wechat/error', new Error(`iLink session expired; pausing ${this.c.sessionExpiredPauseMs}ms`))
852
- await sleep(this.c.sessionExpiredPauseMs)
853
- consecutiveFailures = 0
854
- this.setStatus('connected')
855
- continue
856
- }
857
- consecutiveFailures += 1
858
- const backoff = consecutiveFailures >= this.c.maxConsecutiveFailures
859
- ? this.c.backoffDelayMs : this.c.retryDelayMs
860
- this.setStatus(consecutiveFailures >= this.c.maxConsecutiveFailures ? 'reconnecting' : 'connected')
861
- this._emitPlatformError('wechat/error', new Error(
862
- `getUpdates failed ret=${ret} errcode=${errcode} errmsg=${batch.raw.errmsg ?? ''} (${consecutiveFailures}/${this.c.maxConsecutiveFailures})`,
863
- ))
864
- if (consecutiveFailures >= this.c.maxConsecutiveFailures) consecutiveFailures = 0
865
- await sleep(backoff)
866
- continue
867
- }
868
-
869
- consecutiveFailures = 0
870
- if (batch.syncBuf) this.syncBuf = batch.syncBuf
871
- if (this.statusValue !== 'connected') {
872
- this.logger?.info?.('[dsh-bridge wechat] connected to iLink platform')
873
- }
874
- if (this.stopPollingLocal) break
875
- this.setStatus('connected')
876
- for (const message of batch.messages) {
877
- if (this.stopPollingLocal) break
878
- this.dispatchInbound(message)
879
- }
880
- if (this.c.pollIdleDelayMs > 0) await sleep(this.c.pollIdleDelayMs)
881
- } catch (error) {
882
- if (this.stopPollingLocal) break
883
- if (error?.httpStatus === 403) {
884
- // iLink 独占锁:同 token 已有别的 poller。响亮报错并停止。
885
- this.setStatus('error')
886
- this._emitPlatformError('wechat/fatal', new Error(
887
- 'iLink returned HTTP 403: another poller (hermes-agent, OpenClaw, or a duplicate dsh-bridge WeChat bot) is already polling this account. ' +
888
- 'iLink allows exactly one authenticated poller per token. Stop the other gateway or use a dedicated WeChat account.',
889
- ))
890
- fatal = true
891
- this.stopPollingLocal = true
892
- break
893
- }
894
- consecutiveFailures += 1
895
- const backoff = consecutiveFailures >= this.c.maxConsecutiveFailures
896
- ? this.c.backoffDelayMs : this.c.retryDelayMs
897
- this.setStatus(consecutiveFailures >= this.c.maxConsecutiveFailures ? 'reconnecting' : 'connected')
898
- this._emitPlatformError('wechat/error', error)
899
- if (consecutiveFailures >= this.c.maxConsecutiveFailures) consecutiveFailures = 0
900
- await sleep(backoff)
901
- }
902
- }
903
- // 清理当前轮询 abort(避免悬挂引用);致命错误保持终态,普通停止回到 idle
904
- if (this._pollAbort === pollAbort) this._pollAbort = null
905
- if (!fatal) this.setStatus('idle')
906
- }
907
-
908
- // ---- 入站管道(去重 + context token 捕获;策略在上层 node) ---------------
909
-
910
- dispatchInbound(message) {
911
- const sender = String(message.from_user_id ?? '')
912
- const messageId = String(message.message_id ?? '')
913
- if (!sender || sender === this.c.accountId) return
914
- if (messageId && this.isDuplicate(messageId)) return
915
- if (messageId) this.remember(messageId)
916
-
917
- const contextToken = String(message.context_token ?? '')
918
- if (contextToken) {
919
- this.contextTokens.set(sender, contextToken)
920
- this._scheduleTokenPersist()
921
- }
922
-
923
- try {
924
- this.ctx.emit('wechat/message', { ...message, instanceId: this.instanceId })
925
- } catch { /* 上层未订阅时不致命 */ }
926
- }
927
-
928
- // context token 持久化:防抖合并写(内存映射为唯一事实源,整体回写)。
929
- // 此前每条入站消息都 existsSync + readFileSync + writeFileSync 一轮,热路径同步 IO。
930
- _scheduleTokenPersist(delayMs = 2000) {
931
- if (this._persistTokensTimer) return
932
- this._persistTokensTimer = setTimeout(() => {
933
- this._persistTokensTimer = null
934
- this._persistTokensNow()
935
- }, delayMs)
936
- if (this._persistTokensTimer.unref) this._persistTokensTimer.unref()
937
- }
938
-
939
- _persistTokensNow() {
940
- try {
941
- const tokenFile = path.join(process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh'), 'dsh-bridge', 'wechat-context-tokens.json')
942
- fs.mkdirSync(path.dirname(tokenFile), { recursive: true })
943
- fs.writeFileSync(tokenFile, JSON.stringify(Object.fromEntries(this.contextTokens), null, 2), 'utf8')
944
- } catch { /* 持久化失败不致命:内存映射仍在,下次消息会重试 */ }
945
- }
946
-
947
- isDuplicate(id) {
948
- const seen = this.dedup.get(id)
949
- if (seen !== undefined && Date.now() - seen < MESSAGE_DEDUP_TTL_SECONDS * 1000) return true
950
- return false
951
- }
952
-
953
- remember(id) {
954
- this.dedup.set(id, Date.now())
955
- if (this.dedup.size > 512) {
956
- const cutoff = Date.now() - MESSAGE_DEDUP_TTL_SECONDS * 1000
957
- for (const [key, at] of this.dedup) {
958
- if (at < cutoff) this.dedup.delete(key)
959
- }
960
- }
961
- }
962
-
963
- // ---- 限流熔断 ------------------------------------------------------------
964
-
965
- recordRateLimit() {
966
- const now = Date.now()
967
- const windowStart = now - this.c.rateLimitCircuitWindowMs
968
- this.rateLimitHits = this.rateLimitHits.filter((ts) => ts >= windowStart)
969
- this.rateLimitHits.push(now)
970
- if (this.rateLimitHits.length >= this.c.rateLimitCircuitThreshold) {
971
- this.rateLimitUntil = Math.max(this.rateLimitUntil, now + this.c.rateLimitCircuitOpenMs)
972
- return this.rateLimitUntil > now
973
- }
974
- return false
975
- }
976
- }
977
-
978
- function randomId() {
979
- return Math.random().toString(36).slice(2) + Date.now().toString(36)
980
- }
981
-
982
- // 媒体类型常量(v0.2)
983
- const MEDIA_TYPE_IMAGE = 2
984
- const MEDIA_TYPE_VOICE = 3
985
- const MEDIA_TYPE_FILE = 4
986
- const MEDIA_TYPE_VIDEO = 5
987
-
988
- export const gatewayConstants = {
989
- ILINK_BASE_URL,
990
- WEIXIN_CDN_BASE_URL,
991
- MAX_MESSAGE_CHARS,
992
- TYPING_START,
993
- TYPING_STOP,
994
- ITEM_TEXT,
995
- GATEWAY_STATUS,
996
- MEDIA_TYPE_IMAGE,
997
- MEDIA_TYPE_VOICE,
998
- MEDIA_TYPE_FILE,
999
- MEDIA_TYPE_VIDEO,
1000
- }