@wenbin_wb/dsh-bridge 2.8.7 → 2.9.0

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