@seamnet/client 0.12.4

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,736 @@
1
+ /**
2
+ * Seam WeChat Plugin — iLink Bot 微信通道
3
+ *
4
+ * 契约(Plugin v1):
5
+ * - Actions:
6
+ * wechat_send : 发送微信消息(用当前 context_token)
7
+ * wechat_login : 启动扫码登录流程(生成二维码通过 IM 发给触发者)
8
+ * wechat_status : 查询绑定状态
9
+ *
10
+ * 依赖:
11
+ * - `im` 插件(通过 hub.get('im') 发送二维码图片)
12
+ * - `event-bus` service(订阅 'im.message' 事件触发扫码)
13
+ * - `state` service(持久化 binding)
14
+ *
15
+ * 错误码:
16
+ * - WECHAT_NOT_BOUND : 未绑定或还没收到首条消息(缺 context_token)
17
+ * - WECHAT_QR_FAILED : 获取二维码失败
18
+ * - WECHAT_QR_EXPIRED : 扫码超时
19
+ * - WECHAT_SESSION_FROZEN : bot 被风控(-14)
20
+ * - WECHAT_UNKNOWN_ACTION : 未知 action
21
+ */
22
+
23
+ const crypto = require('node:crypto');
24
+ const path = require('node:path');
25
+ const nodeCrypto = require('node:crypto');
26
+ const { seamError, wrap } = require('../../errors.cjs');
27
+ const { validatePayload } = require('../../contracts/actions.cjs');
28
+
29
+ /**
30
+ * 从 item 里解析 AES 密钥(返回 hex string)
31
+ * - image_item 外层有 aeskey (hex 格式)
32
+ * - media.aes_key 有两种编码:
33
+ * - 图片: base64(raw 16 bytes) → decode 后 16 字节,hex encode
34
+ * - 文件/语音/视频: base64(32 char hex string) → decode 后是 hex 字符串
35
+ */
36
+ function resolveAesKeyHex(item) {
37
+ if (!item) return null;
38
+ if (item.aeskey) return item.aeskey;
39
+ if (item.media?.aes_key) {
40
+ try {
41
+ const decoded = Buffer.from(item.media.aes_key, 'base64');
42
+ if (decoded.length === 32) {
43
+ // 可能是 hex string 作 UTF-8 写入
44
+ const asStr = decoded.toString('utf8');
45
+ if (/^[0-9a-fA-F]{32}$/.test(asStr)) return asStr.toLowerCase();
46
+ }
47
+ if (decoded.length === 16) {
48
+ return decoded.toString('hex');
49
+ }
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * iLink 图片/文件加密下载 + AES-128-ECB 解密
59
+ * @param {Object} item - image_item 或 file_item(包含 media 和 aeskey 信息)
60
+ * @returns {Promise<Buffer>} 解密后的二进制数据
61
+ */
62
+ async function downloadAndDecrypt(item) {
63
+ const media = item?.media;
64
+ const aeskeyHex = resolveAesKeyHex(item);
65
+ if (!media?.full_url) throw new Error('media.full_url missing');
66
+ if (!aeskeyHex) throw new Error('aeskey missing (no item.aeskey or media.aes_key)');
67
+ const resp = await fetch(media.full_url);
68
+ if (!resp.ok) throw new Error(`download failed ${resp.status}`);
69
+ const encrypted = Buffer.from(await resp.arrayBuffer());
70
+ const key = Buffer.from(aeskeyHex, 'hex');
71
+ if (key.length !== 16) throw new Error(`bad aeskey length ${key.length} (hex: ${aeskeyHex})`);
72
+ const dec = nodeCrypto.createDecipheriv('aes-128-ecb', key, null);
73
+ return Buffer.concat([dec.update(encrypted), dec.final()]);
74
+ }
75
+
76
+ const WECHAT_API_BASE = 'https://ilinkai.weixin.qq.com/';
77
+
78
+ function randomWechatUin() {
79
+ const uint32 = crypto.randomBytes(4).readUInt32BE(0);
80
+ return Buffer.from(String(uint32), 'utf-8').toString('base64');
81
+ }
82
+
83
+ function buildHeaders(token) {
84
+ const headers = {
85
+ 'Content-Type': 'application/json',
86
+ AuthorizationType: 'ilink_bot_token',
87
+ 'X-WECHAT-UIN': randomWechatUin(),
88
+ };
89
+ if (token) {
90
+ headers['Authorization'] = `Bearer ${token}`;
91
+ }
92
+ return headers;
93
+ }
94
+
95
+ async function apiPost(endpoint, body, token, timeoutMs = 15000) {
96
+ const url = `${WECHAT_API_BASE}${endpoint}`;
97
+ const bodyStr = JSON.stringify(body);
98
+ const controller = new AbortController();
99
+ const t = setTimeout(() => controller.abort(), timeoutMs);
100
+ try {
101
+ const res = await fetch(url, {
102
+ method: 'POST',
103
+ headers: {
104
+ ...buildHeaders(token),
105
+ 'Content-Length': String(Buffer.byteLength(bodyStr)),
106
+ },
107
+ body: bodyStr,
108
+ signal: controller.signal,
109
+ });
110
+ clearTimeout(t);
111
+ const text = await res.text();
112
+ // 非长轮询的响应打 debug 日志,方便追查
113
+ if (endpoint !== 'ilink/bot/getupdates' && !res.ok) {
114
+ console.log(`[wechat-api] ${endpoint} status=${res.status} body=${text.slice(0, 400)}`);
115
+ }
116
+ if (!res.ok) throw new Error(`${endpoint} ${res.status}: ${text}`);
117
+ return JSON.parse(text);
118
+ } catch (err) {
119
+ clearTimeout(t);
120
+ if (err.name === 'AbortError') return { ret: 0, msgs: [], _aborted: true };
121
+ throw err;
122
+ }
123
+ }
124
+
125
+ async function getQrcode() {
126
+ const url = `${WECHAT_API_BASE}ilink/bot/get_bot_qrcode?bot_type=3`;
127
+ const res = await fetch(url);
128
+ return res.json();
129
+ }
130
+
131
+ async function getQrcodeStatus(qrcode) {
132
+ const url = `${WECHAT_API_BASE}ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
133
+ const controller = new AbortController();
134
+ const t = setTimeout(() => controller.abort(), 40000);
135
+ try {
136
+ const res = await fetch(url, { signal: controller.signal });
137
+ clearTimeout(t);
138
+ return res.json();
139
+ } catch (err) {
140
+ clearTimeout(t);
141
+ if (err.name === 'AbortError') return { status: 'timeout' };
142
+ throw err;
143
+ }
144
+ }
145
+
146
+ async function getUpdates(token, updatesBuf) {
147
+ return apiPost(
148
+ 'ilink/bot/getupdates',
149
+ {
150
+ get_updates_buf: updatesBuf || '',
151
+ base_info: { channel_version: '1.0.0' },
152
+ },
153
+ token,
154
+ 35000
155
+ );
156
+ }
157
+
158
+ async function sendWechatMessage(token, toUserId, text, contextToken) {
159
+ return apiPost(
160
+ 'ilink/bot/sendmessage',
161
+ {
162
+ msg: {
163
+ from_user_id: '',
164
+ to_user_id: toUserId,
165
+ client_id: `seam-${nodeCrypto.randomBytes(8).toString('hex')}`,
166
+ message_type: 2,
167
+ message_state: 2,
168
+ item_list: [{ type: 1, text_item: { text } }],
169
+ context_token: contextToken,
170
+ },
171
+ base_info: { channel_version: '1.0.2' },
172
+ },
173
+ token
174
+ );
175
+ }
176
+
177
+ // 文件扩展名 → media_type:1=图片, 2=视频, 3=文件
178
+ const MEDIA_TYPES_BY_EXT = {
179
+ '.jpg': 1, '.jpeg': 1, '.png': 1, '.gif': 1, '.webp': 1, '.bmp': 1,
180
+ '.mp4': 2, '.mov': 2, '.avi': 2, '.mkv': 2,
181
+ '.pdf': 3, '.txt': 3, '.md': 3, '.docx': 3, '.xlsx': 3, '.pptx': 3,
182
+ '.zip': 3, '.tar': 3, '.gz': 3, '.json': 3, '.csv': 3, '.log': 3,
183
+ };
184
+ // media_type → item_type(sendmessage 里用的):1→2, 2→5, 3→4
185
+ const ITEM_TYPE_BY_MEDIA = { 1: 2, 2: 5, 3: 4 };
186
+
187
+ /**
188
+ * 上传并发送文件/图片到微信(5 步流程):
189
+ * 1. getuploadurl → upload_param
190
+ * 2. AES-128-ECB (PKCS7) 加密文件
191
+ * 3. PUT 到 CDN
192
+ * 4. sendmessage 带 file_key + aes_key
193
+ * 来源:渡的 Python 实现(角落/skills/wechat-guardian/scripts/send_file.py)
194
+ */
195
+ const CDN_BASE = 'https://novac2c.cdn.weixin.qq.com/c2c';
196
+
197
+ async function uploadAndSendFile(token, toUserId, contextToken, filePath) {
198
+ const fs = require('node:fs');
199
+ const pathMod = require('node:path');
200
+ const plaintext = fs.readFileSync(filePath);
201
+ const ext = pathMod.extname(filePath).toLowerCase();
202
+ const mediaType = MEDIA_TYPES_BY_EXT[ext] || 3;
203
+ const rawSize = plaintext.length;
204
+ const rawFileMd5 = nodeCrypto.createHash('md5').update(plaintext).digest('hex');
205
+ const filesize = Math.ceil((rawSize + 1) / 16) * 16; // PKCS7 padded size
206
+ const filekey = nodeCrypto.randomBytes(16).toString('hex');
207
+ const aesKey = nodeCrypto.randomBytes(16);
208
+ const aesKeyHex = aesKey.toString('hex');
209
+ const fileName = pathMod.basename(filePath);
210
+
211
+ // Step 1: getuploadurl → upload_param (base64 token string)
212
+ const up = await apiPost(
213
+ 'ilink/bot/getuploadurl',
214
+ {
215
+ filekey,
216
+ media_type: mediaType,
217
+ to_user_id: toUserId,
218
+ rawsize: rawSize,
219
+ rawfilemd5: rawFileMd5,
220
+ filesize,
221
+ no_need_thumb: true,
222
+ aeskey: aesKeyHex,
223
+ base_info: { channel_version: '1.0.2' },
224
+ },
225
+ token,
226
+ 20000
227
+ );
228
+ const uploadParam = up?.upload_param;
229
+ if (!uploadParam || typeof uploadParam !== 'string') {
230
+ throw new Error(`getuploadurl bad response: ${JSON.stringify(up).slice(0, 300)}`);
231
+ }
232
+
233
+ // Step 2: AES-128-ECB + PKCS7 加密
234
+ const cipher = nodeCrypto.createCipheriv('aes-128-ecb', aesKey, null);
235
+ cipher.setAutoPadding(true);
236
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
237
+
238
+ // Step 3: POST 加密数据到 CDN;响应 header 里的 x-encrypted-param 是 download_param
239
+ const cdnUrl = `${CDN_BASE}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`;
240
+ const cdnResp = await fetch(cdnUrl, {
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/octet-stream' },
243
+ body: ciphertext,
244
+ });
245
+ if (!cdnResp.ok) {
246
+ const txt = await cdnResp.text();
247
+ throw new Error(`CDN upload failed ${cdnResp.status}: ${txt.slice(0, 200)}`);
248
+ }
249
+ const downloadParam = cdnResp.headers.get('x-encrypted-param');
250
+ if (!downloadParam) {
251
+ throw new Error(`CDN response missing x-encrypted-param header`);
252
+ }
253
+
254
+ // Step 4: sendmessage
255
+ const itemType = ITEM_TYPE_BY_MEDIA[mediaType] || 4;
256
+ const aesKeyB64 = Buffer.from(aesKeyHex).toString('base64'); // base64 of hex string
257
+ let item;
258
+ const media = {
259
+ encrypt_query_param: downloadParam,
260
+ aes_key: aesKeyB64,
261
+ encrypt_type: 1,
262
+ };
263
+ if (mediaType === 1) {
264
+ item = { type: itemType, image_item: { media, mid_size: filesize } };
265
+ } else if (mediaType === 2) {
266
+ item = { type: itemType, video_item: { media, video_size: filesize } };
267
+ } else {
268
+ item = {
269
+ type: itemType,
270
+ file_item: { media, file_name: fileName, len: String(rawSize) },
271
+ };
272
+ }
273
+
274
+ const clientId = `seam-${nodeCrypto.randomBytes(8).toString('hex')}`;
275
+ const resp = await apiPost(
276
+ 'ilink/bot/sendmessage',
277
+ {
278
+ msg: {
279
+ from_user_id: '',
280
+ to_user_id: toUserId,
281
+ client_id: clientId,
282
+ message_type: 2,
283
+ message_state: 2,
284
+ context_token: contextToken,
285
+ item_list: [item],
286
+ },
287
+ base_info: { channel_version: '1.0.2' },
288
+ },
289
+ token
290
+ );
291
+ return { ok: true, mediaType, itemType, fileName, bytes: rawSize, response: resp };
292
+ }
293
+
294
+ function createWechatPlugin() {
295
+ let log = null;
296
+ let hub = null;
297
+ let im = null;
298
+ let stateScope = null;
299
+ let binding = null;
300
+ let running = false;
301
+
302
+ function saveBinding() {
303
+ if (binding && stateScope) {
304
+ try {
305
+ stateScope.set('binding', binding);
306
+ } catch (e) {
307
+ log.warn('binding_save_failed', { message: e.message });
308
+ }
309
+ }
310
+ }
311
+
312
+ async function startLogin(fromUserId) {
313
+ log.info('qr_login_start', { fromUserId });
314
+ const qrData = await getQrcode();
315
+ if (!qrData.qrcode) {
316
+ throw seamError({
317
+ code: 'WECHAT_QR_FAILED',
318
+ message: 'Failed to get QR code from iLink Bot API',
319
+ docs: 'docs/maintainer-guide.md#WECHAT_QR_FAILED',
320
+ });
321
+ }
322
+
323
+ const qrUrl = `${WECHAT_API_BASE}ilink/bot/get_bot_qrcode?qrcode=${qrData.qrcode}`;
324
+ const scanUrl = qrData.qrcode_img_content || qrUrl;
325
+
326
+ if (fromUserId && im) {
327
+ try {
328
+ const QRCode = require('qrcode');
329
+ const qrImgPath = path.join(hub.seamDir, 'wechat-qr.png');
330
+ await QRCode.toFile(qrImgPath, scanUrl, { width: 300, margin: 2 });
331
+ await im.sendImage(fromUserId, qrImgPath);
332
+ log.info('qr_image_sent', { fromUserId });
333
+ } catch (e) {
334
+ log.warn('qr_image_failed', { message: e.message });
335
+ }
336
+ try {
337
+ await im.sendMessage(
338
+ fromUserId,
339
+ `📱 微信绑定二维码已发送(5分钟内有效)。\n\n扫码确认后,在微信里给bot发一条消息完成绑定。`
340
+ );
341
+ } catch (e) {
342
+ log.warn('qr_notice_send_failed', { message: e.message });
343
+ }
344
+ }
345
+
346
+ hub.inject(`📱 [微信绑定] 二维码已发送给用户`);
347
+
348
+ for (let i = 0; i < 30; i++) {
349
+ const status = await getQrcodeStatus(qrData.qrcode);
350
+ if (status.status === 'confirmed') {
351
+ // 绑定的微信就是 inviter 本人(一对一 bot),用 inviter 的昵称作显示名
352
+ // 避免终端里显示一长串 ilink 用户 ID
353
+ const displayName =
354
+ hub.credentials?.inviterName ||
355
+ hub.credentials?.inviter ||
356
+ '微信用户';
357
+ binding = {
358
+ bot_token: status.bot_token,
359
+ ilink_bot_id: status.ilink_bot_id,
360
+ ilink_user_id: status.ilink_user_id,
361
+ baseurl: status.baseurl || WECHAT_API_BASE,
362
+ context_token: null,
363
+ updates_buf: '',
364
+ status: 'pending_first_message',
365
+ display_name: displayName,
366
+ created_at: new Date().toISOString(),
367
+ };
368
+ saveBinding();
369
+ log.info('qr_confirmed', { ilink_user_id: binding.ilink_user_id });
370
+ hub.inject('📱 [微信绑定] 扫码成功!请在微信里给bot发一条消息完成绑定。');
371
+ messageLoop();
372
+ return { ok: true, status: 'confirmed' };
373
+ } else if (status.status === 'scaned') {
374
+ log.info('qr_scanned');
375
+ }
376
+ }
377
+
378
+ throw seamError({
379
+ code: 'WECHAT_QR_EXPIRED',
380
+ message: 'QR code expired after 30 polls (~5 min)',
381
+ hint: '重新触发 wechat_login 或发"绑定微信"',
382
+ docs: 'docs/maintainer-guide.md#WECHAT_QR_EXPIRED',
383
+ });
384
+ }
385
+
386
+ async function messageLoop() {
387
+ if (!binding || !binding.bot_token) return;
388
+ if (running) return; // avoid double start
389
+ running = true;
390
+ log.info('message_loop_started');
391
+
392
+ while (running) {
393
+ try {
394
+ const bufPreview = (binding.updates_buf || '').slice(0, 40);
395
+ log.info('poll_begin', { bufPreview });
396
+ const resp = await getUpdates(binding.bot_token, binding.updates_buf);
397
+ log.info('poll_end', {
398
+ msgCount: resp.msgs ? resp.msgs.length : 0,
399
+ hasNewBuf: resp.get_updates_buf !== undefined,
400
+ newBufPreview: (resp.get_updates_buf || '').slice(0, 40),
401
+ aborted: !!resp._aborted,
402
+ ret: resp.ret,
403
+ });
404
+ if (resp.msgs && resp.msgs.length > 0) {
405
+ for (const msg of resp.msgs) {
406
+ if (msg.context_token) {
407
+ binding.context_token = msg.context_token;
408
+ }
409
+ let text = '';
410
+ if (msg.item_list) {
411
+ for (const item of msg.item_list) {
412
+ if (item.type === 1 && item.text_item?.text) {
413
+ text += item.text_item.text;
414
+ }
415
+ }
416
+ }
417
+
418
+ // 优先用 binding.display_name(绑定时从 credentials.inviterName 来),
419
+ // 否则 iLink 的昵称,最后 fallback 到原 id
420
+ const sender =
421
+ binding?.display_name ||
422
+ msg.from_user_nickname ||
423
+ msg.from_user_id ||
424
+ '微信用户';
425
+ const msgBuf = hub.service('message-buffer');
426
+ if (text) {
427
+ log.info('wechat_message_received', {
428
+ sender,
429
+ textLength: text.length,
430
+ });
431
+ if (msgBuf) {
432
+ msgBuf.queueText(
433
+ `wechat:${sender}`,
434
+ text,
435
+ (items) => {
436
+ const ts = new Date().toTimeString().slice(0, 5);
437
+ const joined = items.map((i) => i.content).join('\n');
438
+ const prefix = `📱 [微信 ${ts} → wechat_send]`;
439
+ if (items.length === 1) {
440
+ hub.inject(`${prefix} ${sender}: ${joined}`);
441
+ } else {
442
+ hub.inject(`${prefix} ${sender}:\n${joined}`);
443
+ }
444
+ },
445
+ { delay: 5000, maxDelay: 15000 }
446
+ );
447
+ } else {
448
+ const ts = new Date().toTimeString().slice(0, 5);
449
+ hub.inject(`📱 [微信 ${ts} → wechat_send] ${sender}: ${text}`);
450
+ }
451
+ } else {
452
+ // 非文本消息:按 type 分派下载
453
+ const itemTypes = (msg.item_list || []).map((i) => i.type);
454
+ const inbox = hub.service('inbox');
455
+ const ts = new Date().toTimeString().slice(0, 5);
456
+
457
+ // 取第一个非文本 item 处理(v1 简化)
458
+ const firstItem = (msg.item_list || []).find(
459
+ (i) => i.type !== 1
460
+ );
461
+
462
+ try {
463
+ if (firstItem?.type === 2 && firstItem.image_item) {
464
+ // 图片
465
+ const buf = await downloadAndDecrypt(firstItem.image_item);
466
+ const p = inbox.save(buf, { ext: 'jpg', src: 'wechat' });
467
+ log.info('wechat_image_saved', {
468
+ sender,
469
+ bytes: buf.length,
470
+ path: p,
471
+ });
472
+ hub.inject(`📱 [微信图片 ${ts} → wechat_send] ${sender} → ${p}`);
473
+ } else if (firstItem?.type === 3 && firstItem.voice_item) {
474
+ // 语音
475
+ const buf = await downloadAndDecrypt(firstItem.voice_item);
476
+ const p = inbox.save(buf, { ext: 'amr', src: 'wechat' });
477
+ log.info('wechat_voice_saved', {
478
+ sender,
479
+ bytes: buf.length,
480
+ path: p,
481
+ });
482
+ hub.inject(`📱 [微信语音 ${ts} → wechat_send] ${sender} → ${p}`);
483
+ } else if (firstItem?.type === 5 && firstItem.video_item) {
484
+ // 视频
485
+ const buf = await downloadAndDecrypt(firstItem.video_item);
486
+ const p = inbox.save(buf, { ext: 'mp4', src: 'wechat' });
487
+ log.info('wechat_video_saved', {
488
+ sender,
489
+ bytes: buf.length,
490
+ path: p,
491
+ });
492
+ hub.inject(`📱 [微信视频 ${ts} → wechat_send] ${sender} → ${p}`);
493
+ } else if (firstItem?.type === 4 && firstItem.file_item) {
494
+ // 文件
495
+ const fileItem = firstItem.file_item;
496
+ const buf = await downloadAndDecrypt(fileItem);
497
+ const fileName =
498
+ fileItem.file_name ||
499
+ fileItem.name ||
500
+ fileItem.media?.file_name ||
501
+ `file_${Date.now()}`;
502
+ const ext = path.extname(fileName).replace(/^\./, '') || 'bin';
503
+ const p = inbox.save(buf, { ext, src: 'wechat' });
504
+ log.info('wechat_file_saved', {
505
+ sender,
506
+ bytes: buf.length,
507
+ path: p,
508
+ fileName,
509
+ });
510
+ hub.inject(
511
+ `📱 [微信文件 ${ts} → wechat_send] ${sender} → ${p} (原名: ${fileName}, ${buf.length} bytes)`
512
+ );
513
+ } else {
514
+ // 未实现的类型
515
+ log.warn('wechat_message_unhandled_type', {
516
+ sender,
517
+ itemTypes,
518
+ itemKeys: (msg.item_list || [])
519
+ .slice(0, 2)
520
+ .map((i) => Object.keys(i)),
521
+ });
522
+ const hint =
523
+ firstItem?.type === 3 ? '[语音]' :
524
+ firstItem?.type === 5 ? '[链接]' :
525
+ firstItem?.type === 6 ? '[表情]' :
526
+ `[未知类型 ${JSON.stringify(itemTypes)}]`;
527
+ hub.inject(`📱 [微信 ${ts} → wechat_send] ${sender}: ${hint}`);
528
+ }
529
+ } catch (e) {
530
+ log.error('wechat_media_download_failed', e, {
531
+ sender,
532
+ itemTypes,
533
+ });
534
+ hub.inject(
535
+ `📱 [微信 ${ts} → wechat_send] ${sender}: [媒体下载失败: ${e.message}]`
536
+ );
537
+ }
538
+ }
539
+
540
+ if (binding.status === 'pending_first_message') {
541
+ binding.status = 'active';
542
+ log.info('binding_active');
543
+ hub.inject('📱 [微信绑定] 绑定完成!微信通道已激活。');
544
+ }
545
+ }
546
+ }
547
+ // 永远更新游标(即使 msgs 为空),避免卡在旧游标
548
+ if (resp.get_updates_buf !== undefined) {
549
+ binding.updates_buf = resp.get_updates_buf;
550
+ }
551
+ saveBinding();
552
+ } catch (err) {
553
+ if (err.message && err.message.includes('-14')) {
554
+ log.error(
555
+ 'session_frozen',
556
+ wrap(err, {
557
+ code: 'WECHAT_SESSION_FROZEN',
558
+ hint: 'bot 被风控,暂停 1 小时',
559
+ docs: 'docs/maintainer-guide.md#WECHAT_SESSION_FROZEN',
560
+ }).toLog()
561
+ );
562
+ binding.status = 'frozen';
563
+ saveBinding();
564
+ await new Promise((r) => setTimeout(r, 3600000));
565
+ binding.status = 'active';
566
+ saveBinding();
567
+ } else {
568
+ log.warn('poll_error', { message: err.message });
569
+ await new Promise((r) => setTimeout(r, 5000));
570
+ }
571
+ }
572
+ }
573
+ log.info('message_loop_stopped');
574
+ }
575
+
576
+ async function init(h) {
577
+ hub = h;
578
+ log = hub.logger('wechat');
579
+
580
+ // Service 依赖
581
+ const state = hub.service('state');
582
+ const bus = hub.service('event-bus');
583
+ if (!state) {
584
+ throw seamError({
585
+ code: 'WECHAT_MISSING_STATE_SVC',
586
+ message: 'wechat plugin requires `state` service',
587
+ });
588
+ }
589
+ if (!bus) {
590
+ throw seamError({
591
+ code: 'WECHAT_MISSING_EVENT_BUS',
592
+ message: 'wechat plugin requires `event-bus` service',
593
+ });
594
+ }
595
+ stateScope = state.scope('wechat');
596
+
597
+ // Plugin 依赖
598
+ im = hub.get('im');
599
+ if (!im) {
600
+ throw seamError({
601
+ code: 'WECHAT_MISSING_IM_DEP',
602
+ message: 'wechat plugin requires `im` plugin loaded first',
603
+ hint: 'guardian 加载插件时确保 im 在 wechat 之前',
604
+ });
605
+ }
606
+
607
+ // 订阅 IM 消息:收到"绑定微信" 触发扫码
608
+ bus.on('im.message', ({ from, text, isGroup }) => {
609
+ if (isGroup) return;
610
+ if (String(text).trim() === '绑定微信') {
611
+ log.info('bind_trigger_received', { from });
612
+ startLogin(from).catch((e) => {
613
+ log.error('bind_trigger_failed', e);
614
+ im.sendMessage(from, `📱 微信绑定失败:${e.message}`).catch(() => {});
615
+ });
616
+ }
617
+ });
618
+
619
+ // 恢复已有 binding(从 state)
620
+ const existing = stateScope.get('binding');
621
+ if (existing) {
622
+ binding = existing;
623
+ // 老 binding 没有 display_name 字段 → 从 credentials 补上
624
+ if (!binding.display_name) {
625
+ binding.display_name =
626
+ hub.credentials?.inviterName ||
627
+ hub.credentials?.inviter ||
628
+ '微信用户';
629
+ saveBinding();
630
+ log.info('binding_display_name_backfilled', {
631
+ display_name: binding.display_name,
632
+ });
633
+ }
634
+ log.info('binding_restored', {
635
+ ilink_user_id: binding.ilink_user_id || 'unknown',
636
+ status: binding.status,
637
+ display_name: binding.display_name,
638
+ });
639
+ if (binding.bot_token && binding.status !== 'frozen') {
640
+ messageLoop();
641
+ }
642
+ } else {
643
+ log.info('no_existing_binding');
644
+ }
645
+ }
646
+
647
+ async function handleRequest(req) {
648
+ const check = validatePayload(req.action, req);
649
+ if (!check.ok) {
650
+ throw seamError({ code: 'WECHAT_BAD_PAYLOAD', message: check.error });
651
+ }
652
+ if (req.action === 'wechat_login') {
653
+ // fromUserId 从 req 里来,没有则不发给任何人
654
+ return await startLogin(req.fromUserId);
655
+ }
656
+ if (req.action === 'wechat_send') {
657
+ if (!binding || !binding.context_token) {
658
+ throw seamError({
659
+ code: 'WECHAT_NOT_BOUND',
660
+ message: 'WeChat not bound or no context_token',
661
+ hint: '用户需要先给 bot 发一条消息',
662
+ docs: 'docs/maintainer-guide.md#WECHAT_NOT_BOUND',
663
+ });
664
+ }
665
+ const resp = await sendWechatMessage(
666
+ binding.bot_token,
667
+ binding.ilink_user_id,
668
+ req.text,
669
+ binding.context_token
670
+ );
671
+ log.info('wechat_send_response', { ret: resp?.ret, msg: resp?.msg, keys: resp && Object.keys(resp) });
672
+ return { ok: true, response: resp };
673
+ }
674
+ if (req.action === 'wechat_send_image' || req.action === 'wechat_send_file') {
675
+ if (!binding || !binding.context_token) {
676
+ throw seamError({
677
+ code: 'WECHAT_NOT_BOUND',
678
+ message: 'WeChat not bound or no context_token',
679
+ hint: '用户需要先给 bot 发一条消息',
680
+ });
681
+ }
682
+ if (!req.filePath) {
683
+ throw seamError({
684
+ code: 'WECHAT_FILE_PATH_MISSING',
685
+ message: 'filePath is required',
686
+ });
687
+ }
688
+ const result = await uploadAndSendFile(
689
+ binding.bot_token,
690
+ binding.ilink_user_id,
691
+ binding.context_token,
692
+ req.filePath
693
+ );
694
+ log.info('wechat_file_sent', {
695
+ fileName: result.fileName,
696
+ bytes: result.bytes,
697
+ mediaType: result.mediaType,
698
+ });
699
+ return result;
700
+ }
701
+ if (req.action === 'wechat_status') {
702
+ return {
703
+ ok: true,
704
+ bound: !!binding,
705
+ status: binding?.status || 'not_bound',
706
+ user: binding?.ilink_user_id || null,
707
+ };
708
+ }
709
+ throw seamError({
710
+ code: 'WECHAT_UNKNOWN_ACTION',
711
+ message: `unknown action: ${req.action}`,
712
+ });
713
+ }
714
+
715
+ async function destroy() {
716
+ running = false;
717
+ if (log) log.info('destroyed');
718
+ }
719
+
720
+ return {
721
+ name: 'wechat',
722
+ description: '微信iLink通道(绑定/发送文本/图片/文件)',
723
+ actions: [
724
+ 'wechat_send',
725
+ 'wechat_send_image',
726
+ 'wechat_send_file',
727
+ 'wechat_login',
728
+ 'wechat_status',
729
+ ],
730
+ init,
731
+ handleRequest,
732
+ destroy,
733
+ };
734
+ }
735
+
736
+ module.exports = { createWechatPlugin };