@seamnet/client 0.13.0 → 0.13.2
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 +221 -129
- package/package.json +1 -1
package/lib/plugins/im/index.cjs
CHANGED
|
@@ -136,17 +136,41 @@ function createImPlugin() {
|
|
|
136
136
|
|
|
137
137
|
// ===== Supervisor 状态(SDK 僵尸时自动重建 chat 实例)=====
|
|
138
138
|
let credentialsRef = null;
|
|
139
|
-
|
|
140
|
-
let
|
|
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 校验代数,老代事件忽略
|
|
141
145
|
let keepaliveTimer = null;
|
|
142
146
|
let processRejectionHandler = null;
|
|
147
|
+
// 重建失败后自驱重试:指数退避 + jitter;成功后重置
|
|
148
|
+
let rebuildRetryAttempt = 0;
|
|
149
|
+
let rebuildRetryTimer = null;
|
|
143
150
|
const REBUILD_COOLDOWN_MS = 10_000;
|
|
144
151
|
const REBUILD_DEBOUNCE_MS = 3_000;
|
|
152
|
+
const REBUILD_WATCHDOG_MS = 30_000; // 整个重建最多 30s,超时强制失败
|
|
153
|
+
const SDK_CALL_TIMEOUT_MS = 10_000; // 单个 SDK 调用超时
|
|
154
|
+
const LOGOUT_TIMEOUT_MS = 5_000;
|
|
155
|
+
const LOGIN_TIMEOUT_MS = 15_000;
|
|
156
|
+
const SDK_READY_TIMEOUT_MS = 10_000; // 等 SDK_READY 最长 10s,超时 reject
|
|
145
157
|
const KEEPALIVE_INTERVAL_MS = 3 * 60 * 1000;
|
|
146
158
|
const KEEPALIVE_TIMEOUT_MS = 10_000;
|
|
159
|
+
// 重建失败退避表(最后一档持续)
|
|
160
|
+
const REBUILD_RETRY_BACKOFF_MS = [3_000, 10_000, 30_000, 60_000];
|
|
147
161
|
// SDK 错误 code 需要触发 rebuild(2801=PING 超时,WebSocket 应用层僵尸)
|
|
148
162
|
const SDK_REBUILD_ERROR_CODES = new Set([2801]);
|
|
149
163
|
|
|
164
|
+
function withTimeout(promise, ms, name) {
|
|
165
|
+
let tid = null;
|
|
166
|
+
const timeoutP = new Promise((_, reject) => {
|
|
167
|
+
tid = setTimeout(() => reject(new Error(`${name} timeout ${ms}ms`)), ms);
|
|
168
|
+
});
|
|
169
|
+
return Promise.race([promise, timeoutP]).finally(() => {
|
|
170
|
+
if (tid) clearTimeout(tid);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
150
174
|
async function prefetchDisplayName(userId) {
|
|
151
175
|
if (!chat || !userId || displayNameCache.has(userId)) return;
|
|
152
176
|
displayNameCache.set(userId, null);
|
|
@@ -339,50 +363,62 @@ function createImPlugin() {
|
|
|
339
363
|
return { ok: true };
|
|
340
364
|
}
|
|
341
365
|
|
|
342
|
-
// ===== 事件 handler
|
|
366
|
+
// ===== 事件 handler 工厂(每次 attachListenersTo 时绑定当前 generation)=====
|
|
367
|
+
// handler 校验 generation,老实例晚到的事件直接忽略,避免污染新状态
|
|
343
368
|
|
|
344
|
-
function
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
369
|
+
function makeHandlers(gen) {
|
|
370
|
+
return {
|
|
371
|
+
onSdkReady: () => {
|
|
372
|
+
if (gen !== chatGeneration) return;
|
|
373
|
+
// sdkReady 的置 true 统一在 rebuildChat/init 成功 swap 后做,handler 只记录
|
|
374
|
+
log.info('sdk_ready', { userId: myUserId, gen });
|
|
375
|
+
},
|
|
376
|
+
onSdkNotReady: () => {
|
|
377
|
+
if (gen !== chatGeneration) return;
|
|
378
|
+
sdkReady = false;
|
|
379
|
+
log.warn('sdk_not_ready', { gen });
|
|
380
|
+
},
|
|
381
|
+
onSdkError: (event) => {
|
|
382
|
+
if (gen !== chatGeneration) return;
|
|
383
|
+
const err = event?.data || event;
|
|
384
|
+
const code = err?.code;
|
|
385
|
+
log.warn('sdk_error', {
|
|
386
|
+
code,
|
|
387
|
+
message: err?.message || String(err).slice(0, 200),
|
|
388
|
+
gen,
|
|
389
|
+
});
|
|
390
|
+
if (SDK_REBUILD_ERROR_CODES.has(code)) {
|
|
391
|
+
scheduleRebuild(`sdk_error:${code}`);
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
onKickedOut: (event) => {
|
|
395
|
+
if (gen !== chatGeneration) return;
|
|
396
|
+
log.warn('sdk_kicked_out', {
|
|
397
|
+
type: event?.data?.type,
|
|
398
|
+
message: event?.data?.message,
|
|
399
|
+
gen,
|
|
400
|
+
});
|
|
401
|
+
sdkReady = false;
|
|
402
|
+
scheduleRebuild('kicked_out');
|
|
403
|
+
},
|
|
404
|
+
onNetStateChange: (event) => {
|
|
405
|
+
if (gen !== chatGeneration) return;
|
|
406
|
+
log.info('sdk_net_state', { state: event?.data?.state, gen });
|
|
407
|
+
},
|
|
408
|
+
onFriendListUpdated: () => {
|
|
409
|
+
if (gen !== chatGeneration) return;
|
|
410
|
+
refreshContactsFromFriendList().catch((err) => {
|
|
411
|
+
log.warn('refresh_contacts_on_update_failed', { message: err.message });
|
|
412
|
+
});
|
|
413
|
+
},
|
|
414
|
+
onMessageReceived: (event) => {
|
|
415
|
+
if (gen !== chatGeneration) return;
|
|
416
|
+
handleMessageEvent(event);
|
|
417
|
+
},
|
|
418
|
+
};
|
|
383
419
|
}
|
|
384
420
|
|
|
385
|
-
function
|
|
421
|
+
function handleMessageEvent(event) {
|
|
386
422
|
for (const msg of event.data) {
|
|
387
423
|
if (msg.from === myUserId) continue;
|
|
388
424
|
|
|
@@ -499,88 +535,166 @@ function createImPlugin() {
|
|
|
499
535
|
}
|
|
500
536
|
}
|
|
501
537
|
|
|
502
|
-
function
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
538
|
+
function attachListenersTo(chatInstance, gen) {
|
|
539
|
+
const h = makeHandlers(gen);
|
|
540
|
+
chatInstance.on(TencentCloudChat.EVENT.SDK_READY, h.onSdkReady);
|
|
541
|
+
chatInstance.on(TencentCloudChat.EVENT.SDK_NOT_READY, h.onSdkNotReady);
|
|
542
|
+
chatInstance.on(TencentCloudChat.EVENT.ERROR, h.onSdkError);
|
|
543
|
+
chatInstance.on(TencentCloudChat.EVENT.KICKED_OUT, h.onKickedOut);
|
|
544
|
+
chatInstance.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, h.onNetStateChange);
|
|
545
|
+
chatInstance.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, h.onMessageReceived);
|
|
509
546
|
try {
|
|
510
|
-
|
|
547
|
+
chatInstance.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, h.onFriendListUpdated);
|
|
511
548
|
} catch (err) {
|
|
512
549
|
log.warn('friend_list_listener_register_failed', { message: err.message });
|
|
513
550
|
}
|
|
514
551
|
}
|
|
515
552
|
|
|
516
|
-
|
|
553
|
+
function waitForSdkReadyStrict(chatInstance, ms) {
|
|
554
|
+
return new Promise((resolve, reject) => {
|
|
555
|
+
const timer = setTimeout(
|
|
556
|
+
() => reject(new Error(`SDK_READY timeout ${ms}ms`)),
|
|
557
|
+
ms
|
|
558
|
+
);
|
|
559
|
+
chatInstance.on(TencentCloudChat.EVENT.SDK_READY, () => {
|
|
560
|
+
clearTimeout(timer);
|
|
561
|
+
resolve();
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
}
|
|
517
565
|
|
|
518
|
-
|
|
566
|
+
// ===== 候选模式构建新 chat(失败则隔离销毁,不污染 active chat)=====
|
|
567
|
+
async function buildChatCandidate() {
|
|
568
|
+
const myGen = ++chatGeneration;
|
|
569
|
+
// 清 globals,让 setupSdkGlobals 重新铺 window 等
|
|
519
570
|
delete global.window;
|
|
520
|
-
|
|
571
|
+
global._imSdkGlobalsSet = false;
|
|
572
|
+
const nextChat = TencentCloudChat.create({ SDKAppID: Number(credentialsRef.sdkAppId) });
|
|
521
573
|
setupSdkGlobals();
|
|
522
574
|
try {
|
|
523
575
|
const TIMUploadPlugin = require('tim-upload-plugin');
|
|
524
|
-
|
|
576
|
+
nextChat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin });
|
|
525
577
|
} catch {}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
578
|
+
nextChat.setLogLevel(4);
|
|
579
|
+
attachListenersTo(nextChat, myGen);
|
|
580
|
+
try {
|
|
581
|
+
await withTimeout(
|
|
582
|
+
nextChat.login({ userID: myUserId, userSig: credentialsRef.userSig }),
|
|
583
|
+
LOGIN_TIMEOUT_MS,
|
|
584
|
+
'chat.login'
|
|
585
|
+
);
|
|
586
|
+
// 严格等 SDK_READY:超时 reject,不再假成功
|
|
587
|
+
await waitForSdkReadyStrict(nextChat, SDK_READY_TIMEOUT_MS);
|
|
588
|
+
} catch (err) {
|
|
589
|
+
// 失败的 candidate 必须隔离销毁,避免 late event 污染新状态
|
|
590
|
+
try {
|
|
591
|
+
await withTimeout(
|
|
592
|
+
(nextChat.logout && nextChat.logout().catch(() => {})) || Promise.resolve(),
|
|
593
|
+
LOGOUT_TIMEOUT_MS,
|
|
594
|
+
'cand_logout'
|
|
595
|
+
);
|
|
596
|
+
} catch {}
|
|
597
|
+
try { nextChat.destroy && nextChat.destroy(); } catch {}
|
|
598
|
+
throw err;
|
|
599
|
+
}
|
|
600
|
+
return { nextChat, gen: myGen };
|
|
539
601
|
}
|
|
540
602
|
|
|
603
|
+
// ===== 重建 chat 实例(SDK 僵尸时用)=====
|
|
541
604
|
async function rebuildChat(reason) {
|
|
542
|
-
log.warn('chat_rebuild_start', { reason });
|
|
605
|
+
log.warn('chat_rebuild_start', { reason, prevGen: chatGeneration });
|
|
543
606
|
sdkReady = false;
|
|
544
|
-
|
|
545
|
-
|
|
607
|
+
const oldChat = chat;
|
|
608
|
+
|
|
609
|
+
// 候选模式:先把新 chat 完整拉起来(login + SDK_READY),失败则 throw,老 chat 保持不变
|
|
610
|
+
const { nextChat, gen: newGen } = await buildChatCandidate();
|
|
611
|
+
|
|
612
|
+
// 成功:swap 到新 chat;sdkReady 只在 swap 完成后置 true
|
|
613
|
+
chat = nextChat;
|
|
614
|
+
sdkReady = true;
|
|
615
|
+
log.info('chat_rebuild_done', { reason, newGen });
|
|
616
|
+
|
|
617
|
+
// 老 chat 后台销毁,不阻塞新 chat 服务
|
|
618
|
+
(async () => {
|
|
619
|
+
try {
|
|
620
|
+
if (oldChat && oldChat.logout) {
|
|
621
|
+
await withTimeout(oldChat.logout().catch(() => {}), LOGOUT_TIMEOUT_MS, 'old_logout');
|
|
622
|
+
}
|
|
623
|
+
} catch (e) {
|
|
624
|
+
log.warn('old_logout_timeout', { message: e.message });
|
|
625
|
+
}
|
|
626
|
+
try { oldChat && oldChat.destroy && oldChat.destroy(); } catch {}
|
|
627
|
+
})();
|
|
628
|
+
|
|
546
629
|
try {
|
|
547
|
-
await
|
|
548
|
-
try { await refreshContactsFromFriendList(); } catch {}
|
|
549
|
-
log.info('chat_rebuild_done', { reason });
|
|
630
|
+
await withTimeout(refreshContactsFromFriendList(), SDK_CALL_TIMEOUT_MS, 'refresh_contacts');
|
|
550
631
|
} catch (e) {
|
|
551
|
-
log.
|
|
632
|
+
log.warn('refresh_contacts_after_rebuild_timeout', { message: e.message });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function scheduleRebuildRetry(reason, delayMs) {
|
|
637
|
+
if (rebuildRetryTimer) clearTimeout(rebuildRetryTimer);
|
|
638
|
+
rebuildRetryTimer = setTimeout(() => {
|
|
639
|
+
rebuildRetryTimer = null;
|
|
640
|
+
scheduleRebuild(reason);
|
|
641
|
+
}, delayMs);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function doRebuild(reason) {
|
|
645
|
+
rebuildScheduled = false;
|
|
646
|
+
rebuilding = true;
|
|
647
|
+
try {
|
|
648
|
+
await withTimeout(rebuildChat(reason), REBUILD_WATCHDOG_MS, 'rebuild_watchdog');
|
|
649
|
+
lastRebuildSuccessAt = Date.now();
|
|
650
|
+
rebuildRetryAttempt = 0;
|
|
651
|
+
if (rebuildRetryTimer) { clearTimeout(rebuildRetryTimer); rebuildRetryTimer = null; }
|
|
652
|
+
} catch (e) {
|
|
653
|
+
log.error('chat_rebuild_failed', e, { reason, message: e.message, attempt: rebuildRetryAttempt });
|
|
654
|
+
// 自驱重试:指数退避 + jitter;不依赖外部信号
|
|
655
|
+
const idx = Math.min(rebuildRetryAttempt, REBUILD_RETRY_BACKOFF_MS.length - 1);
|
|
656
|
+
const base = REBUILD_RETRY_BACKOFF_MS[idx];
|
|
657
|
+
const jitter = Math.floor(Math.random() * 1000);
|
|
658
|
+
const delay = base + jitter;
|
|
659
|
+
rebuildRetryAttempt += 1;
|
|
660
|
+
log.info('chat_rebuild_retry_scheduled', { attempt: rebuildRetryAttempt, delayMs: delay });
|
|
661
|
+
scheduleRebuildRetry(`auto_retry:${reason}`, delay);
|
|
662
|
+
} finally {
|
|
663
|
+
rebuilding = false;
|
|
664
|
+
if (rebuildRequestedAgain) {
|
|
665
|
+
rebuildRequestedAgain = false;
|
|
666
|
+
scheduleRebuild('trailing');
|
|
667
|
+
}
|
|
552
668
|
}
|
|
553
669
|
}
|
|
554
670
|
|
|
555
671
|
function scheduleRebuild(reason) {
|
|
556
|
-
|
|
672
|
+
// 已在重建中:标记 trailing,结束后再跑一轮
|
|
673
|
+
if (rebuilding) {
|
|
674
|
+
rebuildRequestedAgain = true;
|
|
675
|
+
log.info('chat_rebuild_queued_trailing', { reason });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
// 已在等待启动:合并(debounce)
|
|
679
|
+
if (rebuildScheduled) return;
|
|
680
|
+
// Cooldown 只针对上次"成功"的重建——失败后允许立即重试
|
|
557
681
|
const now = Date.now();
|
|
558
|
-
if (now -
|
|
682
|
+
if (now - lastRebuildSuccessAt < REBUILD_COOLDOWN_MS) {
|
|
559
683
|
log.info('chat_rebuild_skipped_cooldown', { reason });
|
|
560
684
|
return;
|
|
561
685
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
lastRebuildAt = Date.now();
|
|
567
|
-
} finally {
|
|
568
|
-
rebuildInProgress = false;
|
|
569
|
-
}
|
|
570
|
-
}, REBUILD_DEBOUNCE_MS);
|
|
686
|
+
// 立即降级:阻止发送路径继续用坏 chat
|
|
687
|
+
sdkReady = false;
|
|
688
|
+
rebuildScheduled = true;
|
|
689
|
+
setTimeout(() => { doRebuild(reason).catch(() => {}); }, REBUILD_DEBOUNCE_MS);
|
|
571
690
|
}
|
|
572
691
|
|
|
573
692
|
function startKeepalive() {
|
|
574
693
|
if (keepaliveTimer) return;
|
|
575
694
|
keepaliveTimer = setInterval(async () => {
|
|
576
|
-
if (!sdkReady ||
|
|
695
|
+
if (!sdkReady || rebuilding || rebuildScheduled) return;
|
|
577
696
|
try {
|
|
578
|
-
await
|
|
579
|
-
chat.getMyProfile(),
|
|
580
|
-
new Promise((_, reject) =>
|
|
581
|
-
setTimeout(() => reject(new Error('keepalive timeout')), KEEPALIVE_TIMEOUT_MS)
|
|
582
|
-
),
|
|
583
|
-
]);
|
|
697
|
+
await withTimeout(chat.getMyProfile(), KEEPALIVE_TIMEOUT_MS, 'keepalive');
|
|
584
698
|
} catch (e) {
|
|
585
699
|
log.warn('keepalive_failed', { message: e.message });
|
|
586
700
|
scheduleRebuild(`keepalive:${e.message}`);
|
|
@@ -609,54 +723,26 @@ function createImPlugin() {
|
|
|
609
723
|
myUserId = credentials.userId;
|
|
610
724
|
credentialsRef = credentials;
|
|
611
725
|
|
|
612
|
-
// 先 delete window 让 SDK 走 node 路径
|
|
613
|
-
delete global.window;
|
|
614
726
|
TencentCloudChat = require('@tencentcloud/chat');
|
|
615
|
-
chat = TencentCloudChat.create({ SDKAppID: Number(credentials.sdkAppId) });
|
|
616
|
-
|
|
617
|
-
// 然后补上 SDK 运行所需的 window/document 等全局
|
|
618
|
-
setupSdkGlobals();
|
|
619
727
|
|
|
728
|
+
// 首次走 candidate 模式:严格 login + SDK_READY,失败直接 throw
|
|
620
729
|
try {
|
|
621
|
-
const
|
|
622
|
-
chat
|
|
730
|
+
const built = await buildChatCandidate();
|
|
731
|
+
chat = built.nextChat;
|
|
732
|
+
sdkReady = true;
|
|
733
|
+
log.info('login_ok', { userId: myUserId, gen: built.gen });
|
|
623
734
|
log.info('upload_plugin_registered');
|
|
624
|
-
} catch (e) {
|
|
625
|
-
log.warn('upload_plugin_unavailable', { message: e.message });
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
chat.setLogLevel(4);
|
|
629
|
-
|
|
630
|
-
// 所有事件监听通过 setupAllListeners 统一注册(便于 rebuild 时重注册)
|
|
631
|
-
setupAllListeners();
|
|
632
|
-
|
|
633
|
-
try {
|
|
634
|
-
await chat.login({ userID: myUserId, userSig: credentials.userSig });
|
|
635
|
-
log.info('login_ok', { userId: myUserId });
|
|
636
735
|
} catch (err) {
|
|
637
736
|
log.error('login_failed', err);
|
|
638
737
|
throw wrap(err, {
|
|
639
738
|
code: 'IM_LOGIN_FAILED',
|
|
640
|
-
hint: '检查 userSig 是否过期、sdkAppId
|
|
739
|
+
hint: '检查 userSig 是否过期、sdkAppId 是否正确、网络到 my-imcloud.com',
|
|
641
740
|
docs: 'docs/maintainer-guide.md#IM_LOGIN_FAILED',
|
|
642
741
|
});
|
|
643
742
|
}
|
|
644
743
|
|
|
645
|
-
// 等 SDK_READY(最多 10s)
|
|
646
|
-
await new Promise((resolve) => {
|
|
647
|
-
if (sdkReady) return resolve();
|
|
648
|
-
const timer = setTimeout(() => {
|
|
649
|
-
log.warn('sdk_ready_wait_timeout');
|
|
650
|
-
resolve();
|
|
651
|
-
}, 10000);
|
|
652
|
-
chat.on(TencentCloudChat.EVENT.SDK_READY, () => {
|
|
653
|
-
clearTimeout(timer);
|
|
654
|
-
resolve();
|
|
655
|
-
});
|
|
656
|
-
});
|
|
657
|
-
|
|
658
744
|
// 拉一次好友列表建立关系表 contacts.json
|
|
659
|
-
// FRIEND_LIST_UPDATED 监听已在
|
|
745
|
+
// FRIEND_LIST_UPDATED 监听已在 buildChatCandidate 里注册
|
|
660
746
|
seedDisplayNameFromContacts();
|
|
661
747
|
await refreshContactsFromFriendList();
|
|
662
748
|
|
|
@@ -730,17 +816,23 @@ function createImPlugin() {
|
|
|
730
816
|
clearInterval(keepaliveTimer);
|
|
731
817
|
keepaliveTimer = null;
|
|
732
818
|
}
|
|
819
|
+
if (rebuildRetryTimer) {
|
|
820
|
+
clearTimeout(rebuildRetryTimer);
|
|
821
|
+
rebuildRetryTimer = null;
|
|
822
|
+
}
|
|
733
823
|
if (processRejectionHandler) {
|
|
734
824
|
try { process.off('unhandledRejection', processRejectionHandler); } catch {}
|
|
735
825
|
processRejectionHandler = null;
|
|
736
826
|
}
|
|
737
827
|
if (chat) {
|
|
738
828
|
try {
|
|
739
|
-
await chat.logout();
|
|
829
|
+
await withTimeout(chat.logout().catch(() => {}), LOGOUT_TIMEOUT_MS, 'destroy_logout');
|
|
740
830
|
if (log) log.info('logged_out');
|
|
741
831
|
} catch (e) {
|
|
742
832
|
if (log) log.warn('logout_failed', { message: e.message });
|
|
743
833
|
}
|
|
834
|
+
try { chat.destroy && chat.destroy(); } catch {}
|
|
835
|
+
chat = null;
|
|
744
836
|
}
|
|
745
837
|
}
|
|
746
838
|
|