@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.
- package/lib/plugins/im/index.cjs +349 -176
- package/package.json +1 -1
package/lib/plugins/im/index.cjs
CHANGED
|
@@ -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
|
-
|
|
368
|
-
|
|
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();
|