@wenbin_wb/dsh-bridge 1.0.8 → 1.2.0

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