@seamnet/client 0.12.7 → 0.13.1

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.
@@ -134,6 +134,37 @@ function createImPlugin() {
134
134
  // userId → nick 缓存;null 表示已查过但无 nick,避免反复拉
135
135
  const displayNameCache = new Map();
136
136
 
137
+ // ===== Supervisor 状态(SDK 僵尸时自动重建 chat 实例)=====
138
+ let credentialsRef = null;
139
+ // 状态机:scheduled(待启动)/ rebuilding(进行中);期间又来信号标记 requestedAgain
140
+ let rebuildScheduled = false;
141
+ let rebuilding = false;
142
+ let rebuildRequestedAgain = false;
143
+ let lastRebuildSuccessAt = 0;
144
+ let chatGeneration = 0; // chat 实例代数,每次 create 递增;listener 校验代数,老代事件忽略
145
+ let keepaliveTimer = null;
146
+ let processRejectionHandler = null;
147
+ const REBUILD_COOLDOWN_MS = 10_000;
148
+ const REBUILD_DEBOUNCE_MS = 3_000;
149
+ const REBUILD_WATCHDOG_MS = 30_000; // 整个重建最多 30s,超时强制失败
150
+ const SDK_CALL_TIMEOUT_MS = 10_000; // 单个 SDK 调用超时
151
+ const LOGOUT_TIMEOUT_MS = 5_000;
152
+ const LOGIN_TIMEOUT_MS = 15_000;
153
+ const KEEPALIVE_INTERVAL_MS = 3 * 60 * 1000;
154
+ const KEEPALIVE_TIMEOUT_MS = 10_000;
155
+ // SDK 错误 code 需要触发 rebuild(2801=PING 超时,WebSocket 应用层僵尸)
156
+ const SDK_REBUILD_ERROR_CODES = new Set([2801]);
157
+
158
+ function withTimeout(promise, ms, name) {
159
+ let tid = null;
160
+ const timeoutP = new Promise((_, reject) => {
161
+ tid = setTimeout(() => reject(new Error(`${name} timeout ${ms}ms`)), ms);
162
+ });
163
+ return Promise.race([promise, timeoutP]).finally(() => {
164
+ if (tid) clearTimeout(tid);
165
+ });
166
+ }
167
+
137
168
  async function prefetchDisplayName(userId) {
138
169
  if (!chat || !userId || displayNameCache.has(userId)) return;
139
170
  displayNameCache.set(userId, null);
@@ -326,6 +357,296 @@ function createImPlugin() {
326
357
  return { ok: true };
327
358
  }
328
359
 
360
+ // ===== 事件 handler 工厂(每次 setupAllListeners 时绑定当前 generation)=====
361
+ // handler 校验 generation,老实例晚到的事件直接忽略,避免污染新状态
362
+
363
+ function makeHandlers(gen) {
364
+ return {
365
+ onSdkReady: () => {
366
+ if (gen !== chatGeneration) return;
367
+ sdkReady = true;
368
+ log.info('sdk_ready', { userId: myUserId, gen });
369
+ },
370
+ onSdkNotReady: () => {
371
+ if (gen !== chatGeneration) return;
372
+ sdkReady = false;
373
+ log.warn('sdk_not_ready', { gen });
374
+ },
375
+ onSdkError: (event) => {
376
+ if (gen !== chatGeneration) return;
377
+ const err = event?.data || event;
378
+ const code = err?.code;
379
+ log.warn('sdk_error', {
380
+ code,
381
+ message: err?.message || String(err).slice(0, 200),
382
+ gen,
383
+ });
384
+ if (SDK_REBUILD_ERROR_CODES.has(code)) {
385
+ scheduleRebuild(`sdk_error:${code}`);
386
+ }
387
+ },
388
+ onKickedOut: (event) => {
389
+ if (gen !== chatGeneration) return;
390
+ log.warn('sdk_kicked_out', {
391
+ type: event?.data?.type,
392
+ message: event?.data?.message,
393
+ gen,
394
+ });
395
+ sdkReady = false;
396
+ scheduleRebuild('kicked_out');
397
+ },
398
+ onNetStateChange: (event) => {
399
+ if (gen !== chatGeneration) return;
400
+ log.info('sdk_net_state', { state: event?.data?.state, gen });
401
+ },
402
+ onFriendListUpdated: () => {
403
+ if (gen !== chatGeneration) return;
404
+ refreshContactsFromFriendList().catch((err) => {
405
+ log.warn('refresh_contacts_on_update_failed', { message: err.message });
406
+ });
407
+ },
408
+ onMessageReceived: (event) => {
409
+ if (gen !== chatGeneration) return;
410
+ handleMessageEvent(event);
411
+ },
412
+ };
413
+ }
414
+
415
+ function handleMessageEvent(event) {
416
+ for (const msg of event.data) {
417
+ if (msg.from === myUserId) continue;
418
+
419
+ const isGroup = msg.conversationType === TencentCloudChat.TYPES.CONV_GROUP;
420
+ const sender = msg.from;
421
+ if (!msg.nick && !displayNameCache.has(sender)) {
422
+ prefetchDisplayName(sender).catch(() => {});
423
+ }
424
+ const senderDisplay = formatSenderDisplay(msg);
425
+ const groupId = isGroup ? msg.to : null;
426
+ const ts = new Date().toTimeString().slice(0, 5);
427
+ const conversationType = msg.conversationType;
428
+ const replyTool = isGroup ? 'seam msg group' : 'seam msg send';
429
+
430
+ // 图片消息
431
+ if (msg.type === TencentCloudChat.TYPES.MSG_IMAGE) {
432
+ const inbox = hub.service('inbox');
433
+ const imgInfo =
434
+ msg.payload?.imageInfoArray?.find((i) => i.sizeType === 3) ||
435
+ msg.payload?.imageInfoArray?.[0];
436
+ if (imgInfo?.url && inbox) {
437
+ (async () => {
438
+ try {
439
+ const resp = await fetch(imgInfo.url);
440
+ if (!resp.ok) throw new Error(`http ${resp.status}`);
441
+ const buf = Buffer.from(await resp.arrayBuffer());
442
+ const p = inbox.save(buf, { ext: 'jpg', src: 'im' });
443
+ log.info('im_image_saved', { sender, bytes: buf.length, path: p });
444
+ const prefix = isGroup
445
+ ? `💬 [Seam群图片 ${ts} ${groupId} → ${replyTool}]`
446
+ : `💬 [Seam图片 ${ts} → ${replyTool}]`;
447
+ hub.inject(`${prefix} ${senderDisplay} → ${p}`);
448
+ } catch (e) {
449
+ log.error('im_image_download_failed', e, { sender });
450
+ hub.inject(`💬 [Seam ${ts} → ${replyTool}] ${senderDisplay}: [图片下载失败: ${e.message}]`);
451
+ }
452
+ })();
453
+ }
454
+ continue;
455
+ }
456
+
457
+ // 文件消息
458
+ if (msg.type === TencentCloudChat.TYPES.MSG_FILE) {
459
+ const inbox = hub.service('inbox');
460
+ const fileUrl = msg.payload?.fileUrl;
461
+ const fileName = msg.payload?.fileName || `file_${Date.now()}.bin`;
462
+ const ext = path.extname(fileName).replace(/^\./, '') || 'bin';
463
+ if (fileUrl && inbox) {
464
+ (async () => {
465
+ try {
466
+ const resp = await fetch(fileUrl);
467
+ if (!resp.ok) throw new Error(`http ${resp.status}`);
468
+ const buf = Buffer.from(await resp.arrayBuffer());
469
+ const p = inbox.save(buf, { ext, src: 'im' });
470
+ log.info('im_file_saved', { sender, bytes: buf.length, path: p });
471
+ const prefix = isGroup
472
+ ? `💬 [Seam群文件 ${ts} ${groupId} → ${replyTool}]`
473
+ : `💬 [Seam文件 ${ts} → ${replyTool}]`;
474
+ hub.inject(
475
+ `${prefix} ${senderDisplay} → ${p} (原名: ${fileName}, ${buf.length} bytes)`
476
+ );
477
+ } catch (e) {
478
+ log.error('im_file_download_failed', e, { sender });
479
+ hub.inject(`💬 [Seam ${ts} → ${replyTool}] ${sender}: [文件下载失败: ${e.message}]`);
480
+ }
481
+ })();
482
+ }
483
+ continue;
484
+ }
485
+
486
+ // 文本消息
487
+ let text = '';
488
+ if (msg.type === TencentCloudChat.TYPES.MSG_TEXT) {
489
+ text = msg.payload.text;
490
+ } else {
491
+ text = `[${msg.type}]`;
492
+ }
493
+ if (!text) continue;
494
+
495
+ log.info('message_received', { from: msg.from, isGroup, textLength: text.length });
496
+
497
+ const bufferKey = isGroup ? `im:group:${msg.to}:${msg.from}` : `im:${msg.from}`;
498
+ const makePrefix = (ts2) =>
499
+ isGroup
500
+ ? `💬 [Seam群 ${ts2} ${groupId} → ${replyTool}]`
501
+ : `💬 [Seam ${ts2} → ${replyTool}]`;
502
+
503
+ if (messageBuffer) {
504
+ messageBuffer.queueText(
505
+ bufferKey,
506
+ text,
507
+ (items) => {
508
+ const tsNow = new Date().toTimeString().slice(0, 5);
509
+ const prefix = makePrefix(tsNow);
510
+ const joined = items.map((i) => i.content).join('\n');
511
+ if (items.length === 1) {
512
+ hub.inject(`${prefix} ${senderDisplay}: ${joined}`);
513
+ } else {
514
+ hub.inject(`${prefix} ${senderDisplay}:\n${joined}`);
515
+ }
516
+ if (eventBus) {
517
+ eventBus.emit('im.message', { from: sender, text: joined, isGroup, groupId, conversationType });
518
+ }
519
+ },
520
+ { delay: 5000, maxDelay: 15000 }
521
+ );
522
+ } else {
523
+ const tsNow = new Date().toTimeString().slice(0, 5);
524
+ hub.inject(`${makePrefix(tsNow)} ${senderDisplay}: ${text}`);
525
+ if (eventBus) {
526
+ eventBus.emit('im.message', { from: sender, text, isGroup, groupId, conversationType });
527
+ }
528
+ }
529
+ }
530
+ }
531
+
532
+ function setupAllListeners() {
533
+ const h = makeHandlers(chatGeneration);
534
+ chat.on(TencentCloudChat.EVENT.SDK_READY, h.onSdkReady);
535
+ chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, h.onSdkNotReady);
536
+ chat.on(TencentCloudChat.EVENT.ERROR, h.onSdkError);
537
+ chat.on(TencentCloudChat.EVENT.KICKED_OUT, h.onKickedOut);
538
+ chat.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, h.onNetStateChange);
539
+ chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, h.onMessageReceived);
540
+ try {
541
+ chat.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, h.onFriendListUpdated);
542
+ } catch (err) {
543
+ log.warn('friend_list_listener_register_failed', { message: err.message });
544
+ }
545
+ }
546
+
547
+ // ===== 重建 chat 实例(SDK 僵尸时用)=====
548
+
549
+ async function createAndLoginChat() {
550
+ // 每次 create 递增 generation,让老实例的 handler 自动失效
551
+ chatGeneration += 1;
552
+ // 清理 globals 让 setupSdkGlobals 能重新设置 window 等
553
+ delete global.window;
554
+ global._imSdkGlobalsSet = false;
555
+ chat = TencentCloudChat.create({ SDKAppID: Number(credentialsRef.sdkAppId) });
556
+ setupSdkGlobals();
557
+ try {
558
+ const TIMUploadPlugin = require('tim-upload-plugin');
559
+ chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin });
560
+ } catch {}
561
+ chat.setLogLevel(4);
562
+ setupAllListeners();
563
+ await withTimeout(
564
+ chat.login({ userID: myUserId, userSig: credentialsRef.userSig }),
565
+ LOGIN_TIMEOUT_MS,
566
+ 'chat.login'
567
+ );
568
+ // 等 SDK_READY(最多 10s)——sdkReady 可能已被 handler 置 true
569
+ await new Promise((resolve) => {
570
+ if (sdkReady) return resolve();
571
+ const timer = setTimeout(() => resolve(), 10000);
572
+ const once = () => { clearTimeout(timer); resolve(); };
573
+ chat.on(TencentCloudChat.EVENT.SDK_READY, once);
574
+ });
575
+ }
576
+
577
+ async function rebuildChat(reason) {
578
+ log.warn('chat_rebuild_start', { reason, prevGen: chatGeneration });
579
+ sdkReady = false;
580
+ // 每步都 timeout:老 chat 僵尸可能让 logout 永不 resolve
581
+ const oldChat = chat;
582
+ try {
583
+ await withTimeout(oldChat.logout().catch(() => {}), LOGOUT_TIMEOUT_MS, 'logout');
584
+ } catch (e) {
585
+ log.warn('logout_timeout', { message: e.message });
586
+ }
587
+ try { oldChat.destroy && oldChat.destroy(); } catch {}
588
+ // createAndLoginChat 自身会递增 chatGeneration,老 listener 自动失效
589
+ await createAndLoginChat();
590
+ try {
591
+ await withTimeout(refreshContactsFromFriendList(), SDK_CALL_TIMEOUT_MS, 'refresh_contacts');
592
+ } catch (e) {
593
+ log.warn('refresh_contacts_after_rebuild_timeout', { message: e.message });
594
+ }
595
+ log.info('chat_rebuild_done', { reason, newGen: chatGeneration });
596
+ }
597
+
598
+ async function doRebuild(reason) {
599
+ rebuildScheduled = false;
600
+ rebuilding = true;
601
+ try {
602
+ await withTimeout(rebuildChat(reason), REBUILD_WATCHDOG_MS, 'rebuild_watchdog');
603
+ lastRebuildSuccessAt = Date.now();
604
+ } catch (e) {
605
+ log.error('chat_rebuild_failed', e, { reason, message: e.message });
606
+ // 注意:lastRebuildSuccessAt 不更新,允许立即重试(不被 cooldown 拦)
607
+ } finally {
608
+ rebuilding = false;
609
+ if (rebuildRequestedAgain) {
610
+ rebuildRequestedAgain = false;
611
+ scheduleRebuild('trailing');
612
+ }
613
+ }
614
+ }
615
+
616
+ function scheduleRebuild(reason) {
617
+ // 已在重建中:标记 trailing,结束后再跑一轮
618
+ if (rebuilding) {
619
+ rebuildRequestedAgain = true;
620
+ log.info('chat_rebuild_queued_trailing', { reason });
621
+ return;
622
+ }
623
+ // 已在等待启动:合并(debounce)
624
+ if (rebuildScheduled) return;
625
+ // Cooldown 只针对上次"成功"的重建——失败后允许立即重试
626
+ const now = Date.now();
627
+ if (now - lastRebuildSuccessAt < REBUILD_COOLDOWN_MS) {
628
+ log.info('chat_rebuild_skipped_cooldown', { reason });
629
+ return;
630
+ }
631
+ // 立即降级:阻止发送路径继续用坏 chat
632
+ sdkReady = false;
633
+ rebuildScheduled = true;
634
+ setTimeout(() => { doRebuild(reason).catch(() => {}); }, REBUILD_DEBOUNCE_MS);
635
+ }
636
+
637
+ function startKeepalive() {
638
+ if (keepaliveTimer) return;
639
+ keepaliveTimer = setInterval(async () => {
640
+ if (!sdkReady || rebuilding || rebuildScheduled) return;
641
+ try {
642
+ await withTimeout(chat.getMyProfile(), KEEPALIVE_TIMEOUT_MS, 'keepalive');
643
+ } catch (e) {
644
+ log.warn('keepalive_failed', { message: e.message });
645
+ scheduleRebuild(`keepalive:${e.message}`);
646
+ }
647
+ }, KEEPALIVE_INTERVAL_MS);
648
+ }
649
+
329
650
  async function init(h) {
330
651
  hub = h;
331
652
  log = hub.logger('im');
@@ -345,10 +666,12 @@ function createImPlugin() {
345
666
  }
346
667
 
347
668
  myUserId = credentials.userId;
669
+ credentialsRef = credentials;
348
670
 
349
671
  // 先 delete window 让 SDK 走 node 路径
350
672
  delete global.window;
351
673
  TencentCloudChat = require('@tencentcloud/chat');
674
+ chatGeneration += 1; // 首次也是 gen 1(listener 校验需要)
352
675
  chat = TencentCloudChat.create({ SDKAppID: Number(credentials.sdkAppId) });
353
676
 
354
677
  // 然后补上 SDK 运行所需的 window/document 等全局
@@ -364,171 +687,8 @@ function createImPlugin() {
364
687
 
365
688
  chat.setLogLevel(4);
366
689
 
367
- chat.on(TencentCloudChat.EVENT.SDK_READY, () => {
368
- sdkReady = true;
369
- log.info('sdk_ready', { userId: myUserId });
370
- });
371
-
372
- chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, () => {
373
- sdkReady = false;
374
- log.warn('sdk_not_ready');
375
- });
376
-
377
- // SDK 错误事件:记录但不 throw,避免 guardian 整个 crash
378
- chat.on(TencentCloudChat.EVENT.ERROR, (event) => {
379
- const err = event?.data || event;
380
- log.warn('sdk_error', {
381
- code: err?.code,
382
- message: err?.message || String(err).slice(0, 200),
383
- });
384
- });
385
-
386
- // 被踢下线(2801 等多端登录场景):记录,SDK 会自动重连
387
- chat.on(TencentCloudChat.EVENT.KICKED_OUT, (event) => {
388
- log.warn('sdk_kicked_out', {
389
- type: event?.data?.type,
390
- message: event?.data?.message,
391
- });
392
- sdkReady = false;
393
- });
394
-
395
- // 网络状态变化:断线/重连都只记日志,不干扰主流程
396
- chat.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, (event) => {
397
- log.info('sdk_net_state', { state: event?.data?.state });
398
- });
399
-
400
- chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, (event) => {
401
- for (const msg of event.data) {
402
- if (msg.from === myUserId) continue;
403
-
404
- const isGroup = msg.conversationType === TencentCloudChat.TYPES.CONV_GROUP;
405
- // sender 用于 event-bus(保持 userId 原始值);senderDisplay 用于注入终端(name · userId)
406
- const sender = msg.from;
407
- // 若无 msg.nick 且未缓存,异步拉 profile(下次消息就能用)
408
- if (!msg.nick && !displayNameCache.has(sender)) {
409
- prefetchDisplayName(sender).catch(() => {});
410
- }
411
- const senderDisplay = formatSenderDisplay(msg);
412
- const groupId = isGroup ? msg.to : null;
413
- const ts = new Date().toTimeString().slice(0, 5);
414
- const conversationType = msg.conversationType;
415
-
416
- const replyTool = isGroup ? 'seam msg group' : 'seam msg send';
417
-
418
- // 图片消息:下载 URL 存 inbox
419
- if (msg.type === TencentCloudChat.TYPES.MSG_IMAGE) {
420
- const inbox = hub.service('inbox');
421
- const imgInfo =
422
- msg.payload?.imageInfoArray?.find((i) => i.sizeType === 3) ||
423
- msg.payload?.imageInfoArray?.[0];
424
- if (imgInfo?.url && inbox) {
425
- (async () => {
426
- try {
427
- const resp = await fetch(imgInfo.url);
428
- if (!resp.ok) throw new Error(`http ${resp.status}`);
429
- const buf = Buffer.from(await resp.arrayBuffer());
430
- const p = inbox.save(buf, { ext: 'jpg', src: 'im' });
431
- log.info('im_image_saved', { sender, bytes: buf.length, path: p });
432
- const prefix = isGroup
433
- ? `💬 [Seam群图片 ${ts} ${groupId} → ${replyTool}]`
434
- : `💬 [Seam图片 ${ts} → ${replyTool}]`;
435
- hub.inject(`${prefix} ${senderDisplay} → ${p}`);
436
- } catch (e) {
437
- log.error('im_image_download_failed', e, { sender });
438
- hub.inject(`💬 [Seam ${ts} → ${replyTool}] ${senderDisplay}: [图片下载失败: ${e.message}]`);
439
- }
440
- })();
441
- }
442
- continue;
443
- }
444
-
445
- // 文件消息
446
- if (msg.type === TencentCloudChat.TYPES.MSG_FILE) {
447
- const inbox = hub.service('inbox');
448
- const fileUrl = msg.payload?.fileUrl;
449
- const fileName = msg.payload?.fileName || `file_${Date.now()}.bin`;
450
- const ext = path.extname(fileName).replace(/^\./, '') || 'bin';
451
- if (fileUrl && inbox) {
452
- (async () => {
453
- try {
454
- const resp = await fetch(fileUrl);
455
- if (!resp.ok) throw new Error(`http ${resp.status}`);
456
- const buf = Buffer.from(await resp.arrayBuffer());
457
- const p = inbox.save(buf, { ext, src: 'im' });
458
- log.info('im_file_saved', { sender, bytes: buf.length, path: p });
459
- const prefix = isGroup
460
- ? `💬 [Seam群文件 ${ts} ${groupId} → ${replyTool}]`
461
- : `💬 [Seam文件 ${ts} → ${replyTool}]`;
462
- hub.inject(
463
- `${prefix} ${senderDisplay} → ${p} (原名: ${fileName}, ${buf.length} bytes)`
464
- );
465
- } catch (e) {
466
- log.error('im_file_download_failed', e, { sender });
467
- hub.inject(`💬 [Seam ${ts} → ${replyTool}] ${sender}: [文件下载失败: ${e.message}]`);
468
- }
469
- })();
470
- }
471
- continue;
472
- }
473
-
474
- // 文本消息(含其他未处理类型用占位)
475
- let text = '';
476
- if (msg.type === TencentCloudChat.TYPES.MSG_TEXT) {
477
- text = msg.payload.text;
478
- } else {
479
- text = `[${msg.type}]`;
480
- }
481
- if (!text) continue;
482
-
483
- log.info('message_received', {
484
- from: msg.from,
485
- isGroup,
486
- textLength: text.length,
487
- });
488
-
489
- const bufferKey = isGroup
490
- ? `im:group:${msg.to}:${msg.from}`
491
- : `im:${msg.from}`;
492
-
493
- const makePrefix = (ts2) =>
494
- isGroup
495
- ? `💬 [Seam群 ${ts2} ${groupId} → ${replyTool}]`
496
- : `💬 [Seam ${ts2} → ${replyTool}]`;
497
-
498
- if (messageBuffer) {
499
- messageBuffer.queueText(
500
- bufferKey,
501
- text,
502
- (items) => {
503
- const ts = new Date().toTimeString().slice(0, 5);
504
- const prefix = makePrefix(ts);
505
- const joined = items.map((i) => i.content).join('\n');
506
- if (items.length === 1) {
507
- hub.inject(`${prefix} ${senderDisplay}: ${joined}`);
508
- } else {
509
- hub.inject(`${prefix} ${senderDisplay}:\n${joined}`);
510
- }
511
- if (eventBus) {
512
- eventBus.emit('im.message', {
513
- from: sender,
514
- text: joined,
515
- isGroup,
516
- groupId,
517
- conversationType,
518
- });
519
- }
520
- },
521
- { delay: 5000, maxDelay: 15000 }
522
- );
523
- } else {
524
- const ts = new Date().toTimeString().slice(0, 5);
525
- hub.inject(`${makePrefix(ts)} ${senderDisplay}: ${text}`);
526
- if (eventBus) {
527
- eventBus.emit('im.message', { from: sender, text, isGroup, groupId, conversationType });
528
- }
529
- }
530
- }
531
- });
690
+ // 所有事件监听通过 setupAllListeners 统一注册(便于 rebuild 时重注册)
691
+ setupAllListeners();
532
692
 
533
693
  try {
534
694
  await chat.login({ userID: myUserId, userSig: credentials.userSig });
@@ -556,20 +716,10 @@ function createImPlugin() {
556
716
  });
557
717
 
558
718
  // 拉一次好友列表建立关系表 contacts.json
719
+ // FRIEND_LIST_UPDATED 监听已在 setupAllListeners 里注册
559
720
  seedDisplayNameFromContacts();
560
721
  await refreshContactsFromFriendList();
561
722
 
562
- // 监听好友列表变化(加/删好友触发),自动更新 contacts.json
563
- try {
564
- chat.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, () => {
565
- refreshContactsFromFriendList().catch((err) => {
566
- log.warn('refresh_contacts_on_update_failed', { message: err.message });
567
- });
568
- });
569
- } catch (err) {
570
- log.warn('friend_list_listener_register_failed', { message: err.message });
571
- }
572
-
573
723
  // 首次入网:给邀请人发一条上线消息(仅一次,用 state 记录)
574
724
  if (
575
725
  stateScope
@@ -586,6 +736,21 @@ function createImPlugin() {
586
736
  log.warn('announce_to_inviter_failed', { message: err.message });
587
737
  }
588
738
  }
739
+
740
+ // ===== 启动 Supervisor:keepalive + unhandled_rejection 监听 =====
741
+ startKeepalive();
742
+
743
+ processRejectionHandler = (reason) => {
744
+ const code = reason?.code;
745
+ if (code && SDK_REBUILD_ERROR_CODES.has(code)) {
746
+ scheduleRebuild(`unhandledRejection:${code}`);
747
+ }
748
+ };
749
+ process.on('unhandledRejection', processRejectionHandler);
750
+ log.info('supervisor_started', {
751
+ keepalive_ms: KEEPALIVE_INTERVAL_MS,
752
+ rebuild_codes: [...SDK_REBUILD_ERROR_CODES],
753
+ });
589
754
  }
590
755
 
591
756
  async function handleRequest(req) {
@@ -621,6 +786,14 @@ function createImPlugin() {
621
786
  }
622
787
 
623
788
  async function destroy() {
789
+ if (keepaliveTimer) {
790
+ clearInterval(keepaliveTimer);
791
+ keepaliveTimer = null;
792
+ }
793
+ if (processRejectionHandler) {
794
+ try { process.off('unhandledRejection', processRejectionHandler); } catch {}
795
+ processRejectionHandler = null;
796
+ }
624
797
  if (chat) {
625
798
  try {
626
799
  await chat.logout();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamnet/client",
3
- "version": "0.12.7",
3
+ "version": "0.13.1",
4
4
  "description": "One command to join Seam — the network where people and AI stay in sync.",
5
5
  "bin": {
6
6
  "seam-client": "bin/cli.js",