@wenbin_wb/dsh-bridge 2.10.4 → 2.10.6

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,986 +1,986 @@
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 } 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 = `${baseUrl.replace(/\/+$/, '')}/${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 = `${baseUrl.replace(/\/+$/, '')}/${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 = {} }) {
296
- super(ctx, 'wechat')
297
- this.logger = logger
298
- this.c = {
299
- baseUrl: config.baseUrl ?? ILINK_BASE_URL,
300
- cdnBaseUrl: config.cdnBaseUrl ?? WEIXIN_CDN_BASE_URL,
301
- token: config.token ?? '',
302
- accountId: config.accountId ?? '',
303
- longPollTimeoutMs: config.longPollTimeoutMs ?? LONG_POLL_TIMEOUT_MS,
304
- apiTimeoutMs: config.apiTimeoutMs ?? API_TIMEOUT_MS,
305
- pollIdleDelayMs: config.pollIdleDelayMs ?? 0,
306
- qrPollIntervalMs: config.qrPollIntervalMs ?? 1000,
307
- retryDelayMs: config.retryDelayMs ?? 2000,
308
- backoffDelayMs: config.backoffDelayMs ?? 30_000,
309
- maxConsecutiveFailures: config.maxConsecutiveFailures ?? 3,
310
- sessionExpiredPauseMs: config.sessionExpiredPauseMs ?? 600_000,
311
- sendChunkDelayMs: config.sendChunkDelayMs ?? 1500,
312
- sendChunkRetries: config.sendChunkRetries ?? 4,
313
- sendChunkRetryDelayMs: config.sendChunkRetryDelayMs ?? 1000,
314
- rateLimitCircuitOpenMs: config.rateLimitCircuitOpenMs ?? 30_000,
315
- rateLimitCircuitWindowMs: config.rateLimitCircuitWindowMs ?? 30_000,
316
- rateLimitCircuitThreshold: config.rateLimitCircuitThreshold ?? 1,
317
- }
318
- this.syncBuf = ''
319
- this.pollTask = null
320
- this.stopPollingLocal = false
321
- // 当前轮询循环的中止信号:stop/restart 时 abort 以立即中断 in-flight 长轮询
322
- this._pollAbort = null
323
- this.statusValue = 'idle'
324
- this.contextTokens = new Map()
325
- try {
326
- const tokenFile = path.join(process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh'), 'dsh-bridge', 'wechat-context-tokens.json')
327
- if (fs.existsSync(tokenFile)) {
328
- const data = JSON.parse(fs.readFileSync(tokenFile, 'utf8'))
329
- for (const [k, v] of Object.entries(data)) {
330
- if (v) this.contextTokens.set(k, String(v))
331
- }
332
- }
333
- } catch {}
334
- this.dedup = new Map()
335
- this.typingTickets = new Map()
336
- this.rateLimitHits = []
337
- this.rateLimitUntil = 0
338
- this._disposed = false
339
- // 内存泄漏防护:每 5 分钟清理过期缓存
340
- this.cleanupInterval = setInterval(() => this._cleanupMaps(), 300_000)
341
- this._persistTokensTimer = null
342
- }
343
-
344
- _cleanupMaps() {
345
- const now = Date.now()
346
- // 清理 contextTokens:超过 1 小时未使用的删除
347
- const contextTokenTtl = 3600_000
348
- // 清理 typingTickets:超过 30 秒的删除
349
- const typingTicketTtl = 30_000
350
-
351
- // contextTokens 没有时间戳,保守策略:如果 Map 过大才清理(超过 100 个)
352
- if (this.contextTokens.size > 100) {
353
- this.logger?.warn(`contextTokens Map 过大 (${this.contextTokens.size}),清理旧数据`)
354
- // 保留最近 50 个,删除其余
355
- const entries = Array.from(this.contextTokens.entries())
356
- this.contextTokens.clear()
357
- entries.slice(-50).forEach(([k, v]) => this.contextTokens.set(k, v))
358
- }
359
-
360
- // 清理 typingTickets
361
- for (const [peerId, ticket] of this.typingTickets) {
362
- if (now - ticket.at > typingTicketTtl) {
363
- this.typingTickets.delete(peerId)
364
- }
365
- }
366
- }
367
-
368
- // ---- 状态访问器 ----------------------------------------------------------
369
-
370
- get status() { return this.statusValue }
371
- get configured() { return Boolean(this.c.token && this.c.accountId) }
372
- get accountId() { return this.c.accountId }
373
- get baseUrl() { return this.c.baseUrl }
374
-
375
- // ---- 生命周期 ------------------------------------------------------------
376
-
377
- /** 运行中由外部持有 setTimeout 等资源;dispose 停止轮询。 */
378
- dispose() {
379
- this._disposed = true
380
- this.stopPollingLocal = true
381
- if (this.cleanupInterval) {
382
- clearInterval(this.cleanupInterval)
383
- this.cleanupInterval = null
384
- }
385
- // 退出前冲刷未落盘的 context token
386
- if (this._persistTokensTimer) {
387
- clearTimeout(this._persistTokensTimer)
388
- this._persistTokensTimer = null
389
- this._persistTokensNow()
390
- }
391
- void this.stop()
392
- }
393
-
394
- async stop() {
395
- this.stopPollingLocal = true
396
- this._pollAbort?.abort()
397
- const task = this.pollTask
398
- this.pollTask = null
399
- if (task) {
400
- try { await task } catch { /* 轮询错误通过事件暴露,不在此抛出 */ }
401
- }
402
- this.setStatus('idle')
403
- }
404
-
405
- async start() {
406
- if (this._startingPromise) return this._startingPromise
407
- if (!this.configured) {
408
- this.setStatus('idle')
409
- return
410
- }
411
- this._startingPromise = this.restart()
412
- try {
413
- await this._startingPromise
414
- } finally {
415
- this._startingPromise = null
416
- }
417
- }
418
-
419
- setCredentials({ token, accountId, baseUrl } = {}) {
420
- if (token !== undefined) this.c.token = token
421
- if (accountId !== undefined) this.c.accountId = accountId
422
- if (baseUrl !== undefined) this.c.baseUrl = baseUrl
423
- void this.restart()
424
- }
425
-
426
- // ---- 对外能力 ------------------------------------------------------------
427
-
428
- contextTokenFor(peerId) { return this.contextTokens.get(peerId) }
429
- setContextToken(peerId, token) { if (token) this.contextTokens.set(peerId, token) }
430
-
431
- /**
432
- * 扫码登录。成功即采用凭据并开始轮询。返回 { success, credentials?, error? }。
433
- * 调用方负责持久化凭据。
434
- */
435
- async loginQr({ onQr, onStatus, timeoutMs } = {}) {
436
- const credentials = await qrLogin({
437
- baseUrl: this.c.baseUrl,
438
- timeoutMs,
439
- pollIntervalMs: this.c.qrPollIntervalMs,
440
- onQr,
441
- onStatus,
442
- })
443
- if (!credentials) return { success: false, error: 'login failed or timed out' }
444
- this.setCredentials(credentials)
445
- return { success: true, credentials }
446
- }
447
-
448
- /**
449
- * 发送一条文本气泡(< maxMessageChars)。分块由上层负责。
450
- * 带逐块重试、会话过期无 token 降级、限流熔断。
451
- */
452
- async sendText(to, text, clientId) {
453
- if (!text.trim()) return { success: false, error: 'empty message' }
454
- if (!this.configured) return { success: false, error: 'not configured' }
455
- let contextToken = this.contextTokens.get(to)
456
- const id = clientId ?? `dsh-bridge-wechat-${randomId()}`
457
- let lastError
458
- let retriedWithoutToken = false
459
-
460
- for (let attempt = 0; attempt <= this.c.sendChunkRetries; attempt++) {
461
- if (this.rateLimitUntil > Date.now()) {
462
- return { success: false, error: 'iLink sendmessage rate limited; cooldown active' }
463
- }
464
- try {
465
- const resp = await sendMessage({
466
- baseUrl: this.c.baseUrl,
467
- token: this.c.token,
468
- to,
469
- text,
470
- contextToken,
471
- clientId: id,
472
- timeoutMs: this.c.apiTimeoutMs,
473
- })
474
- const ret = resp.ret
475
- const errcode = resp.errcode
476
- if ((ret !== undefined && ret !== 0) || (errcode !== undefined && errcode !== 0)) {
477
- const isSessionExpired = ret === SESSION_EXPIRED_ERRCODE || errcode === SESSION_EXPIRED_ERRCODE
478
- || isStaleSessionRet(ret, errcode, resp.errmsg)
479
- if (isSessionExpired) {
480
- if (contextToken && !retriedWithoutToken) {
481
- retriedWithoutToken = true
482
- contextToken = undefined
483
- this.contextTokens.delete(to)
484
- await sleep(this.c.sendChunkRetryDelayMs)
485
- continue
486
- }
487
- lastError = new Error(`iLink sendmessage session expired: ret=${ret} errcode=${errcode}`)
488
- break
489
- }
490
- const isRateLimited = ret === RATE_LIMIT_ERRCODE || errcode === RATE_LIMIT_ERRCODE
491
- if (isRateLimited) {
492
- lastError = new Error(`iLink sendmessage rate limited: ret=${ret} errcode=${errcode} errmsg=${resp.errmsg ?? ''}`)
493
- if (this.recordRateLimit()) break
494
- if (attempt >= this.c.sendChunkRetries) break
495
- await sleep(this.c.sendChunkRetryDelayMs * 3)
496
- continue
497
- }
498
- lastError = new Error(`iLink sendmessage error: ret=${ret} errcode=${errcode} errmsg=${resp.errmsg ?? ''}`)
499
- break
500
- }
501
- this.rateLimitHits = []
502
- return { success: true, messageId: id }
503
- } catch (error) {
504
- lastError = error instanceof Error ? error : new Error(String(error))
505
- if (attempt >= this.c.sendChunkRetries) break
506
- await sleep(this.c.sendChunkRetryDelayMs * (attempt + 1))
507
- }
508
- }
509
- return { success: false, error: lastError?.message ?? 'send failed' }
510
- }
511
-
512
- /**
513
- * 获取媒体上传 URL(v0.2)。
514
- * @param {object} opts
515
- * @param {string} opts.to 接收用户 ID
516
- * @param {number} opts.mediaType 媒体类型(2=图片 3=语音 4=文件 5=视频)
517
- * @param {string} opts.filekey 随机 hex 标识(32 字符)
518
- * @param {number} opts.rawSize 明文大小
519
- * @param {string} opts.rawFileMd5 明文 MD5
520
- * @param {number} opts.fileSize 密文大小(AES 填充后)
521
- * @param {string} opts.aesKeyHex AES key 的 hex 表示(32 字符)
522
- * @returns {Promise<{uploadParam?: string, uploadFullUrl?: string}>}
523
- */
524
- async getUploadUrl({ to, mediaType, filekey, rawSize, rawFileMd5, fileSize, aesKeyHex }) {
525
- if (!this.configured) throw new Error('not configured')
526
- // 映射 MessageItemType 到 UploadMediaType (IMAGE:1, VIDEO:2, FILE:3, VOICE:4)
527
- let uploadMediaType = mediaType
528
- if (mediaType === 2) uploadMediaType = 1 // IMAGE
529
- else if (mediaType === 4) uploadMediaType = 3 // FILE
530
- else if (mediaType === 3) uploadMediaType = 4 // VOICE
531
- else if (mediaType === 5) uploadMediaType = 2 // VIDEO
532
-
533
- const resp = await postJson({
534
- baseUrl: this.c.baseUrl,
535
- endpoint: 'ilink/bot/getuploadurl',
536
- token: this.c.token,
537
- payload: {
538
- filekey,
539
- media_type: uploadMediaType,
540
- to_user_id: to,
541
- rawsize: rawSize,
542
- rawfilemd5: rawFileMd5,
543
- filesize: fileSize,
544
- no_need_thumb: true,
545
- aeskey: aesKeyHex,
546
- },
547
- timeoutMs: this.c.apiTimeoutMs,
548
- })
549
- return {
550
- uploadParam: resp.upload_param,
551
- uploadFullUrl: resp.upload_full_url,
552
- }
553
- }
554
-
555
- /**
556
- * 发送媒体消息(图片/文件/语音/视频)。
557
- * @param {object} opts
558
- * @param {string} opts.to 接收用户 ID
559
- * @param {number} opts.mediaType 媒体类型(2=图片 3=语音 4=文件 5=视频)
560
- * @param {string} opts.encryptedQueryParam CDN 加密参数(上传后获取)
561
- * @param {string} opts.aesKeyBase64 AES key 的 base64(hex) 表示
562
- * @param {number} opts.ciphertextSize 密文大小
563
- * @param {number} opts.plaintextSize 明文大小
564
- * @param {string} opts.filename 文件名
565
- * @param {string} opts.rawFileMd5 明文 MD5
566
- * @param {string} [opts.clientId] 客户端消息 ID
567
- * @returns {Promise<{success: boolean, error?: string, messageId?: string}>}
568
- */
569
- async sendMedia({
570
- to,
571
- mediaType,
572
- encryptedQueryParam,
573
- aesKeyBase64,
574
- aesKeyHex,
575
- ciphertextSize,
576
- plaintextSize,
577
- filename,
578
- rawFileMd5,
579
- clientId,
580
- }) {
581
- if (!this.configured) return { success: false, error: 'not configured' }
582
- const contextToken = this.contextTokens.get(to)
583
- const id = clientId ?? `dsh-bridge-wechat-${randomId()}`
584
- const hexKey = aesKeyHex || (aesKeyBase64 ? Buffer.from(aesKeyBase64, 'base64').toString('hex') : '')
585
-
586
- // 构建媒体项(全字段兼容各端微信客户端解析)
587
- let item
588
- if (mediaType === 2) { // 图片
589
- item = {
590
- type: 2,
591
- image_item: {
592
- media: {
593
- encrypt_query_param: encryptedQueryParam,
594
- aes_key: aesKeyBase64,
595
- aeskey: hexKey,
596
- encrypt_type: 1,
597
- },
598
- aeskey: hexKey,
599
- aes_key: aesKeyBase64,
600
- filesize: ciphertextSize,
601
- rawsize: plaintextSize,
602
- rawfilemd5: rawFileMd5,
603
- },
604
- }
605
- } else if (mediaType === 4) { // 文件
606
- item = {
607
- type: 4,
608
- file_item: {
609
- file_name: filename,
610
- len: String(plaintextSize),
611
- media: {
612
- encrypt_query_param: encryptedQueryParam,
613
- aes_key: aesKeyBase64,
614
- encrypt_type: 1,
615
- },
616
- },
617
- }
618
- } else if (mediaType === 3) { // 语音
619
- item = {
620
- type: 3,
621
- voice_item: {
622
- media: {
623
- encrypt_query_param: encryptedQueryParam,
624
- aes_key: aesKeyBase64,
625
- aeskey: hexKey,
626
- encrypt_type: 0,
627
- },
628
- aeskey: hexKey,
629
- aes_key: aesKeyBase64,
630
- encode_type: 6, // silk
631
- sample_rate: 24000,
632
- bits_per_sample: 16,
633
- },
634
- }
635
- } else if (mediaType === 5) { // 视频
636
- item = {
637
- type: 5,
638
- video_item: {
639
- media: {
640
- encrypt_query_param: encryptedQueryParam,
641
- aes_key: aesKeyBase64,
642
- aeskey: hexKey,
643
- encrypt_type: 1,
644
- },
645
- aeskey: hexKey,
646
- aes_key: aesKeyBase64,
647
- filesize: ciphertextSize,
648
- rawsize: plaintextSize,
649
- rawfilemd5: rawFileMd5,
650
- },
651
- }
652
- } else {
653
- return { success: false, error: `unsupported media type ${mediaType}` }
654
- }
655
-
656
- try {
657
- const resp = await sendMessage({
658
- baseUrl: this.c.baseUrl,
659
- token: this.c.token,
660
- to,
661
- item,
662
- contextToken,
663
- clientId: id,
664
- timeoutMs: this.c.apiTimeoutMs,
665
- })
666
- const ret = resp.ret
667
- const errcode = resp.errcode
668
- if ((ret !== undefined && ret !== 0) || (errcode !== undefined && errcode !== 0)) {
669
- return {
670
- success: false,
671
- error: `iLink sendmessage error: ret=${ret} errcode=${errcode} errmsg=${resp.errmsg ?? ''}`,
672
- }
673
- }
674
- return { success: true, messageId: id }
675
- } catch (error) {
676
- return {
677
- success: false,
678
- error: error instanceof Error ? error.message : String(error),
679
- }
680
- }
681
- }
682
-
683
- /**
684
- * 加密并发送本地媒体文件(图片/文档)到微信
685
- */
686
- async sendMediaFile(to, filePath) {
687
- if (!this.configured || !fs.existsSync(filePath)) return { success: false, error: 'not configured or file not found' }
688
- try {
689
- const buf = await fs.promises.readFile(filePath)
690
- const ext = path.extname(filePath).toLowerCase()
691
- const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
692
- const mediaType = isImage ? 2 : 4
693
- const filename = path.basename(filePath)
694
- const rawFileMd5 = md5(buf)
695
- const aesKey = generateAesKey()
696
- const aesKeyHex = aesKey.toString('hex')
697
- const aesKeyBase64 = encodeAesKeyForApi(aesKey)
698
- const filekey = generateFilekey()
699
- const rawSize = buf.length
700
- const fileSize = aes128PaddedSize(rawSize)
701
-
702
- const uploadInfo = await this.getUploadUrl({
703
- to,
704
- filekey,
705
- mediaType,
706
- rawSize,
707
- rawFileMd5,
708
- fileSize,
709
- aesKeyHex,
710
- })
711
-
712
- const uploadUrl = uploadInfo.uploadFullUrl || `${this.c.cdnBaseUrl.replace(/\/+$/, '')}/upload?encrypted_query_param=${encodeURIComponent(uploadInfo.uploadParam)}&filekey=${encodeURIComponent(filekey)}`
713
-
714
- const encryptedParam = await uploadMedia({
715
- plaintext: buf,
716
- uploadUrl,
717
- aesKey,
718
- })
719
-
720
- return await this.sendMedia({
721
- to,
722
- mediaType,
723
- encryptedQueryParam: encryptedParam,
724
- aesKeyBase64,
725
- aesKeyHex,
726
- ciphertextSize: fileSize,
727
- plaintextSize: rawSize,
728
- filename,
729
- rawFileMd5,
730
- })
731
- } catch (err) {
732
- this.logger?.warn?.('[dsh-bridge wechat] sendMediaFile failed: %s', err?.message ?? err)
733
- return { success: false, error: err?.message }
734
- }
735
- }
736
-
737
- /** 显示/隐藏 typing 指示(尽力而为,失败不致命)。 */
738
- async sendTyping(to, status) {
739
- if (!this.configured) return
740
- const ticket = await this.typingTicket(to)
741
- if (!ticket) return
742
- try {
743
- await sendTyping({
744
- baseUrl: this.c.baseUrl,
745
- token: this.c.token,
746
- toUserId: to,
747
- typingTicket: ticket,
748
- status,
749
- })
750
- } catch { /* typing 是装饰性的 */ }
751
- }
752
-
753
- async typingTicket(peerId) {
754
- const cached = this.typingTickets.get(peerId)
755
- if (cached && Date.now() - cached.at < 600_000) return cached.ticket
756
- try {
757
- const { typingTicket } = await getConfig({
758
- baseUrl: this.c.baseUrl,
759
- token: this.c.token,
760
- userId: peerId,
761
- contextToken: this.contextTokens.get(peerId),
762
- })
763
- if (typingTicket) {
764
- this.typingTickets.set(peerId, { ticket: typingTicket, at: Date.now() })
765
- return typingTicket
766
- }
767
- } catch { /* 非致命 */ }
768
- return undefined
769
- }
770
-
771
- // -------------------------------------------------------------------------
772
- // 轮询循环
773
- // -------------------------------------------------------------------------
774
-
775
- async restart() {
776
- if (this._restartingPromise) return this._restartingPromise
777
- this._restartingPromise = (async () => {
778
- this.stopPollingLocal = true
779
- // 立即中断旧循环的 in-flight 长轮询,避免等待最长 35s 才切换
780
- this._pollAbort?.abort()
781
- const previous = this.pollTask
782
- this.pollTask = null
783
- if (previous) {
784
- try { await previous } catch { /* 被替换 */ }
785
- }
786
- if (!this.configured) {
787
- this.setStatus('idle')
788
- return
789
- }
790
- this.stopPollingLocal = false
791
- this.setStatus('starting')
792
- this.pollTask = this.runPollLoop()
793
- })()
794
- try {
795
- await this._restartingPromise
796
- } finally {
797
- this._restartingPromise = null
798
- }
799
- }
800
-
801
- setStatus(status) {
802
- if (this.statusValue === status) return
803
- this.statusValue = status
804
- try {
805
- this.ctx.emit('wechat/status', status)
806
- } catch { /* emit 失败不致命 */ }
807
- }
808
-
809
- async runPollLoop() {
810
- // 每次轮询循环持有一个独立 abort:stop/restart 时中断 in-flight getUpdates(最长 35s)
811
- const pollAbort = new AbortController()
812
- this._pollAbort = pollAbort
813
- let consecutiveFailures = 0
814
- let timeoutMs = this.c.longPollTimeoutMs
815
- let fatal = false
816
- while (!this.stopPollingLocal) {
817
- try {
818
- const batch = await getUpdates({
819
- baseUrl: this.c.baseUrl,
820
- token: this.c.token,
821
- syncBuf: this.syncBuf,
822
- timeoutMs,
823
- signal: pollAbort.signal,
824
- })
825
- if (this.stopPollingLocal) break
826
-
827
- if (typeof batch.raw.longpolling_timeout_ms === 'number' && batch.raw.longpolling_timeout_ms > 0) {
828
- timeoutMs = batch.raw.longpolling_timeout_ms
829
- }
830
-
831
- const ret = batch.raw.ret
832
- const errcode = batch.raw.errcode
833
- if ((ret !== undefined && ret !== 0 && ret !== null) || (errcode !== undefined && errcode !== 0 && errcode !== null)) {
834
- if (ret === SESSION_EXPIRED_ERRCODE || errcode === SESSION_EXPIRED_ERRCODE
835
- || isStaleSessionRet(ret, errcode, batch.raw.errmsg)) {
836
- this.setStatus('paused')
837
- this.ctx.emit('wechat/error', new Error(`iLink session expired; pausing ${this.c.sessionExpiredPauseMs}ms`))
838
- await sleep(this.c.sessionExpiredPauseMs)
839
- consecutiveFailures = 0
840
- this.setStatus('connected')
841
- continue
842
- }
843
- consecutiveFailures += 1
844
- const backoff = consecutiveFailures >= this.c.maxConsecutiveFailures
845
- ? this.c.backoffDelayMs : this.c.retryDelayMs
846
- this.setStatus(consecutiveFailures >= this.c.maxConsecutiveFailures ? 'reconnecting' : 'connected')
847
- this.ctx.emit('wechat/error', new Error(
848
- `getUpdates failed ret=${ret} errcode=${errcode} errmsg=${batch.raw.errmsg ?? ''} (${consecutiveFailures}/${this.c.maxConsecutiveFailures})`,
849
- ))
850
- if (consecutiveFailures >= this.c.maxConsecutiveFailures) consecutiveFailures = 0
851
- await sleep(backoff)
852
- continue
853
- }
854
-
855
- consecutiveFailures = 0
856
- if (batch.syncBuf) this.syncBuf = batch.syncBuf
857
- if (this.statusValue !== 'connected') {
858
- this.logger?.info?.('[dsh-bridge wechat] connected to iLink platform')
859
- }
860
- if (this.stopPollingLocal) break
861
- this.setStatus('connected')
862
- for (const message of batch.messages) {
863
- if (this.stopPollingLocal) break
864
- this.dispatchInbound(message)
865
- }
866
- if (this.c.pollIdleDelayMs > 0) await sleep(this.c.pollIdleDelayMs)
867
- } catch (error) {
868
- if (this.stopPollingLocal) break
869
- if (error?.httpStatus === 403) {
870
- // iLink 独占锁:同 token 已有别的 poller。响亮报错并停止。
871
- this.setStatus('error')
872
- this.ctx.emit('wechat/fatal', new Error(
873
- 'iLink returned HTTP 403: another poller (hermes-agent, OpenClaw, or a duplicate dsh-bridge WeChat bot) is already polling this account. ' +
874
- 'iLink allows exactly one authenticated poller per token. Stop the other gateway or use a dedicated WeChat account.',
875
- ))
876
- fatal = true
877
- this.stopPollingLocal = true
878
- break
879
- }
880
- consecutiveFailures += 1
881
- const backoff = consecutiveFailures >= this.c.maxConsecutiveFailures
882
- ? this.c.backoffDelayMs : this.c.retryDelayMs
883
- this.setStatus(consecutiveFailures >= this.c.maxConsecutiveFailures ? 'reconnecting' : 'connected')
884
- this.ctx.emit('wechat/error', error instanceof Error ? error : new Error(String(error)))
885
- if (consecutiveFailures >= this.c.maxConsecutiveFailures) consecutiveFailures = 0
886
- await sleep(backoff)
887
- }
888
- }
889
- // 清理当前轮询 abort(避免悬挂引用);致命错误保持终态,普通停止回到 idle
890
- if (this._pollAbort === pollAbort) this._pollAbort = null
891
- if (!fatal) this.setStatus('idle')
892
- }
893
-
894
- // ---- 入站管道(去重 + context token 捕获;策略在上层 node) ---------------
895
-
896
- dispatchInbound(message) {
897
- const sender = String(message.from_user_id ?? '')
898
- const messageId = String(message.message_id ?? '')
899
- if (!sender || sender === this.c.accountId) return
900
- if (messageId && this.isDuplicate(messageId)) return
901
- if (messageId) this.remember(messageId)
902
-
903
- const contextToken = String(message.context_token ?? '')
904
- if (contextToken) {
905
- this.contextTokens.set(sender, contextToken)
906
- this._scheduleTokenPersist()
907
- }
908
-
909
- try {
910
- this.ctx.emit('wechat/message', message)
911
- } catch { /* 上层未订阅时不致命 */ }
912
- }
913
-
914
- // context token 持久化:防抖合并写(内存映射为唯一事实源,整体回写)。
915
- // 此前每条入站消息都 existsSync + readFileSync + writeFileSync 一轮,热路径同步 IO。
916
- _scheduleTokenPersist(delayMs = 2000) {
917
- if (this._persistTokensTimer) return
918
- this._persistTokensTimer = setTimeout(() => {
919
- this._persistTokensTimer = null
920
- this._persistTokensNow()
921
- }, delayMs)
922
- if (this._persistTokensTimer.unref) this._persistTokensTimer.unref()
923
- }
924
-
925
- _persistTokensNow() {
926
- try {
927
- const tokenFile = path.join(process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh'), 'dsh-bridge', 'wechat-context-tokens.json')
928
- fs.mkdirSync(path.dirname(tokenFile), { recursive: true })
929
- fs.writeFileSync(tokenFile, JSON.stringify(Object.fromEntries(this.contextTokens), null, 2), 'utf8')
930
- } catch { /* 持久化失败不致命:内存映射仍在,下次消息会重试 */ }
931
- }
932
-
933
- isDuplicate(id) {
934
- const seen = this.dedup.get(id)
935
- if (seen !== undefined && Date.now() - seen < MESSAGE_DEDUP_TTL_SECONDS * 1000) return true
936
- return false
937
- }
938
-
939
- remember(id) {
940
- this.dedup.set(id, Date.now())
941
- if (this.dedup.size > 512) {
942
- const cutoff = Date.now() - MESSAGE_DEDUP_TTL_SECONDS * 1000
943
- for (const [key, at] of this.dedup) {
944
- if (at < cutoff) this.dedup.delete(key)
945
- }
946
- }
947
- }
948
-
949
- // ---- 限流熔断 ------------------------------------------------------------
950
-
951
- recordRateLimit() {
952
- const now = Date.now()
953
- const windowStart = now - this.c.rateLimitCircuitWindowMs
954
- this.rateLimitHits = this.rateLimitHits.filter((ts) => ts >= windowStart)
955
- this.rateLimitHits.push(now)
956
- if (this.rateLimitHits.length >= this.c.rateLimitCircuitThreshold) {
957
- this.rateLimitUntil = Math.max(this.rateLimitUntil, now + this.c.rateLimitCircuitOpenMs)
958
- return this.rateLimitUntil > now
959
- }
960
- return false
961
- }
962
- }
963
-
964
- function randomId() {
965
- return Math.random().toString(36).slice(2) + Date.now().toString(36)
966
- }
967
-
968
- // 媒体类型常量(v0.2)
969
- const MEDIA_TYPE_IMAGE = 2
970
- const MEDIA_TYPE_VOICE = 3
971
- const MEDIA_TYPE_FILE = 4
972
- const MEDIA_TYPE_VIDEO = 5
973
-
974
- export const gatewayConstants = {
975
- ILINK_BASE_URL,
976
- WEIXIN_CDN_BASE_URL,
977
- MAX_MESSAGE_CHARS,
978
- TYPING_START,
979
- TYPING_STOP,
980
- ITEM_TEXT,
981
- GATEWAY_STATUS,
982
- MEDIA_TYPE_IMAGE,
983
- MEDIA_TYPE_VOICE,
984
- MEDIA_TYPE_FILE,
985
- MEDIA_TYPE_VIDEO,
986
- }
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 } 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 = `${baseUrl.replace(/\/+$/, '')}/${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 = `${baseUrl.replace(/\/+$/, '')}/${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 = {} }) {
296
+ super(ctx, 'wechat')
297
+ this.logger = logger
298
+ this.c = {
299
+ baseUrl: config.baseUrl ?? ILINK_BASE_URL,
300
+ cdnBaseUrl: config.cdnBaseUrl ?? WEIXIN_CDN_BASE_URL,
301
+ token: config.token ?? '',
302
+ accountId: config.accountId ?? '',
303
+ longPollTimeoutMs: config.longPollTimeoutMs ?? LONG_POLL_TIMEOUT_MS,
304
+ apiTimeoutMs: config.apiTimeoutMs ?? API_TIMEOUT_MS,
305
+ pollIdleDelayMs: config.pollIdleDelayMs ?? 0,
306
+ qrPollIntervalMs: config.qrPollIntervalMs ?? 1000,
307
+ retryDelayMs: config.retryDelayMs ?? 2000,
308
+ backoffDelayMs: config.backoffDelayMs ?? 30_000,
309
+ maxConsecutiveFailures: config.maxConsecutiveFailures ?? 3,
310
+ sessionExpiredPauseMs: config.sessionExpiredPauseMs ?? 600_000,
311
+ sendChunkDelayMs: config.sendChunkDelayMs ?? 1500,
312
+ sendChunkRetries: config.sendChunkRetries ?? 4,
313
+ sendChunkRetryDelayMs: config.sendChunkRetryDelayMs ?? 1000,
314
+ rateLimitCircuitOpenMs: config.rateLimitCircuitOpenMs ?? 30_000,
315
+ rateLimitCircuitWindowMs: config.rateLimitCircuitWindowMs ?? 30_000,
316
+ rateLimitCircuitThreshold: config.rateLimitCircuitThreshold ?? 1,
317
+ }
318
+ this.syncBuf = ''
319
+ this.pollTask = null
320
+ this.stopPollingLocal = false
321
+ // 当前轮询循环的中止信号:stop/restart 时 abort 以立即中断 in-flight 长轮询
322
+ this._pollAbort = null
323
+ this.statusValue = 'idle'
324
+ this.contextTokens = new Map()
325
+ try {
326
+ const tokenFile = path.join(process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh'), 'dsh-bridge', 'wechat-context-tokens.json')
327
+ if (fs.existsSync(tokenFile)) {
328
+ const data = JSON.parse(fs.readFileSync(tokenFile, 'utf8'))
329
+ for (const [k, v] of Object.entries(data)) {
330
+ if (v) this.contextTokens.set(k, String(v))
331
+ }
332
+ }
333
+ } catch {}
334
+ this.dedup = new Map()
335
+ this.typingTickets = new Map()
336
+ this.rateLimitHits = []
337
+ this.rateLimitUntil = 0
338
+ this._disposed = false
339
+ // 内存泄漏防护:每 5 分钟清理过期缓存
340
+ this.cleanupInterval = setInterval(() => this._cleanupMaps(), 300_000)
341
+ this._persistTokensTimer = null
342
+ }
343
+
344
+ _cleanupMaps() {
345
+ const now = Date.now()
346
+ // 清理 contextTokens:超过 1 小时未使用的删除
347
+ const contextTokenTtl = 3600_000
348
+ // 清理 typingTickets:超过 30 秒的删除
349
+ const typingTicketTtl = 30_000
350
+
351
+ // contextTokens 没有时间戳,保守策略:如果 Map 过大才清理(超过 100 个)
352
+ if (this.contextTokens.size > 100) {
353
+ this.logger?.warn(`contextTokens Map 过大 (${this.contextTokens.size}),清理旧数据`)
354
+ // 保留最近 50 个,删除其余
355
+ const entries = Array.from(this.contextTokens.entries())
356
+ this.contextTokens.clear()
357
+ entries.slice(-50).forEach(([k, v]) => this.contextTokens.set(k, v))
358
+ }
359
+
360
+ // 清理 typingTickets
361
+ for (const [peerId, ticket] of this.typingTickets) {
362
+ if (now - ticket.at > typingTicketTtl) {
363
+ this.typingTickets.delete(peerId)
364
+ }
365
+ }
366
+ }
367
+
368
+ // ---- 状态访问器 ----------------------------------------------------------
369
+
370
+ get status() { return this.statusValue }
371
+ get configured() { return Boolean(this.c.token && this.c.accountId) }
372
+ get accountId() { return this.c.accountId }
373
+ get baseUrl() { return this.c.baseUrl }
374
+
375
+ // ---- 生命周期 ------------------------------------------------------------
376
+
377
+ /** 运行中由外部持有 setTimeout 等资源;dispose 停止轮询。 */
378
+ dispose() {
379
+ this._disposed = true
380
+ this.stopPollingLocal = true
381
+ if (this.cleanupInterval) {
382
+ clearInterval(this.cleanupInterval)
383
+ this.cleanupInterval = null
384
+ }
385
+ // 退出前冲刷未落盘的 context token
386
+ if (this._persistTokensTimer) {
387
+ clearTimeout(this._persistTokensTimer)
388
+ this._persistTokensTimer = null
389
+ this._persistTokensNow()
390
+ }
391
+ void this.stop()
392
+ }
393
+
394
+ async stop() {
395
+ this.stopPollingLocal = true
396
+ this._pollAbort?.abort()
397
+ const task = this.pollTask
398
+ this.pollTask = null
399
+ if (task) {
400
+ try { await task } catch { /* 轮询错误通过事件暴露,不在此抛出 */ }
401
+ }
402
+ this.setStatus('idle')
403
+ }
404
+
405
+ async start() {
406
+ if (this._startingPromise) return this._startingPromise
407
+ if (!this.configured) {
408
+ this.setStatus('idle')
409
+ return
410
+ }
411
+ this._startingPromise = this.restart()
412
+ try {
413
+ await this._startingPromise
414
+ } finally {
415
+ this._startingPromise = null
416
+ }
417
+ }
418
+
419
+ setCredentials({ token, accountId, baseUrl } = {}) {
420
+ if (token !== undefined) this.c.token = token
421
+ if (accountId !== undefined) this.c.accountId = accountId
422
+ if (baseUrl !== undefined) this.c.baseUrl = baseUrl
423
+ void this.restart()
424
+ }
425
+
426
+ // ---- 对外能力 ------------------------------------------------------------
427
+
428
+ contextTokenFor(peerId) { return this.contextTokens.get(peerId) }
429
+ setContextToken(peerId, token) { if (token) this.contextTokens.set(peerId, token) }
430
+
431
+ /**
432
+ * 扫码登录。成功即采用凭据并开始轮询。返回 { success, credentials?, error? }。
433
+ * 调用方负责持久化凭据。
434
+ */
435
+ async loginQr({ onQr, onStatus, timeoutMs } = {}) {
436
+ const credentials = await qrLogin({
437
+ baseUrl: this.c.baseUrl,
438
+ timeoutMs,
439
+ pollIntervalMs: this.c.qrPollIntervalMs,
440
+ onQr,
441
+ onStatus,
442
+ })
443
+ if (!credentials) return { success: false, error: 'login failed or timed out' }
444
+ this.setCredentials(credentials)
445
+ return { success: true, credentials }
446
+ }
447
+
448
+ /**
449
+ * 发送一条文本气泡(< maxMessageChars)。分块由上层负责。
450
+ * 带逐块重试、会话过期无 token 降级、限流熔断。
451
+ */
452
+ async sendText(to, text, clientId) {
453
+ if (!text.trim()) return { success: false, error: 'empty message' }
454
+ if (!this.configured) return { success: false, error: 'not configured' }
455
+ let contextToken = this.contextTokens.get(to)
456
+ const id = clientId ?? `dsh-bridge-wechat-${randomId()}`
457
+ let lastError
458
+ let retriedWithoutToken = false
459
+
460
+ for (let attempt = 0; attempt <= this.c.sendChunkRetries; attempt++) {
461
+ if (this.rateLimitUntil > Date.now()) {
462
+ return { success: false, error: 'iLink sendmessage rate limited; cooldown active' }
463
+ }
464
+ try {
465
+ const resp = await sendMessage({
466
+ baseUrl: this.c.baseUrl,
467
+ token: this.c.token,
468
+ to,
469
+ text,
470
+ contextToken,
471
+ clientId: id,
472
+ timeoutMs: this.c.apiTimeoutMs,
473
+ })
474
+ const ret = resp.ret
475
+ const errcode = resp.errcode
476
+ if ((ret !== undefined && ret !== 0) || (errcode !== undefined && errcode !== 0)) {
477
+ const isSessionExpired = ret === SESSION_EXPIRED_ERRCODE || errcode === SESSION_EXPIRED_ERRCODE
478
+ || isStaleSessionRet(ret, errcode, resp.errmsg)
479
+ if (isSessionExpired) {
480
+ if (contextToken && !retriedWithoutToken) {
481
+ retriedWithoutToken = true
482
+ contextToken = undefined
483
+ this.contextTokens.delete(to)
484
+ await sleep(this.c.sendChunkRetryDelayMs)
485
+ continue
486
+ }
487
+ lastError = new Error(`iLink sendmessage session expired: ret=${ret} errcode=${errcode}`)
488
+ break
489
+ }
490
+ const isRateLimited = ret === RATE_LIMIT_ERRCODE || errcode === RATE_LIMIT_ERRCODE
491
+ if (isRateLimited) {
492
+ lastError = new Error(`iLink sendmessage rate limited: ret=${ret} errcode=${errcode} errmsg=${resp.errmsg ?? ''}`)
493
+ if (this.recordRateLimit()) break
494
+ if (attempt >= this.c.sendChunkRetries) break
495
+ await sleep(this.c.sendChunkRetryDelayMs * 3)
496
+ continue
497
+ }
498
+ lastError = new Error(`iLink sendmessage error: ret=${ret} errcode=${errcode} errmsg=${resp.errmsg ?? ''}`)
499
+ break
500
+ }
501
+ this.rateLimitHits = []
502
+ return { success: true, messageId: id }
503
+ } catch (error) {
504
+ lastError = error instanceof Error ? error : new Error(String(error))
505
+ if (attempt >= this.c.sendChunkRetries) break
506
+ await sleep(this.c.sendChunkRetryDelayMs * (attempt + 1))
507
+ }
508
+ }
509
+ return { success: false, error: lastError?.message ?? 'send failed' }
510
+ }
511
+
512
+ /**
513
+ * 获取媒体上传 URL(v0.2)。
514
+ * @param {object} opts
515
+ * @param {string} opts.to 接收用户 ID
516
+ * @param {number} opts.mediaType 媒体类型(2=图片 3=语音 4=文件 5=视频)
517
+ * @param {string} opts.filekey 随机 hex 标识(32 字符)
518
+ * @param {number} opts.rawSize 明文大小
519
+ * @param {string} opts.rawFileMd5 明文 MD5
520
+ * @param {number} opts.fileSize 密文大小(AES 填充后)
521
+ * @param {string} opts.aesKeyHex AES key 的 hex 表示(32 字符)
522
+ * @returns {Promise<{uploadParam?: string, uploadFullUrl?: string}>}
523
+ */
524
+ async getUploadUrl({ to, mediaType, filekey, rawSize, rawFileMd5, fileSize, aesKeyHex }) {
525
+ if (!this.configured) throw new Error('not configured')
526
+ // 映射 MessageItemType 到 UploadMediaType (IMAGE:1, VIDEO:2, FILE:3, VOICE:4)
527
+ let uploadMediaType = mediaType
528
+ if (mediaType === 2) uploadMediaType = 1 // IMAGE
529
+ else if (mediaType === 4) uploadMediaType = 3 // FILE
530
+ else if (mediaType === 3) uploadMediaType = 4 // VOICE
531
+ else if (mediaType === 5) uploadMediaType = 2 // VIDEO
532
+
533
+ const resp = await postJson({
534
+ baseUrl: this.c.baseUrl,
535
+ endpoint: 'ilink/bot/getuploadurl',
536
+ token: this.c.token,
537
+ payload: {
538
+ filekey,
539
+ media_type: uploadMediaType,
540
+ to_user_id: to,
541
+ rawsize: rawSize,
542
+ rawfilemd5: rawFileMd5,
543
+ filesize: fileSize,
544
+ no_need_thumb: true,
545
+ aeskey: aesKeyHex,
546
+ },
547
+ timeoutMs: this.c.apiTimeoutMs,
548
+ })
549
+ return {
550
+ uploadParam: resp.upload_param,
551
+ uploadFullUrl: resp.upload_full_url,
552
+ }
553
+ }
554
+
555
+ /**
556
+ * 发送媒体消息(图片/文件/语音/视频)。
557
+ * @param {object} opts
558
+ * @param {string} opts.to 接收用户 ID
559
+ * @param {number} opts.mediaType 媒体类型(2=图片 3=语音 4=文件 5=视频)
560
+ * @param {string} opts.encryptedQueryParam CDN 加密参数(上传后获取)
561
+ * @param {string} opts.aesKeyBase64 AES key 的 base64(hex) 表示
562
+ * @param {number} opts.ciphertextSize 密文大小
563
+ * @param {number} opts.plaintextSize 明文大小
564
+ * @param {string} opts.filename 文件名
565
+ * @param {string} opts.rawFileMd5 明文 MD5
566
+ * @param {string} [opts.clientId] 客户端消息 ID
567
+ * @returns {Promise<{success: boolean, error?: string, messageId?: string}>}
568
+ */
569
+ async sendMedia({
570
+ to,
571
+ mediaType,
572
+ encryptedQueryParam,
573
+ aesKeyBase64,
574
+ aesKeyHex,
575
+ ciphertextSize,
576
+ plaintextSize,
577
+ filename,
578
+ rawFileMd5,
579
+ clientId,
580
+ }) {
581
+ if (!this.configured) return { success: false, error: 'not configured' }
582
+ const contextToken = this.contextTokens.get(to)
583
+ const id = clientId ?? `dsh-bridge-wechat-${randomId()}`
584
+ const hexKey = aesKeyHex || (aesKeyBase64 ? Buffer.from(aesKeyBase64, 'base64').toString('hex') : '')
585
+
586
+ // 构建媒体项(全字段兼容各端微信客户端解析)
587
+ let item
588
+ if (mediaType === 2) { // 图片
589
+ item = {
590
+ type: 2,
591
+ image_item: {
592
+ media: {
593
+ encrypt_query_param: encryptedQueryParam,
594
+ aes_key: aesKeyBase64,
595
+ aeskey: hexKey,
596
+ encrypt_type: 1,
597
+ },
598
+ aeskey: hexKey,
599
+ aes_key: aesKeyBase64,
600
+ filesize: ciphertextSize,
601
+ rawsize: plaintextSize,
602
+ rawfilemd5: rawFileMd5,
603
+ },
604
+ }
605
+ } else if (mediaType === 4) { // 文件
606
+ item = {
607
+ type: 4,
608
+ file_item: {
609
+ file_name: filename,
610
+ len: String(plaintextSize),
611
+ media: {
612
+ encrypt_query_param: encryptedQueryParam,
613
+ aes_key: aesKeyBase64,
614
+ encrypt_type: 1,
615
+ },
616
+ },
617
+ }
618
+ } else if (mediaType === 3) { // 语音
619
+ item = {
620
+ type: 3,
621
+ voice_item: {
622
+ media: {
623
+ encrypt_query_param: encryptedQueryParam,
624
+ aes_key: aesKeyBase64,
625
+ aeskey: hexKey,
626
+ encrypt_type: 0,
627
+ },
628
+ aeskey: hexKey,
629
+ aes_key: aesKeyBase64,
630
+ encode_type: 6, // silk
631
+ sample_rate: 24000,
632
+ bits_per_sample: 16,
633
+ },
634
+ }
635
+ } else if (mediaType === 5) { // 视频
636
+ item = {
637
+ type: 5,
638
+ video_item: {
639
+ media: {
640
+ encrypt_query_param: encryptedQueryParam,
641
+ aes_key: aesKeyBase64,
642
+ aeskey: hexKey,
643
+ encrypt_type: 1,
644
+ },
645
+ aeskey: hexKey,
646
+ aes_key: aesKeyBase64,
647
+ filesize: ciphertextSize,
648
+ rawsize: plaintextSize,
649
+ rawfilemd5: rawFileMd5,
650
+ },
651
+ }
652
+ } else {
653
+ return { success: false, error: `unsupported media type ${mediaType}` }
654
+ }
655
+
656
+ try {
657
+ const resp = await sendMessage({
658
+ baseUrl: this.c.baseUrl,
659
+ token: this.c.token,
660
+ to,
661
+ item,
662
+ contextToken,
663
+ clientId: id,
664
+ timeoutMs: this.c.apiTimeoutMs,
665
+ })
666
+ const ret = resp.ret
667
+ const errcode = resp.errcode
668
+ if ((ret !== undefined && ret !== 0) || (errcode !== undefined && errcode !== 0)) {
669
+ return {
670
+ success: false,
671
+ error: `iLink sendmessage error: ret=${ret} errcode=${errcode} errmsg=${resp.errmsg ?? ''}`,
672
+ }
673
+ }
674
+ return { success: true, messageId: id }
675
+ } catch (error) {
676
+ return {
677
+ success: false,
678
+ error: error instanceof Error ? error.message : String(error),
679
+ }
680
+ }
681
+ }
682
+
683
+ /**
684
+ * 加密并发送本地媒体文件(图片/文档)到微信
685
+ */
686
+ async sendMediaFile(to, filePath) {
687
+ if (!this.configured || !fs.existsSync(filePath)) return { success: false, error: 'not configured or file not found' }
688
+ try {
689
+ const buf = await fs.promises.readFile(filePath)
690
+ const ext = path.extname(filePath).toLowerCase()
691
+ const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
692
+ const mediaType = isImage ? 2 : 4
693
+ const filename = path.basename(filePath)
694
+ const rawFileMd5 = md5(buf)
695
+ const aesKey = generateAesKey()
696
+ const aesKeyHex = aesKey.toString('hex')
697
+ const aesKeyBase64 = encodeAesKeyForApi(aesKey)
698
+ const filekey = generateFilekey()
699
+ const rawSize = buf.length
700
+ const fileSize = aes128PaddedSize(rawSize)
701
+
702
+ const uploadInfo = await this.getUploadUrl({
703
+ to,
704
+ filekey,
705
+ mediaType,
706
+ rawSize,
707
+ rawFileMd5,
708
+ fileSize,
709
+ aesKeyHex,
710
+ })
711
+
712
+ const uploadUrl = uploadInfo.uploadFullUrl || `${this.c.cdnBaseUrl.replace(/\/+$/, '')}/upload?encrypted_query_param=${encodeURIComponent(uploadInfo.uploadParam)}&filekey=${encodeURIComponent(filekey)}`
713
+
714
+ const encryptedParam = await uploadMedia({
715
+ plaintext: buf,
716
+ uploadUrl,
717
+ aesKey,
718
+ })
719
+
720
+ return await this.sendMedia({
721
+ to,
722
+ mediaType,
723
+ encryptedQueryParam: encryptedParam,
724
+ aesKeyBase64,
725
+ aesKeyHex,
726
+ ciphertextSize: fileSize,
727
+ plaintextSize: rawSize,
728
+ filename,
729
+ rawFileMd5,
730
+ })
731
+ } catch (err) {
732
+ this.logger?.warn?.('[dsh-bridge wechat] sendMediaFile failed: %s', err?.message ?? err)
733
+ return { success: false, error: err?.message }
734
+ }
735
+ }
736
+
737
+ /** 显示/隐藏 typing 指示(尽力而为,失败不致命)。 */
738
+ async sendTyping(to, status) {
739
+ if (!this.configured) return
740
+ const ticket = await this.typingTicket(to)
741
+ if (!ticket) return
742
+ try {
743
+ await sendTyping({
744
+ baseUrl: this.c.baseUrl,
745
+ token: this.c.token,
746
+ toUserId: to,
747
+ typingTicket: ticket,
748
+ status,
749
+ })
750
+ } catch { /* typing 是装饰性的 */ }
751
+ }
752
+
753
+ async typingTicket(peerId) {
754
+ const cached = this.typingTickets.get(peerId)
755
+ if (cached && Date.now() - cached.at < 600_000) return cached.ticket
756
+ try {
757
+ const { typingTicket } = await getConfig({
758
+ baseUrl: this.c.baseUrl,
759
+ token: this.c.token,
760
+ userId: peerId,
761
+ contextToken: this.contextTokens.get(peerId),
762
+ })
763
+ if (typingTicket) {
764
+ this.typingTickets.set(peerId, { ticket: typingTicket, at: Date.now() })
765
+ return typingTicket
766
+ }
767
+ } catch { /* 非致命 */ }
768
+ return undefined
769
+ }
770
+
771
+ // -------------------------------------------------------------------------
772
+ // 轮询循环
773
+ // -------------------------------------------------------------------------
774
+
775
+ async restart() {
776
+ if (this._restartingPromise) return this._restartingPromise
777
+ this._restartingPromise = (async () => {
778
+ this.stopPollingLocal = true
779
+ // 立即中断旧循环的 in-flight 长轮询,避免等待最长 35s 才切换
780
+ this._pollAbort?.abort()
781
+ const previous = this.pollTask
782
+ this.pollTask = null
783
+ if (previous) {
784
+ try { await previous } catch { /* 被替换 */ }
785
+ }
786
+ if (!this.configured) {
787
+ this.setStatus('idle')
788
+ return
789
+ }
790
+ this.stopPollingLocal = false
791
+ this.setStatus('starting')
792
+ this.pollTask = this.runPollLoop()
793
+ })()
794
+ try {
795
+ await this._restartingPromise
796
+ } finally {
797
+ this._restartingPromise = null
798
+ }
799
+ }
800
+
801
+ setStatus(status) {
802
+ if (this.statusValue === status) return
803
+ this.statusValue = status
804
+ try {
805
+ this.ctx.emit('wechat/status', status)
806
+ } catch { /* emit 失败不致命 */ }
807
+ }
808
+
809
+ async runPollLoop() {
810
+ // 每次轮询循环持有一个独立 abort:stop/restart 时中断 in-flight getUpdates(最长 35s)
811
+ const pollAbort = new AbortController()
812
+ this._pollAbort = pollAbort
813
+ let consecutiveFailures = 0
814
+ let timeoutMs = this.c.longPollTimeoutMs
815
+ let fatal = false
816
+ while (!this.stopPollingLocal) {
817
+ try {
818
+ const batch = await getUpdates({
819
+ baseUrl: this.c.baseUrl,
820
+ token: this.c.token,
821
+ syncBuf: this.syncBuf,
822
+ timeoutMs,
823
+ signal: pollAbort.signal,
824
+ })
825
+ if (this.stopPollingLocal) break
826
+
827
+ if (typeof batch.raw.longpolling_timeout_ms === 'number' && batch.raw.longpolling_timeout_ms > 0) {
828
+ timeoutMs = batch.raw.longpolling_timeout_ms
829
+ }
830
+
831
+ const ret = batch.raw.ret
832
+ const errcode = batch.raw.errcode
833
+ if ((ret !== undefined && ret !== 0 && ret !== null) || (errcode !== undefined && errcode !== 0 && errcode !== null)) {
834
+ if (ret === SESSION_EXPIRED_ERRCODE || errcode === SESSION_EXPIRED_ERRCODE
835
+ || isStaleSessionRet(ret, errcode, batch.raw.errmsg)) {
836
+ this.setStatus('paused')
837
+ this.ctx.emit('wechat/error', new Error(`iLink session expired; pausing ${this.c.sessionExpiredPauseMs}ms`))
838
+ await sleep(this.c.sessionExpiredPauseMs)
839
+ consecutiveFailures = 0
840
+ this.setStatus('connected')
841
+ continue
842
+ }
843
+ consecutiveFailures += 1
844
+ const backoff = consecutiveFailures >= this.c.maxConsecutiveFailures
845
+ ? this.c.backoffDelayMs : this.c.retryDelayMs
846
+ this.setStatus(consecutiveFailures >= this.c.maxConsecutiveFailures ? 'reconnecting' : 'connected')
847
+ this.ctx.emit('wechat/error', new Error(
848
+ `getUpdates failed ret=${ret} errcode=${errcode} errmsg=${batch.raw.errmsg ?? ''} (${consecutiveFailures}/${this.c.maxConsecutiveFailures})`,
849
+ ))
850
+ if (consecutiveFailures >= this.c.maxConsecutiveFailures) consecutiveFailures = 0
851
+ await sleep(backoff)
852
+ continue
853
+ }
854
+
855
+ consecutiveFailures = 0
856
+ if (batch.syncBuf) this.syncBuf = batch.syncBuf
857
+ if (this.statusValue !== 'connected') {
858
+ this.logger?.info?.('[dsh-bridge wechat] connected to iLink platform')
859
+ }
860
+ if (this.stopPollingLocal) break
861
+ this.setStatus('connected')
862
+ for (const message of batch.messages) {
863
+ if (this.stopPollingLocal) break
864
+ this.dispatchInbound(message)
865
+ }
866
+ if (this.c.pollIdleDelayMs > 0) await sleep(this.c.pollIdleDelayMs)
867
+ } catch (error) {
868
+ if (this.stopPollingLocal) break
869
+ if (error?.httpStatus === 403) {
870
+ // iLink 独占锁:同 token 已有别的 poller。响亮报错并停止。
871
+ this.setStatus('error')
872
+ this.ctx.emit('wechat/fatal', new Error(
873
+ 'iLink returned HTTP 403: another poller (hermes-agent, OpenClaw, or a duplicate dsh-bridge WeChat bot) is already polling this account. ' +
874
+ 'iLink allows exactly one authenticated poller per token. Stop the other gateway or use a dedicated WeChat account.',
875
+ ))
876
+ fatal = true
877
+ this.stopPollingLocal = true
878
+ break
879
+ }
880
+ consecutiveFailures += 1
881
+ const backoff = consecutiveFailures >= this.c.maxConsecutiveFailures
882
+ ? this.c.backoffDelayMs : this.c.retryDelayMs
883
+ this.setStatus(consecutiveFailures >= this.c.maxConsecutiveFailures ? 'reconnecting' : 'connected')
884
+ this.ctx.emit('wechat/error', error instanceof Error ? error : new Error(String(error)))
885
+ if (consecutiveFailures >= this.c.maxConsecutiveFailures) consecutiveFailures = 0
886
+ await sleep(backoff)
887
+ }
888
+ }
889
+ // 清理当前轮询 abort(避免悬挂引用);致命错误保持终态,普通停止回到 idle
890
+ if (this._pollAbort === pollAbort) this._pollAbort = null
891
+ if (!fatal) this.setStatus('idle')
892
+ }
893
+
894
+ // ---- 入站管道(去重 + context token 捕获;策略在上层 node) ---------------
895
+
896
+ dispatchInbound(message) {
897
+ const sender = String(message.from_user_id ?? '')
898
+ const messageId = String(message.message_id ?? '')
899
+ if (!sender || sender === this.c.accountId) return
900
+ if (messageId && this.isDuplicate(messageId)) return
901
+ if (messageId) this.remember(messageId)
902
+
903
+ const contextToken = String(message.context_token ?? '')
904
+ if (contextToken) {
905
+ this.contextTokens.set(sender, contextToken)
906
+ this._scheduleTokenPersist()
907
+ }
908
+
909
+ try {
910
+ this.ctx.emit('wechat/message', message)
911
+ } catch { /* 上层未订阅时不致命 */ }
912
+ }
913
+
914
+ // context token 持久化:防抖合并写(内存映射为唯一事实源,整体回写)。
915
+ // 此前每条入站消息都 existsSync + readFileSync + writeFileSync 一轮,热路径同步 IO。
916
+ _scheduleTokenPersist(delayMs = 2000) {
917
+ if (this._persistTokensTimer) return
918
+ this._persistTokensTimer = setTimeout(() => {
919
+ this._persistTokensTimer = null
920
+ this._persistTokensNow()
921
+ }, delayMs)
922
+ if (this._persistTokensTimer.unref) this._persistTokensTimer.unref()
923
+ }
924
+
925
+ _persistTokensNow() {
926
+ try {
927
+ const tokenFile = path.join(process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh'), 'dsh-bridge', 'wechat-context-tokens.json')
928
+ fs.mkdirSync(path.dirname(tokenFile), { recursive: true })
929
+ fs.writeFileSync(tokenFile, JSON.stringify(Object.fromEntries(this.contextTokens), null, 2), 'utf8')
930
+ } catch { /* 持久化失败不致命:内存映射仍在,下次消息会重试 */ }
931
+ }
932
+
933
+ isDuplicate(id) {
934
+ const seen = this.dedup.get(id)
935
+ if (seen !== undefined && Date.now() - seen < MESSAGE_DEDUP_TTL_SECONDS * 1000) return true
936
+ return false
937
+ }
938
+
939
+ remember(id) {
940
+ this.dedup.set(id, Date.now())
941
+ if (this.dedup.size > 512) {
942
+ const cutoff = Date.now() - MESSAGE_DEDUP_TTL_SECONDS * 1000
943
+ for (const [key, at] of this.dedup) {
944
+ if (at < cutoff) this.dedup.delete(key)
945
+ }
946
+ }
947
+ }
948
+
949
+ // ---- 限流熔断 ------------------------------------------------------------
950
+
951
+ recordRateLimit() {
952
+ const now = Date.now()
953
+ const windowStart = now - this.c.rateLimitCircuitWindowMs
954
+ this.rateLimitHits = this.rateLimitHits.filter((ts) => ts >= windowStart)
955
+ this.rateLimitHits.push(now)
956
+ if (this.rateLimitHits.length >= this.c.rateLimitCircuitThreshold) {
957
+ this.rateLimitUntil = Math.max(this.rateLimitUntil, now + this.c.rateLimitCircuitOpenMs)
958
+ return this.rateLimitUntil > now
959
+ }
960
+ return false
961
+ }
962
+ }
963
+
964
+ function randomId() {
965
+ return Math.random().toString(36).slice(2) + Date.now().toString(36)
966
+ }
967
+
968
+ // 媒体类型常量(v0.2)
969
+ const MEDIA_TYPE_IMAGE = 2
970
+ const MEDIA_TYPE_VOICE = 3
971
+ const MEDIA_TYPE_FILE = 4
972
+ const MEDIA_TYPE_VIDEO = 5
973
+
974
+ export const gatewayConstants = {
975
+ ILINK_BASE_URL,
976
+ WEIXIN_CDN_BASE_URL,
977
+ MAX_MESSAGE_CHARS,
978
+ TYPING_START,
979
+ TYPING_STOP,
980
+ ITEM_TEXT,
981
+ GATEWAY_STATUS,
982
+ MEDIA_TYPE_IMAGE,
983
+ MEDIA_TYPE_VOICE,
984
+ MEDIA_TYPE_FILE,
985
+ MEDIA_TYPE_VIDEO,
986
+ }