@seamnet/client 0.12.7 → 0.13.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.
@@ -134,6 +134,19 @@ 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
+ let rebuildInProgress = false;
140
+ let lastRebuildAt = 0;
141
+ let keepaliveTimer = null;
142
+ let processRejectionHandler = null;
143
+ const REBUILD_COOLDOWN_MS = 10_000;
144
+ const REBUILD_DEBOUNCE_MS = 3_000;
145
+ const KEEPALIVE_INTERVAL_MS = 3 * 60 * 1000;
146
+ const KEEPALIVE_TIMEOUT_MS = 10_000;
147
+ // SDK 错误 code 需要触发 rebuild(2801=PING 超时,WebSocket 应用层僵尸)
148
+ const SDK_REBUILD_ERROR_CODES = new Set([2801]);
149
+
137
150
  async function prefetchDisplayName(userId) {
138
151
  if (!chat || !userId || displayNameCache.has(userId)) return;
139
152
  displayNameCache.set(userId, null);
@@ -326,6 +339,255 @@ function createImPlugin() {
326
339
  return { ok: true };
327
340
  }
328
341
 
342
+ // ===== 事件 handler(独立函数,便于 rebuild 时重新注册)=====
343
+
344
+ function onSdkReady() {
345
+ sdkReady = true;
346
+ log.info('sdk_ready', { userId: myUserId });
347
+ }
348
+
349
+ function onSdkNotReady() {
350
+ sdkReady = false;
351
+ log.warn('sdk_not_ready');
352
+ }
353
+
354
+ function onSdkError(event) {
355
+ const err = event?.data || event;
356
+ const code = err?.code;
357
+ log.warn('sdk_error', {
358
+ code,
359
+ message: err?.message || String(err).slice(0, 200),
360
+ });
361
+ if (SDK_REBUILD_ERROR_CODES.has(code)) {
362
+ scheduleRebuild(`sdk_error:${code}`);
363
+ }
364
+ }
365
+
366
+ function onKickedOut(event) {
367
+ log.warn('sdk_kicked_out', {
368
+ type: event?.data?.type,
369
+ message: event?.data?.message,
370
+ });
371
+ sdkReady = false;
372
+ scheduleRebuild('kicked_out');
373
+ }
374
+
375
+ function onNetStateChange(event) {
376
+ log.info('sdk_net_state', { state: event?.data?.state });
377
+ }
378
+
379
+ function onFriendListUpdated() {
380
+ refreshContactsFromFriendList().catch((err) => {
381
+ log.warn('refresh_contacts_on_update_failed', { message: err.message });
382
+ });
383
+ }
384
+
385
+ function onMessageReceived(event) {
386
+ for (const msg of event.data) {
387
+ if (msg.from === myUserId) continue;
388
+
389
+ const isGroup = msg.conversationType === TencentCloudChat.TYPES.CONV_GROUP;
390
+ const sender = msg.from;
391
+ if (!msg.nick && !displayNameCache.has(sender)) {
392
+ prefetchDisplayName(sender).catch(() => {});
393
+ }
394
+ const senderDisplay = formatSenderDisplay(msg);
395
+ const groupId = isGroup ? msg.to : null;
396
+ const ts = new Date().toTimeString().slice(0, 5);
397
+ const conversationType = msg.conversationType;
398
+ const replyTool = isGroup ? 'seam msg group' : 'seam msg send';
399
+
400
+ // 图片消息
401
+ if (msg.type === TencentCloudChat.TYPES.MSG_IMAGE) {
402
+ const inbox = hub.service('inbox');
403
+ const imgInfo =
404
+ msg.payload?.imageInfoArray?.find((i) => i.sizeType === 3) ||
405
+ msg.payload?.imageInfoArray?.[0];
406
+ if (imgInfo?.url && inbox) {
407
+ (async () => {
408
+ try {
409
+ const resp = await fetch(imgInfo.url);
410
+ if (!resp.ok) throw new Error(`http ${resp.status}`);
411
+ const buf = Buffer.from(await resp.arrayBuffer());
412
+ const p = inbox.save(buf, { ext: 'jpg', src: 'im' });
413
+ log.info('im_image_saved', { sender, bytes: buf.length, path: p });
414
+ const prefix = isGroup
415
+ ? `💬 [Seam群图片 ${ts} ${groupId} → ${replyTool}]`
416
+ : `💬 [Seam图片 ${ts} → ${replyTool}]`;
417
+ hub.inject(`${prefix} ${senderDisplay} → ${p}`);
418
+ } catch (e) {
419
+ log.error('im_image_download_failed', e, { sender });
420
+ hub.inject(`💬 [Seam ${ts} → ${replyTool}] ${senderDisplay}: [图片下载失败: ${e.message}]`);
421
+ }
422
+ })();
423
+ }
424
+ continue;
425
+ }
426
+
427
+ // 文件消息
428
+ if (msg.type === TencentCloudChat.TYPES.MSG_FILE) {
429
+ const inbox = hub.service('inbox');
430
+ const fileUrl = msg.payload?.fileUrl;
431
+ const fileName = msg.payload?.fileName || `file_${Date.now()}.bin`;
432
+ const ext = path.extname(fileName).replace(/^\./, '') || 'bin';
433
+ if (fileUrl && inbox) {
434
+ (async () => {
435
+ try {
436
+ const resp = await fetch(fileUrl);
437
+ if (!resp.ok) throw new Error(`http ${resp.status}`);
438
+ const buf = Buffer.from(await resp.arrayBuffer());
439
+ const p = inbox.save(buf, { ext, src: 'im' });
440
+ log.info('im_file_saved', { sender, bytes: buf.length, path: p });
441
+ const prefix = isGroup
442
+ ? `💬 [Seam群文件 ${ts} ${groupId} → ${replyTool}]`
443
+ : `💬 [Seam文件 ${ts} → ${replyTool}]`;
444
+ hub.inject(
445
+ `${prefix} ${senderDisplay} → ${p} (原名: ${fileName}, ${buf.length} bytes)`
446
+ );
447
+ } catch (e) {
448
+ log.error('im_file_download_failed', e, { sender });
449
+ hub.inject(`💬 [Seam ${ts} → ${replyTool}] ${sender}: [文件下载失败: ${e.message}]`);
450
+ }
451
+ })();
452
+ }
453
+ continue;
454
+ }
455
+
456
+ // 文本消息
457
+ let text = '';
458
+ if (msg.type === TencentCloudChat.TYPES.MSG_TEXT) {
459
+ text = msg.payload.text;
460
+ } else {
461
+ text = `[${msg.type}]`;
462
+ }
463
+ if (!text) continue;
464
+
465
+ log.info('message_received', { from: msg.from, isGroup, textLength: text.length });
466
+
467
+ const bufferKey = isGroup ? `im:group:${msg.to}:${msg.from}` : `im:${msg.from}`;
468
+ const makePrefix = (ts2) =>
469
+ isGroup
470
+ ? `💬 [Seam群 ${ts2} ${groupId} → ${replyTool}]`
471
+ : `💬 [Seam ${ts2} → ${replyTool}]`;
472
+
473
+ if (messageBuffer) {
474
+ messageBuffer.queueText(
475
+ bufferKey,
476
+ text,
477
+ (items) => {
478
+ const tsNow = new Date().toTimeString().slice(0, 5);
479
+ const prefix = makePrefix(tsNow);
480
+ const joined = items.map((i) => i.content).join('\n');
481
+ if (items.length === 1) {
482
+ hub.inject(`${prefix} ${senderDisplay}: ${joined}`);
483
+ } else {
484
+ hub.inject(`${prefix} ${senderDisplay}:\n${joined}`);
485
+ }
486
+ if (eventBus) {
487
+ eventBus.emit('im.message', { from: sender, text: joined, isGroup, groupId, conversationType });
488
+ }
489
+ },
490
+ { delay: 5000, maxDelay: 15000 }
491
+ );
492
+ } else {
493
+ const tsNow = new Date().toTimeString().slice(0, 5);
494
+ hub.inject(`${makePrefix(tsNow)} ${senderDisplay}: ${text}`);
495
+ if (eventBus) {
496
+ eventBus.emit('im.message', { from: sender, text, isGroup, groupId, conversationType });
497
+ }
498
+ }
499
+ }
500
+ }
501
+
502
+ function setupAllListeners() {
503
+ chat.on(TencentCloudChat.EVENT.SDK_READY, onSdkReady);
504
+ chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady);
505
+ chat.on(TencentCloudChat.EVENT.ERROR, onSdkError);
506
+ chat.on(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut);
507
+ chat.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange);
508
+ chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
509
+ try {
510
+ chat.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, onFriendListUpdated);
511
+ } catch (err) {
512
+ log.warn('friend_list_listener_register_failed', { message: err.message });
513
+ }
514
+ }
515
+
516
+ // ===== 重建 chat 实例(SDK 僵尸时用)=====
517
+
518
+ async function createAndLoginChat() {
519
+ delete global.window;
520
+ chat = TencentCloudChat.create({ SDKAppID: Number(credentialsRef.sdkAppId) });
521
+ setupSdkGlobals();
522
+ try {
523
+ const TIMUploadPlugin = require('tim-upload-plugin');
524
+ chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin });
525
+ } catch {}
526
+ chat.setLogLevel(4);
527
+ setupAllListeners();
528
+ await chat.login({
529
+ userID: myUserId,
530
+ userSig: credentialsRef.userSig,
531
+ });
532
+ // 等 SDK_READY(最多 10s)
533
+ await new Promise((resolve) => {
534
+ if (sdkReady) return resolve();
535
+ const timer = setTimeout(() => resolve(), 10000);
536
+ const once = () => { clearTimeout(timer); resolve(); };
537
+ chat.on(TencentCloudChat.EVENT.SDK_READY, once);
538
+ });
539
+ }
540
+
541
+ async function rebuildChat(reason) {
542
+ log.warn('chat_rebuild_start', { reason });
543
+ sdkReady = false;
544
+ try { await chat.logout(); } catch {}
545
+ try { chat.destroy && chat.destroy(); } catch {}
546
+ try {
547
+ await createAndLoginChat();
548
+ try { await refreshContactsFromFriendList(); } catch {}
549
+ log.info('chat_rebuild_done', { reason });
550
+ } catch (e) {
551
+ log.error('chat_rebuild_failed', e, { reason });
552
+ }
553
+ }
554
+
555
+ function scheduleRebuild(reason) {
556
+ if (rebuildInProgress) return;
557
+ const now = Date.now();
558
+ if (now - lastRebuildAt < REBUILD_COOLDOWN_MS) {
559
+ log.info('chat_rebuild_skipped_cooldown', { reason });
560
+ return;
561
+ }
562
+ rebuildInProgress = true;
563
+ setTimeout(async () => {
564
+ try {
565
+ await rebuildChat(reason);
566
+ lastRebuildAt = Date.now();
567
+ } finally {
568
+ rebuildInProgress = false;
569
+ }
570
+ }, REBUILD_DEBOUNCE_MS);
571
+ }
572
+
573
+ function startKeepalive() {
574
+ if (keepaliveTimer) return;
575
+ keepaliveTimer = setInterval(async () => {
576
+ if (!sdkReady || rebuildInProgress) return;
577
+ try {
578
+ await Promise.race([
579
+ chat.getMyProfile(),
580
+ new Promise((_, reject) =>
581
+ setTimeout(() => reject(new Error('keepalive timeout')), KEEPALIVE_TIMEOUT_MS)
582
+ ),
583
+ ]);
584
+ } catch (e) {
585
+ log.warn('keepalive_failed', { message: e.message });
586
+ scheduleRebuild(`keepalive:${e.message}`);
587
+ }
588
+ }, KEEPALIVE_INTERVAL_MS);
589
+ }
590
+
329
591
  async function init(h) {
330
592
  hub = h;
331
593
  log = hub.logger('im');
@@ -345,6 +607,7 @@ function createImPlugin() {
345
607
  }
346
608
 
347
609
  myUserId = credentials.userId;
610
+ credentialsRef = credentials;
348
611
 
349
612
  // 先 delete window 让 SDK 走 node 路径
350
613
  delete global.window;
@@ -364,171 +627,8 @@ function createImPlugin() {
364
627
 
365
628
  chat.setLogLevel(4);
366
629
 
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
- });
630
+ // 所有事件监听通过 setupAllListeners 统一注册(便于 rebuild 时重注册)
631
+ setupAllListeners();
532
632
 
533
633
  try {
534
634
  await chat.login({ userID: myUserId, userSig: credentials.userSig });
@@ -556,20 +656,10 @@ function createImPlugin() {
556
656
  });
557
657
 
558
658
  // 拉一次好友列表建立关系表 contacts.json
659
+ // FRIEND_LIST_UPDATED 监听已在 setupAllListeners 里注册
559
660
  seedDisplayNameFromContacts();
560
661
  await refreshContactsFromFriendList();
561
662
 
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
663
  // 首次入网:给邀请人发一条上线消息(仅一次,用 state 记录)
574
664
  if (
575
665
  stateScope
@@ -586,6 +676,21 @@ function createImPlugin() {
586
676
  log.warn('announce_to_inviter_failed', { message: err.message });
587
677
  }
588
678
  }
679
+
680
+ // ===== 启动 Supervisor:keepalive + unhandled_rejection 监听 =====
681
+ startKeepalive();
682
+
683
+ processRejectionHandler = (reason) => {
684
+ const code = reason?.code;
685
+ if (code && SDK_REBUILD_ERROR_CODES.has(code)) {
686
+ scheduleRebuild(`unhandledRejection:${code}`);
687
+ }
688
+ };
689
+ process.on('unhandledRejection', processRejectionHandler);
690
+ log.info('supervisor_started', {
691
+ keepalive_ms: KEEPALIVE_INTERVAL_MS,
692
+ rebuild_codes: [...SDK_REBUILD_ERROR_CODES],
693
+ });
589
694
  }
590
695
 
591
696
  async function handleRequest(req) {
@@ -621,6 +726,14 @@ function createImPlugin() {
621
726
  }
622
727
 
623
728
  async function destroy() {
729
+ if (keepaliveTimer) {
730
+ clearInterval(keepaliveTimer);
731
+ keepaliveTimer = null;
732
+ }
733
+ if (processRejectionHandler) {
734
+ try { process.off('unhandledRejection', processRejectionHandler); } catch {}
735
+ processRejectionHandler = null;
736
+ }
624
737
  if (chat) {
625
738
  try {
626
739
  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.0",
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",