import type { PlatformSession, ChatMessage } from "./types.js"; import { getBadge, getBadgeDetail } from "./badges.js"; const TWITCH_API_BASE = "https://api.twitch.tv/helix"; const TWITCH_AUTH_BASE = "https://id.twitch.tv/oauth2"; const BATCH_SIZE = 100; export const getTwitchAccessToken = async ( code: string, clientId: string, clientSecret: string, redirectUri: string, ) => { try { const response = await fetch(`${TWITCH_AUTH_BASE}/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, grant_type: "authorization_code", redirect_uri: redirectUri, }), }); const data = await response.json(); return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresIn: data.expires_in, }; } catch (error) { console.error("Twitch token exchange failed:", error); throw error; } }; export const refreshTwitchAccessToken = async ( refreshToken: string, clientId: string, clientSecret: string, ) => { try { const response = await fetch(`${TWITCH_AUTH_BASE}/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: "refresh_token", }), }); if (!response.ok) { throw new Error(`Failed to refresh token: ${response.status}`); } const data = await response.json(); return { accessToken: data.access_token, refreshToken: data.refresh_token || refreshToken, expiresIn: data.expires_in, }; } catch (error) { console.error("Twitch token refresh failed:", error); throw error; } }; export const getTwitchUser = async (accessToken: string, clientId: string) => { try { const response = await fetch(`${TWITCH_API_BASE}/users`, { headers: { Authorization: `Bearer ${accessToken}`, "Client-ID": clientId, }, }); if (!response.ok) { throw new Error( `Twitch API error: ${response.status} ${response.statusText}`, ); } const data = await response.json(); if (!data.data || !data.data[0]) { throw new Error("No user data returned from Twitch API"); } const user = data.data[0]; return { userId: user.id, displayName: user.display_name, login: user.login, profileImageUrl: user.profile_image_url, }; } catch (error) { console.error("Failed to get Twitch user:", error); throw error; } }; export const getTwitchUserById = async ( accessToken: string, clientId: string, userId: string, ) => { try { const response = await fetch(`${TWITCH_API_BASE}/users?id=${userId}`, { headers: { Authorization: `Bearer ${accessToken}`, "Client-ID": clientId, }, }); if (!response.ok) { throw new Error( `Twitch API error: ${response.status} ${response.statusText}`, ); } const data = await response.json(); if (!data.data || !data.data[0]) { return null; } const user = data.data[0]; return { userId: user.id, displayName: user.display_name, login: user.login, profileImageUrl: user.profile_image_url, }; } catch (error) { console.error("Failed to get Twitch user by ID:", error); return null; } }; export const getFollowedChannels = async ( accessToken: string, clientId: string, userId: string, ) => { try { let followedChannels: any[] = []; let cursor: string | undefined = undefined; while (true) { const url = new URL(`${TWITCH_API_BASE}/channels/followed`); url.searchParams.set("user_id", userId); url.searchParams.set("first", "100"); if (cursor) { url.searchParams.set("after", cursor); } const response = await fetch(url.toString(), { headers: { Authorization: `Bearer ${accessToken}`, "Client-ID": clientId, }, }); if (!response.ok) { if (response.status === 401) { throw new Error( `Twitch API error: 401 - Unauthorized. Token may be expired or invalid.`, ); } throw new Error(`Twitch API error: ${response.status}`); } const data = await response.json(); const pageChannels = (data.data || []).map((channel: any) => ({ id: channel.broadcaster_id, name: channel.broadcaster_login, displayName: channel.broadcaster_name || channel.display_name || channel.broadcaster_login, title: channel.game_name || "", thumbnail: channel.thumbnail_url, })); followedChannels = followedChannels.concat(pageChannels); if (data.pagination?.cursor) { cursor = data.pagination.cursor; } else { break; } } const batchSize = BATCH_SIZE; for (let i = 0; i < followedChannels.length; i += batchSize) { const batch = followedChannels.slice(i, i + batchSize); const userIds = batch.map((ch: any) => `id=${ch.id}`).join("&"); const usersResponse = await fetch(`${TWITCH_API_BASE}/users?${userIds}`, { headers: { Authorization: `Bearer ${accessToken}`, "Client-ID": clientId, }, }); if (usersResponse.ok) { const usersData = await usersResponse.json(); const userProfiles = new Map( (usersData.data || []).map((user: any) => [ user.id, user.profile_image_url, ]), ); batch.forEach((ch: any) => { ch.profileImageUrl = userProfiles.get(ch.id); }); } } const streamDataMap = new Map(); for (let i = 0; i < followedChannels.length; i += batchSize) { const batch = followedChannels.slice(i, i + batchSize); const userIdParams = batch.map((ch: any) => `user_id=${ch.id}`).join("&"); const streamsResponse = await fetch( `${TWITCH_API_BASE}/streams?${userIdParams}&first=100`, { headers: { Authorization: `Bearer ${accessToken}`, "Client-ID": clientId, }, }, ); if (streamsResponse.ok) { const streamsData = await streamsResponse.json(); (streamsData.data || []).forEach((s: any) => { streamDataMap.set(s.user_id, s); }); } } followedChannels.forEach((ch: any) => { if (streamDataMap.has(ch.id)) { ch.viewerCount = streamDataMap.get(ch.id).viewer_count; } else { ch.viewerCount = 0; } }); // Only return online channels for the sidebar const onlineChannels = followedChannels.filter((ch: any) => streamDataMap.has(ch.id), ); const sorted = onlineChannels.sort((a, b) => { const nameA = (a?.displayName || a?.name || "").toLowerCase(); const nameB = (b?.displayName || b?.name || "").toLowerCase(); return nameA.localeCompare(nameB); }); return sorted; } catch (error) { console.error("Failed to get followed channels:", error); throw error; } }; export const getAllFollowedChannels = async ( accessToken: string, clientId: string, userId: string, ) => { try { let followedChannels: any[] = []; let cursor: string | undefined = undefined; while (true) { const url = new URL(`${TWITCH_API_BASE}/channels/followed`); url.searchParams.set("user_id", userId); url.searchParams.set("first", "100"); if (cursor) { url.searchParams.set("after", cursor); } const response = await fetch(url.toString(), { headers: { Authorization: `Bearer ${accessToken}`, "Client-ID": clientId, }, }); if (!response.ok) { if (response.status === 401) { throw new Error( `Twitch API error: 401 - Unauthorized. Token may be expired or invalid.`, ); } throw new Error(`Twitch API error: ${response.status}`); } const data = await response.json(); const pageChannels = (data.data || []).map((channel: any) => ({ id: channel.broadcaster_id, name: channel.broadcaster_login, displayName: channel.broadcaster_name || channel.display_name || channel.broadcaster_login, title: channel.game_name || "", thumbnail: channel.thumbnail_url, })); followedChannels = followedChannels.concat(pageChannels); if (data.pagination?.cursor) { cursor = data.pagination.cursor; } else { break; } } const batchSize = BATCH_SIZE; for (let i = 0; i < followedChannels.length; i += batchSize) { const batch = followedChannels.slice(i, i + batchSize); const userIds = batch.map((ch: any) => `id=${ch.id}`).join("&"); const usersResponse = await fetch(`${TWITCH_API_BASE}/users?${userIds}`, { headers: { Authorization: `Bearer ${accessToken}`, "Client-ID": clientId, }, }); if (usersResponse.ok) { const usersData = await usersResponse.json(); const userProfiles = new Map( (usersData.data || []).map((user: any) => [ user.id, user.profile_image_url, ]), ); batch.forEach((ch: any) => { ch.profileImageUrl = userProfiles.get(ch.id); }); } } // Fetch stream data to mark which channels are online const streamDataMap = new Map(); for (let i = 0; i < followedChannels.length; i += batchSize) { const batch = followedChannels.slice(i, i + batchSize); const userIdParams = batch.map((ch: any) => `user_id=${ch.id}`).join("&"); const streamsResponse = await fetch( `${TWITCH_API_BASE}/streams?${userIdParams}&first=100`, { headers: { Authorization: `Bearer ${accessToken}`, "Client-ID": clientId, }, }, ); if (streamsResponse.ok) { const streamsData = await streamsResponse.json(); (streamsData.data || []).forEach((s: any) => { streamDataMap.set(s.user_id, s); }); } } followedChannels.forEach((ch: any) => { if (streamDataMap.has(ch.id)) { ch.viewerCount = streamDataMap.get(ch.id).viewer_count; } else { ch.viewerCount = 0; } }); // Return all followed channels, sorted with online first const sorted = followedChannels.sort((a, b) => { const aIsOnline = streamDataMap.has(a.id) ? 1 : 0; const bIsOnline = streamDataMap.has(b.id) ? 1 : 0; if (aIsOnline !== bIsOnline) { return bIsOnline - aIsOnline; // Online channels first } const nameA = (a?.displayName || a?.name || "").toLowerCase(); const nameB = (b?.displayName || b?.name || "").toLowerCase(); return nameA.localeCompare(nameB); }); return sorted; } catch (error) { console.error("Failed to get all followed channels:", error); throw error; } }; export const getStreamStatus = async ( accessToken: string, clientId: string, userId: string, ): Promise<{ isLive: boolean; title?: string; viewerCount?: number; startedAt?: Date; } | null> => { try { const response = await fetch( `${TWITCH_API_BASE}/streams?user_id=${userId}`, { headers: { Authorization: `Bearer ${accessToken}`, "Client-ID": clientId, }, }, ); if (!response.ok) { throw new Error(`Twitch API error: ${response.status}`); } const data = await response.json(); const stream = data.data?.[0]; if (!stream) { return { isLive: false }; } return { isLive: true, title: stream.title, viewerCount: stream.viewer_count, startedAt: new Date(stream.started_at), }; } catch (error) { console.error("Failed to get stream status:", error); return null; } }; /** * Batch query stream status for multiple users (optimized for rate limiting) * Instead of making N individual requests, makes 1 request with multiple user_ids * Reduces API calls by ~90% when checking multiple streamers */ export const getStreamStatuses = async ( accessToken: string, clientId: string, userIds: string[], ): Promise< Record< string, { isLive: boolean; title?: string; viewerCount?: number; startedAt?: Date } > > => { try { if (!userIds.length) { return {}; } // Build query with all user IDs const params = userIds.map((id) => `user_id=${id}`).join("&"); const response = await fetch( `${TWITCH_API_BASE}/streams?${params}&first=100`, { headers: { Authorization: `Bearer ${accessToken}`, "Client-ID": clientId, }, }, ); if (!response.ok) { throw new Error(`Twitch API error: ${response.status}`); } const data = await response.json(); const result: Record< string, { isLive: boolean; title?: string; viewerCount?: number; startedAt?: Date; } > = {}; // Initialize all user IDs as offline userIds.forEach((id) => { result[id] = { isLive: false }; }); // Update with actual live streams data.data?.forEach((stream: any) => { result[stream.user_id] = { isLive: true, title: stream.title, viewerCount: stream.viewer_count, startedAt: new Date(stream.started_at), }; }); console.log( `Batch stream check: ${userIds.length} streamers checked in 1 request`, ); return result; } catch (error) { console.error("Failed to get batch stream status:", error); throw error; } }; export const getRecentMessages = async ( channelName: string, ): Promise => { try { const response = await fetch( `https://recent-messages.robotty.de/api/v2/recent-messages/${channelName}`, ); if (!response.ok) { console.warn( `Failed to fetch recent messages for ${channelName}:`, response.status, ); return []; } const data = await response.json(); const messages: ChatMessage[] = (data.messages || []) .map((rawMsg: string) => { try { const tagsMatch = rawMsg.match(/^@(.+?)\s+:/); const userMatch = rawMsg.match(/\s:([^!]+)!/); const contentMatch = rawMsg.match(/PRIVMSG #[^\s]+ :(.+)$/); if (!userMatch || !contentMatch) { console.warn("Failed to parse message:", rawMsg.substring(0, 100)); return null; } const author = userMatch[1]; const content = contentMatch[1]; // Parse tags let tags: Record = {}; if (tagsMatch && tagsMatch[1]) { const tagPairs = tagsMatch[1].split(";"); tags = Object.fromEntries( tagPairs.map((pair) => { const [key, value] = pair.split("="); return [key, value || ""]; }), ); } let badges: Record = {}; let badgeDetails: Record = {}; if (tags["badges"]) { tags["badges"].split(",").forEach((badge: string) => { const [name, version] = badge.split("/"); const detail = getBadgeDetail(name, version); if (detail) { badges[name] = detail.url; badgeDetails[name] = { url: detail.url, title: detail.title }; } }); } let emotes: Record = {}; if (tags["emotes"] && tags["emotes"] !== "") { emotes = Object.fromEntries( tags["emotes"].split("/").map((emote: string) => { const [id, positions] = emote.split(":"); return [id, positions.split(",")]; }), ); } // Use the actual Twitch message ID if available, otherwise fall back to a composite ID const messageId = tags["id"] || `${tags["user-id"] || "unknown"}_${tags["tmi-sent-ts"] || Date.now()}`; const message: ChatMessage = { id: messageId, platform: "twitch", author, content, timestamp: new Date(parseInt(tags["tmi-sent-ts"]) || Date.now()), userId: tags["user-id"] || "", authorColor: tags["color"] || "#FFFFFF", badges, badgeDetails, emotes, isBot: tags["bot"] === "1", mentionsUser: false, isPreloaded: true, // Mark as preloaded historical message }; return message; } catch (error) { console.error( "Error parsing message:", rawMsg.substring(0, 100), error, ); return null; } }) .filter((msg: ChatMessage | null): msg is ChatMessage => msg !== null); return messages; } catch (error) { console.error("Failed to fetch recent messages:", error); return []; } };