@seamnet/client 0.12.6 → 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.
package/lib/guardian.js CHANGED
@@ -240,6 +240,24 @@ export async function guardianRun() {
240
240
  };
241
241
  process.on('SIGTERM', () => shutdown('SIGTERM'));
242
242
  process.on('SIGINT', () => shutdown('SIGINT'));
243
+
244
+ // 最后防线:未捕获异常/Promise 只记录不退出,防止 SDK 一个错误把整个 guardian 炸了
245
+ process.on('uncaughtException', (err) => {
246
+ try {
247
+ hub.logger('guardian').error('uncaught_exception', err, {
248
+ message: err?.message?.slice(0, 300),
249
+ code: err?.code,
250
+ });
251
+ } catch {}
252
+ });
253
+ process.on('unhandledRejection', (reason) => {
254
+ try {
255
+ hub.logger('guardian').error('unhandled_rejection', reason, {
256
+ message: reason?.message?.slice(0, 300) || String(reason).slice(0, 300),
257
+ code: reason?.code,
258
+ });
259
+ } catch {}
260
+ });
243
261
  }
244
262
 
245
263
  export async function guardianStop() {
@@ -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,148 +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
- chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, (event) => {
378
- for (const msg of event.data) {
379
- if (msg.from === myUserId) continue;
380
-
381
- const isGroup = msg.conversationType === TencentCloudChat.TYPES.CONV_GROUP;
382
- // sender 用于 event-bus(保持 userId 原始值);senderDisplay 用于注入终端(name · userId)
383
- const sender = msg.from;
384
- // 若无 msg.nick 且未缓存,异步拉 profile(下次消息就能用)
385
- if (!msg.nick && !displayNameCache.has(sender)) {
386
- prefetchDisplayName(sender).catch(() => {});
387
- }
388
- const senderDisplay = formatSenderDisplay(msg);
389
- const groupId = isGroup ? msg.to : null;
390
- const ts = new Date().toTimeString().slice(0, 5);
391
- const conversationType = msg.conversationType;
392
-
393
- const replyTool = isGroup ? 'seam msg group' : 'seam msg send';
394
-
395
- // 图片消息:下载 URL 存 inbox
396
- if (msg.type === TencentCloudChat.TYPES.MSG_IMAGE) {
397
- const inbox = hub.service('inbox');
398
- const imgInfo =
399
- msg.payload?.imageInfoArray?.find((i) => i.sizeType === 3) ||
400
- msg.payload?.imageInfoArray?.[0];
401
- if (imgInfo?.url && inbox) {
402
- (async () => {
403
- try {
404
- const resp = await fetch(imgInfo.url);
405
- if (!resp.ok) throw new Error(`http ${resp.status}`);
406
- const buf = Buffer.from(await resp.arrayBuffer());
407
- const p = inbox.save(buf, { ext: 'jpg', src: 'im' });
408
- log.info('im_image_saved', { sender, bytes: buf.length, path: p });
409
- const prefix = isGroup
410
- ? `💬 [Seam群图片 ${ts} ${groupId} → ${replyTool}]`
411
- : `💬 [Seam图片 ${ts} → ${replyTool}]`;
412
- hub.inject(`${prefix} ${senderDisplay} → ${p}`);
413
- } catch (e) {
414
- log.error('im_image_download_failed', e, { sender });
415
- hub.inject(`💬 [Seam ${ts} → ${replyTool}] ${senderDisplay}: [图片下载失败: ${e.message}]`);
416
- }
417
- })();
418
- }
419
- continue;
420
- }
421
-
422
- // 文件消息
423
- if (msg.type === TencentCloudChat.TYPES.MSG_FILE) {
424
- const inbox = hub.service('inbox');
425
- const fileUrl = msg.payload?.fileUrl;
426
- const fileName = msg.payload?.fileName || `file_${Date.now()}.bin`;
427
- const ext = path.extname(fileName).replace(/^\./, '') || 'bin';
428
- if (fileUrl && inbox) {
429
- (async () => {
430
- try {
431
- const resp = await fetch(fileUrl);
432
- if (!resp.ok) throw new Error(`http ${resp.status}`);
433
- const buf = Buffer.from(await resp.arrayBuffer());
434
- const p = inbox.save(buf, { ext, src: 'im' });
435
- log.info('im_file_saved', { sender, bytes: buf.length, path: p });
436
- const prefix = isGroup
437
- ? `💬 [Seam群文件 ${ts} ${groupId} → ${replyTool}]`
438
- : `💬 [Seam文件 ${ts} → ${replyTool}]`;
439
- hub.inject(
440
- `${prefix} ${senderDisplay} → ${p} (原名: ${fileName}, ${buf.length} bytes)`
441
- );
442
- } catch (e) {
443
- log.error('im_file_download_failed', e, { sender });
444
- hub.inject(`💬 [Seam ${ts} → ${replyTool}] ${sender}: [文件下载失败: ${e.message}]`);
445
- }
446
- })();
447
- }
448
- continue;
449
- }
450
-
451
- // 文本消息(含其他未处理类型用占位)
452
- let text = '';
453
- if (msg.type === TencentCloudChat.TYPES.MSG_TEXT) {
454
- text = msg.payload.text;
455
- } else {
456
- text = `[${msg.type}]`;
457
- }
458
- if (!text) continue;
459
-
460
- log.info('message_received', {
461
- from: msg.from,
462
- isGroup,
463
- textLength: text.length,
464
- });
465
-
466
- const bufferKey = isGroup
467
- ? `im:group:${msg.to}:${msg.from}`
468
- : `im:${msg.from}`;
469
-
470
- const makePrefix = (ts2) =>
471
- isGroup
472
- ? `💬 [Seam群 ${ts2} ${groupId} → ${replyTool}]`
473
- : `💬 [Seam ${ts2} → ${replyTool}]`;
474
-
475
- if (messageBuffer) {
476
- messageBuffer.queueText(
477
- bufferKey,
478
- text,
479
- (items) => {
480
- const ts = new Date().toTimeString().slice(0, 5);
481
- const prefix = makePrefix(ts);
482
- const joined = items.map((i) => i.content).join('\n');
483
- if (items.length === 1) {
484
- hub.inject(`${prefix} ${senderDisplay}: ${joined}`);
485
- } else {
486
- hub.inject(`${prefix} ${senderDisplay}:\n${joined}`);
487
- }
488
- if (eventBus) {
489
- eventBus.emit('im.message', {
490
- from: sender,
491
- text: joined,
492
- isGroup,
493
- groupId,
494
- conversationType,
495
- });
496
- }
497
- },
498
- { delay: 5000, maxDelay: 15000 }
499
- );
500
- } else {
501
- const ts = new Date().toTimeString().slice(0, 5);
502
- hub.inject(`${makePrefix(ts)} ${senderDisplay}: ${text}`);
503
- if (eventBus) {
504
- eventBus.emit('im.message', { from: sender, text, isGroup, groupId, conversationType });
505
- }
506
- }
507
- }
508
- });
630
+ // 所有事件监听通过 setupAllListeners 统一注册(便于 rebuild 时重注册)
631
+ setupAllListeners();
509
632
 
510
633
  try {
511
634
  await chat.login({ userID: myUserId, userSig: credentials.userSig });
@@ -533,20 +656,10 @@ function createImPlugin() {
533
656
  });
534
657
 
535
658
  // 拉一次好友列表建立关系表 contacts.json
659
+ // FRIEND_LIST_UPDATED 监听已在 setupAllListeners 里注册
536
660
  seedDisplayNameFromContacts();
537
661
  await refreshContactsFromFriendList();
538
662
 
539
- // 监听好友列表变化(加/删好友触发),自动更新 contacts.json
540
- try {
541
- chat.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, () => {
542
- refreshContactsFromFriendList().catch((err) => {
543
- log.warn('refresh_contacts_on_update_failed', { message: err.message });
544
- });
545
- });
546
- } catch (err) {
547
- log.warn('friend_list_listener_register_failed', { message: err.message });
548
- }
549
-
550
663
  // 首次入网:给邀请人发一条上线消息(仅一次,用 state 记录)
551
664
  if (
552
665
  stateScope
@@ -563,6 +676,21 @@ function createImPlugin() {
563
676
  log.warn('announce_to_inviter_failed', { message: err.message });
564
677
  }
565
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
+ });
566
694
  }
567
695
 
568
696
  async function handleRequest(req) {
@@ -598,6 +726,14 @@ function createImPlugin() {
598
726
  }
599
727
 
600
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
+ }
601
737
  if (chat) {
602
738
  try {
603
739
  await chat.logout();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamnet/client",
3
- "version": "0.12.6",
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",