@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.
- package/CHANGELOG.md +295 -250
- package/README.en.md +408 -572
- package/README.md +430 -570
- package/client/client.js +3844 -3764
- package/client/index.js +140 -876
- package/client/mobile-styles.js +802 -0
- package/docs/fix-plan-202608.md +108 -0
- package/lib/auth/login-template.js +381 -381
- package/lib/auth/manager.js +531 -469
- package/lib/bridge-rpc.js +436 -511
- package/lib/cloudflared-manager.mjs +361 -345
- package/lib/compat.js +129 -0
- package/lib/feishu/index.js +225 -222
- package/lib/feishu/node.js +433 -409
- package/lib/index.js +1765 -1804
- package/lib/platform/base.js +147 -156
- package/lib/platform/commands.js +221 -0
- package/lib/platform/conversation-bridge.js +816 -1570
- package/lib/platform/dsh-storage.js +117 -0
- package/lib/platform/index.js +10 -10
- package/lib/platform/message-split.js +191 -0
- package/lib/platform/session-catalog.js +372 -0
- package/lib/platform/stream-slices.js +21 -0
- package/lib/qq/index.js +312 -309
- package/lib/qq/node.js +532 -533
- package/lib/telegram/index.js +215 -212
- package/lib/telegram/node.js +348 -350
- package/lib/tunnel-client.mjs +39 -15
- package/lib/wechat/gateway.js +973 -960
- package/lib/wechat/index.js +244 -241
- package/lib/wechat/media.js +285 -281
- package/lib/wechat/node.js +352 -350
- package/package.json +106 -102
package/lib/wechat/gateway.js
CHANGED
|
@@ -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
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
'
|
|
75
|
-
|
|
76
|
-
'
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
let
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
*
|
|
289
|
-
* @param {object} opts
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
this.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
this.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
this.
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
if (
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
+
}
|