/** * TIM Channels — Channel management operations via SDK * * All operations use the TIM SDK (not REST API). * listChannels uses the auth service endpoint. */ import { logger } from '../util/logger.js'; import { httpGet } from '../util/http.js'; import type { TIMClient } from './client.js'; const AUTH_BASE = 'https://auth.ai-talk.live'; // ── Types ── export interface ChannelInfo { id: string; name: string; desc: string; memberCount?: number; } export interface MemberInfo { id: string; nick: string; role: string; avatar?: string; } // ── Functions ── /** * List all available channels from the auth service. */ export async function listChannels(client: TIMClient): Promise { logger.info('[tim/channels] listChannels: fetching from auth service'); try { const headers: Record = {}; if (client.userSig) { headers['Authorization'] = `Bearer ${client.userSig}`; } const data = await httpGet< | ChannelInfo[] | { data?: ChannelInfo[]; channels?: ChannelInfo[] } >(`${AUTH_BASE}/api/channels`, { headers }); const rawList: unknown[] = Array.isArray(data) ? data : ( (data as { data?: unknown[] }).data || (data as { channels?: unknown[] }).channels || [] ); logger.info(`[tim/channels] listChannels: found ${rawList.length} channels`); return rawList.map((item) => { const ch = item as Record; return { id: (ch['channel_id'] || ch['id'] || ch['channelId'] || ch['GroupId'] || '') as string, name: (ch['name'] || ch['Name'] || '') as string, desc: (ch['description'] || ch['desc'] || ch['Introduction'] || '') as string, memberCount: (ch['member_count'] || ch['memberCount'] || 0) as number, }; }); } catch (err) { logger.error(`[tim/channels] listChannels error: ${(err as Error).message}`); throw err; } } /** * Search channels by keyword (client-side filter of listChannels). */ export async function searchChannels( client: TIMClient, keyword: string, ): Promise { logger.info(`[tim/channels] searchChannels: keyword="${keyword}"`); const all = await listChannels(client); const lower = keyword.toLowerCase(); return all.filter(ch => ch.name.toLowerCase().includes(lower) || ch.desc.toLowerCase().includes(lower) || ch.id.toLowerCase().includes(lower), ); } /** * TIM SDK error code for "already a group member". * @see https://cloud.tencent.com/document/product/269/75394 */ const ERR_ALREADY_GROUP_MEMBER = 10013; /** * Join a group channel. * * Per TIM Web SDK docs, joinGroup may: * - Resolve with status JOIN_STATUS_SUCCESS / ALREADY_IN_GROUP / WAIT_APPROVAL * - Reject with error code 10013 ("already group member") * * We handle both paths and always refresh the SDK conversation cache * afterwards to prevent sendMessage error 2801. * * @see https://cloud.tencent.com/document/product/269/75395 */ export async function joinChannel( client: TIMClient, channelId: string, ): Promise<{ success: boolean; message: string }> { const chat = client._chat; if (!chat || !client.isReady) throw new Error('Not connected'); logger.info(`[tim/channels] joinChannel: ${channelId}`); try { await chat.joinGroup({ groupID: channelId }); logger.info(`[tim/channels] joinChannel: joined ${channelId}`); } catch (err) { const code = (err as { code?: number })?.code; if (code === ERR_ALREADY_GROUP_MEMBER) { logger.info(`[tim/channels] joinChannel: already a member of ${channelId} (code=${code})`); } else { logger.error(`[tim/channels] joinChannel failed: code=${code} msg=${(err as Error).message}`); throw err; } } // Force SDK to cache group conversation profile. // Required because: (1) ALREADY_IN_GROUP does not refresh cache, // and (2) even new joins may not populate the conversation list. // Without this, sendMessage fails with error 2801 (group profile cache miss). try { await chat.getConversationProfile(`GROUP${channelId}`); logger.info(`[tim/channels] joinChannel: conversation profile cached for ${channelId}`); } catch (cacheErr) { const cacheCode = (cacheErr as { code?: number })?.code; logger.warn(`[tim/channels] joinChannel: failed to cache conversation profile for ${channelId} (code=${cacheCode})`); } return { success: true, message: `Joined ${channelId}` }; } /** * Leave a group channel. */ export async function leaveChannel( client: TIMClient, channelId: string, ): Promise<{ success: boolean; message: string }> { const chat = client._chat; if (!chat || !client.isReady) throw new Error('Not connected'); logger.info(`[tim/channels] leaveChannel: ${channelId}`); try { await chat.quitGroup(channelId); return { success: true, message: `Left ${channelId}` }; } catch (err) { const code = (err as { code?: number })?.code; logger.error(`[tim/channels] leaveChannel failed: code=${code} msg=${(err as Error).message} ch=${channelId}`); throw err; } } /** * Get members of a group channel. */ export async function getMembers( client: TIMClient, channelId: string, ): Promise { const chat = client._chat; if (!chat || !client.isReady) throw new Error('Not connected'); logger.info(`[tim/channels] getMembers: ${channelId}`); try { const res = await chat.getGroupMemberList({ groupID: channelId, count: 100, offset: 0, }) as { data?: { memberList?: Array<{ userID: string; nick?: string; role?: string; avatar?: string; }>; }; }; const members = (res.data?.memberList || []).map(m => ({ id: m.userID, nick: m.nick || m.userID, role: m.role || 'Member', avatar: m.avatar || '', })); logger.info(`[tim/channels] getMembers: ${channelId} found ${members.length} members`); return members; } catch (err) { const code = (err as { code?: number })?.code; logger.error(`[tim/channels] getMembers failed: code=${code} msg=${(err as Error).message} ch=${channelId}`); throw err; } } /** * Create a new Community group channel. * * Uses GRP_COMMUNITY (社群) — supports large-scale membership, topics, * and free join/leave. Requires Tencent IM flagship plan with Community * feature enabled in console. * * groupID is always auto-generated by the SDK with @TGS#_ prefix. * Community groups reject custom groupIDs that don't start with @TGS#_ * (SDK error ILLEGAL_GRP_ID / 2602). * * @see https://cloud.tencent.com/document/product/269/75394 * @see https://cloud.tencent.com/document/product/269/1502 (群组类型) */ export async function createChannel( client: TIMClient, name: string, description = '', skill = '', ): Promise<{ success: boolean; channelId: string }> { const chat = client._chat; if (!chat || !client.isReady) throw new Error('Not connected'); logger.info(`[tim/channels] createChannel: type=Community name="${name}"`); try { // Community groups require groupID to start with @TGS#_ or be empty. // SDK auto-generates @TGS#_ prefixed ID when groupID is omitted. const result = await chat.createGroup({ type: client._types.GRP_COMMUNITY, name, // groupID intentionally omitted — SDK auto-generates @TGS#_ prefixed ID introduction: description, notification: skill, isSupportTopic: false, }); const actualId = result?.data?.group?.groupID; if (!actualId) throw new Error('SDK returned no groupID'); logger.info(`[tim/channels] Channel created: ${actualId} (type=Community)`); return { success: true, channelId: actualId }; } catch (err) { const code = (err as { code?: number })?.code; const msg = (err as { message?: string })?.message || String(err); logger.error(`[tim/channels] createChannel failed: code=${code} msg=${msg}`); throw err; } } /** * Get channel skill (stored in group notification field). */ export async function getChannelSkill( client: TIMClient, channelId: string, ): Promise { const chat = client._chat; if (!chat || !client.isReady) return null; try { const res = await chat.getGroupProfile({ groupID: channelId, groupCustomFieldFilter: [], }) as { data?: { group?: { notification?: string } } }; return res.data?.group?.notification || null; } catch (err) { const code = (err as { code?: number })?.code; logger.warn(`[tim/channels] getChannelSkill failed: code=${code} msg=${(err as Error).message} ch=${channelId}`); return null; } } /** * Set channel skill (stored in group notification field). */ export async function setChannelSkill( client: TIMClient, channelId: string, skill: string, ): Promise<{ success: boolean }> { const chat = client._chat; if (!chat || !client.isReady) throw new Error('Not connected'); logger.info(`[tim/channels] setChannelSkill: ${channelId}`); try { await chat.updateGroupProfile({ groupID: channelId, notification: skill, }); return { success: true }; } catch (err) { const code = (err as { code?: number })?.code; logger.error(`[tim/channels] setChannelSkill failed: code=${code} msg=${(err as Error).message} ch=${channelId}`); throw err; } } /** * Get channel display name from group profile. */ export async function getGroupName( client: TIMClient, channelId: string, ): Promise { const chat = client._chat; if (!chat || !client.isReady) return null; try { const res = await chat.getGroupProfile({ groupID: channelId, groupCustomFieldFilter: [], }) as { data?: { group?: { name?: string } } }; return res.data?.group?.name || null; } catch (err) { const code = (err as { code?: number })?.code; logger.warn(`[tim/channels] getGroupName failed: code=${code} msg=${(err as Error).message} ch=${channelId}`); return null; } } /** * Read a single group-level custom field (AppDefinedData). * * Uses getGroupProfile with groupCustomFieldFilter to fetch only the * requested field. Returns null if field not set or fetch fails. * * @param fieldKey - The custom field key (e.g. 'gm_req_mention') */ export async function getGroupCustomField( client: TIMClient, channelId: string, fieldKey: string, ): Promise { const chat = client._chat; if (!chat || !client.isReady) return null; try { const res = await chat.getGroupProfile({ groupID: channelId, groupCustomFieldFilter: [fieldKey], }) as { data?: { group?: { groupCustomField?: Array<{ key: string; value: string }>; }; }; }; const fields = res.data?.group?.groupCustomField ?? []; const target = fields.find((f: { key: string; value: string }) => f.key === fieldKey); return target?.value ?? null; } catch (err) { const code = (err as { code?: number })?.code; logger.warn(`[tim/channels] getGroupCustomField(${fieldKey}) failed: code=${code} ch=${channelId}`); return null; } }