Commit current project

This commit is contained in:
2026-03-29 22:44:13 +02:00
parent b3bccb2ae3
commit 7f9469c07d
77 changed files with 20495 additions and 0 deletions

629
src/lib/twitch.ts Normal file
View File

@@ -0,0 +1,629 @@
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 [];
}
};