/** * TIM Messages — Send and fetch messages via SDK * * v3: fetchMessages uses SDK getMessageList() instead of REST API. * This fixes the v2 bug where non-admin accounts got empty results. */ import { logger } from '../util/logger.js'; import type { TIMClient } from './client.js'; // ── Types ── export interface HistoryMessage { id: string; from: string; text: string; time: number; seq?: number; } export interface SendResult { ok: boolean; messageId: string; } // ── Functions ── /** * Send a text message to a group channel. * Includes a 10s local timeout to catch dead SDK connections. * * When atUserList is provided, uses TIM createTextAtMessage to set * protocol-level @mentions (visible to receivers' atUserList detection). * See: docs/audit/012-mention-mode-bot-to-bot-v3.4.4.md */ export async function sendMessage( client: TIMClient, channelId: string, text: string, atUserList?: string[], ): Promise { const chat = client._chat; if (!chat || !client.isReady) { throw new Error('Not connected'); } logger.info(`[tim/messages] sendMessage: ch=${channelId} len=${text.length} at=${atUserList?.join(',') ?? 'none'}`); const message = (atUserList && atUserList.length > 0) ? chat.createTextAtMessage({ to: channelId, conversationType: client._types.CONV_GROUP, payload: { text, atUserList }, }) : chat.createTextMessage({ to: channelId, conversationType: client._types.CONV_GROUP, payload: { text }, }); // CRITICAL: Timer MUST be cleared after race settles. // Without clearTimeout, a successful send leaves an orphan reject() // firing 10s later as an unhandled rejection → gateway crash. let timeoutHandle: ReturnType; const timeoutPromise = new Promise((_, reject) => { timeoutHandle = setTimeout( () => reject(new Error(`sendMessage timeout (10s) for channel ${channelId}`)), 10_000, ); }); try { const res = await Promise.race([chat.sendMessage(message), timeoutPromise]) as { data?: { message?: { ID?: string } }; }; return { ok: true, messageId: res.data?.message?.ID || '', }; } catch (err) { const code = (err as { code?: number })?.code; logger.error(`[tim/messages] sendMessage failed: code=${code} msg=${(err as Error).message} ch=${channelId}`); throw err; } finally { clearTimeout(timeoutHandle!); } } /** * Fetch message history for a group channel using SDK getMessageList(). * * v3 change: REST API → SDK getMessageList() * - Filters non-text messages (system msgs, join/leave notifications) * - Maps to HistoryMessage interface * - seq from message.sequence (optional) * - Works with normal user credentials (fixes v2 empty array bug) */ export async function fetchMessages( client: TIMClient, channelId: string, count = 20, ): Promise { const chat = client._chat; if (!chat || !client.isReady) { throw new Error('Not connected'); } try { logger.info(`[tim/messages] fetchMessages: ch=${channelId} count=${count}`); const conversationID = `GROUP${channelId}`; const res = await chat.getMessageList({ conversationID, count, }) as { data?: { messageList?: Array<{ ID?: string; from?: string; type?: string; payload?: { text?: string }; time?: number; sequence?: number; }>; }; }; const messageList = res.data?.messageList || []; // Filter to text messages only (exclude system messages, join/leave, etc.) return messageList .filter(msg => msg.type === client._types.MSG_TEXT) .map(msg => mapToHistoryMessage(msg, channelId)); } catch (err) { logger.error(`[tim/messages] fetchMessages error for ${channelId}: ${(err as Error).message}`); throw err; } } /** * Map a single SDK message to HistoryMessage interface. * Exported for contract testing. */ export function mapToHistoryMessage( msg: { ID?: string; from?: string; payload?: { text?: string }; time?: number; sequence?: number; }, channelId: string, ): HistoryMessage { const result: HistoryMessage = { id: msg.ID || `${channelId}:${msg.sequence || Date.now()}`, from: msg.from || 'unknown', text: msg.payload?.text || '', time: (msg.time || 0) * 1000, }; if (msg.sequence != null) { result.seq = msg.sequence; } return result; }