/** * TIM Client — SDK connection core * * Manages TIM SDK lifecycle: connect, disconnect, auto-reconnect. * Emits 'message' events for incoming group chat messages. * Emits 'reconnected' when auto-reconnect succeeds. * * Internal noise containment: all SDK calls wrapped in try/catch, * known TIM noise errors logged at debug level. */ import './sdk-logger-init.js'; // Must be before @tencentcloud/chat — redirects SDK logs to file // @ts-ignore — JS side-effect module, no types needed import '../sdk/node-env/index.js'; // Must be before @tencentcloud/chat — installs ws (no permessage-deflate), localStorage, rejection handler import TencentCloudChat from '../sdk/index.js'; // @ts-ignore — vendor JS module, typed as unknown import GroupModule from '../sdk/modules/group-module.js'; // @ts-ignore — vendor JS module, patched for Node.js import TIMUploadPlugin from '../sdk/modules/tim-upload-plugin.js'; import { EventEmitter } from 'node:events'; import { logger } from '../util/logger.js'; import type { TIMCredentials } from '../auth/verify.js'; // ── Types ── export interface RawPushMessage { id: string; channelId: string; seq: number; from: string; nick: string; text: string; time: number; mentionsMe: boolean; source: 'push'; } // ── TIMClient ── export class TIMClient extends EventEmitter { private chat: ReturnType | null = null; private _agentId: string = ''; private _credentials: TIMCredentials | null = null; private _reconnecting = false; private _ready = false; get agentId(): string { return this._agentId; } get isReady(): boolean { return this._ready; } get userSig(): string | null { return this._credentials?.userSig ?? null; } /** * Connect to TIM SDK using verified credentials. */ async connect(credentials: TIMCredentials): Promise { this._agentId = credentials.userId; this._credentials = credentials; logger.info(`[tim/client] Connecting agentId=${this._agentId}`); // Create SDK instance this.chat = TencentCloudChat.create({ SDKAppID: credentials.sdkAppId, modules: { 'group-module': GroupModule }, }); this.chat.setLogLevel(0); // ALL logs — WebSocket heartbeat, transport, message push (P0-A diagnosis) // Register tim-upload-plugin — enables createFileMessage/sendMessage for T4 file transfer // Must be after create(), before login(). SDK_READY triggers UploadModule._init() which looks up this plugin. // @see docs/audit/023-tim-upload-plugin-v1.md §4.7 this.chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin }); logger.info('[tim/client] tim-upload-plugin registered'); // Wait for SDK_READY with guaranteed timer cleanup let readyTimeout: ReturnType; const sdkReadyPromise = new Promise((resolve, reject) => { readyTimeout = setTimeout( () => reject(new Error('SDK_READY timeout (15s)')), 15_000, ); this.chat!.on(TencentCloudChat.EVENT.SDK_READY, () => { clearTimeout(readyTimeout); this._ready = true; resolve(); }); }); // Register event listeners this._registerListeners(); logger.info('[tim/client] Calling TIM SDK login...'); try { await this.chat.login({ userID: credentials.userId, userSig: credentials.userSig, }); await sdkReadyPromise; } catch (err) { clearTimeout(readyTimeout!); this._ready = false; throw err; } logger.info('[tim/client] SDK_READY — connected'); } /** * Gracefully disconnect from TIM SDK. */ async disconnect(): Promise { this._reconnecting = false; if (this.chat) { try { await this.chat.logout(); } catch { /* ignore */ } this._ready = false; this._removeListeners(); this.chat = null; } } // ── Event Listeners ── // eslint-disable-next-line @typescript-eslint/no-explicit-any private _messageHandler: ((...args: any[]) => void) | null = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any private _notReadyHandler: ((...args: any[]) => void) | null = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any private _kickedHandler: ((...args: any[]) => void) | null = null; private _registerListeners(): void { if (!this.chat) { logger.error('[tim/client] _registerListeners: chat instance is null, cannot register'); return; } logger.info('[tim/client] _registerListeners: installing SDK event handlers'); // Disconnect detection this._notReadyHandler = () => { logger.warn('[tim/client] SDK_NOT_READY — connection lost'); this._ready = false; void this._autoReconnect(); }; this.chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, this._notReadyHandler); this._kickedHandler = (event: unknown) => { const ev = event as { data?: { type?: string } } | undefined; const reason = ev?.data?.type || 'unknown'; logger.warn(`[tim/client] KICKED_OUT (reason=${reason})`); this._ready = false; switch (reason) { case 'multipleDevice': case 'multipleAccount': // Multi-device/page kick: do NOT reconnect to avoid kick loop logger.warn('[tim/client] Kicked by multi-device/account login, will not reconnect'); break; case 'userSigExpired': // UserSig expired: old credentials are useless, notify upper layer logger.warn('[tim/client] UserSig expired, emitting credential_expired event'); this.emit('credential_expired'); break; case 'REST_API_Kick': default: // REST API kick or unknown: reconnect with existing credentials void this._autoReconnect(); break; } }; this.chat.on(TencentCloudChat.EVENT.KICKED_OUT, this._kickedHandler); // Push message listener this._messageHandler = (event: unknown) => { const ev = event as { data: unknown[] }; const messages = ev.data as Array<{ conversationType: string; type?: string; from: string; to: string; sequence: number; nick?: string; time: number; payload?: { text?: string; data?: string; description?: string; extension?: string }; atUserList?: string[]; }>; logger.info(`[tim/client] MESSAGE_RECEIVED fired: ${messages?.length ?? 0} message(s)`); if (!messages || messages.length === 0) { logger.warn('[tim/client] MESSAGE_RECEIVED: empty data array, nothing to process'); return; } for (const msg of messages) { try { // ── C2C messages → emit for c2c.ts handler, skip group logic ── if (msg.conversationType === TencentCloudChat.TYPES.CONV_C2C) { logger.info(`[tim/client] C2C msg from=${msg.from}`); this.emit('c2c_message', { from: msg.from, payload: msg.payload, type: msg.type, time: msg.time, }); continue; } // Only group messages if (msg.conversationType !== TencentCloudChat.TYPES.CONV_GROUP) { logger.debug(`[tim/client] skip non-group msg type=${msg.conversationType} from=${msg.from}`); continue; } // Skip own messages if (msg.from === this._agentId) { logger.debug(`[tim/client] skip self msg from=${msg.from} to=${msg.to}`); continue; } const text = msg.payload?.text || ''; if (!text) { logger.debug(`[tim/client] skip empty text msg from=${msg.from} to=${msg.to}`); continue; } const channelId = msg.to; const seq = msg.sequence; const rawMessage: RawPushMessage = { id: `${channelId}:${seq}`, channelId, seq, from: msg.from, nick: msg.nick || msg.from, text, time: msg.time * 1000, mentionsMe: Array.isArray(msg.atUserList) && (msg.atUserList.includes(this._agentId) || msg.atUserList.includes(TencentCloudChat.TYPES.MSG_AT_ALL)), source: 'push', }; logger.debug(`[tim/client] PUSH msg id=${rawMessage.id} ch=${channelId} from=${rawMessage.nick}`); this.emit('message', rawMessage); } catch (err) { logger.debug(`[tim/client] Error processing push message: ${(err as Error).message}`); } } }; this.chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, this._messageHandler); } private _removeListeners(): void { if (!this.chat) return; if (this._notReadyHandler) { this.chat.off(TencentCloudChat.EVENT.SDK_NOT_READY, this._notReadyHandler); this._notReadyHandler = null; } if (this._kickedHandler) { this.chat.off(TencentCloudChat.EVENT.KICKED_OUT, this._kickedHandler); this._kickedHandler = null; } if (this._messageHandler) { this.chat.off(TencentCloudChat.EVENT.MESSAGE_RECEIVED, this._messageHandler); this._messageHandler = null; } } // ── Auto-reconnect ── private async _autoReconnect(maxRetries = 5): Promise { if (this._reconnecting) { logger.debug('[tim/client] Reconnect already in progress'); return; } if (!this._credentials) { logger.error('[tim/client] Cannot reconnect: missing credentials'); return; } this._reconnecting = true; logger.warn('[tim/client] Starting auto-reconnect...'); for (let attempt = 1; attempt <= maxRetries; attempt++) { if (!this._reconnecting) { logger.info('[tim/client] Reconnect cancelled'); return; } const delay = Math.min(5000 * attempt, 25_000); logger.info(`[tim/client] Reconnect attempt ${attempt}/${maxRetries} in ${delay / 1000}s...`); await new Promise(r => setTimeout(r, delay)); try { // Teardown old SDK instance if (this.chat) { this._removeListeners(); try { await this.chat.logout(); } catch { /* ignore */ } this.chat = null; } this._ready = false; await this.connect(this._credentials); if (this._ready) { logger.info(`[tim/client] Auto-reconnect successful on attempt ${attempt}`); this._reconnecting = false; this.emit('reconnected'); return; } } catch (err) { logger.error(`[tim/client] Reconnect attempt ${attempt}/${maxRetries} failed: ${(err as Error).message}`); } } this._reconnecting = false; logger.error(`[tim/client] All ${maxRetries} reconnect attempts failed`); } // ── Internal SDK access (for messages.ts and channels.ts) ── /** @internal */ get _chat(): ReturnType | null { return this.chat; } /** @internal */ get _types(): typeof TencentCloudChat.TYPES { return TencentCloudChat.TYPES; } }