630 lines
17 KiB
TypeScript
630 lines
17 KiB
TypeScript
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<string, any>();
|
|
|
|
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<string, any>();
|
|
|
|
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<ChatMessage[]> => {
|
|
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<string, string> = {};
|
|
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<string, string> = {};
|
|
let badgeDetails: Record<string, { url: string; title: string }> = {};
|
|
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<string, string[]> = {};
|
|
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 [];
|
|
}
|
|
};
|