Files
scoreko-dev/src/extension/startgg.ts
T

359 lines
12 KiB
TypeScript

import { getData, type CountryRecord } from 'country-list';
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
import { nodecg } from './util/nodecg.js';
// ─── Constantes ────────────────────────────────────────────────────────────────
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize';
const STARTGG_OAUTH_TOKEN_ENDPOINTS = [
'https://www.start.gg/api/-/rest/oauth/access_token',
'https://api.start.gg/oauth/access_token',
];
const STARTGG_OAUTH_SCOPES = 'user.identity tournament.manager';
const STARTGG_OAUTH_CALLBACK_PATH = '/startgg/callback';
const STARTGG_OAUTH_DEFAULT_PORT = 34920;
const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 12;
const PARTICIPANTS_PAGE_SIZE = 120;
// ─── Tipos ─────────────────────────────────────────────────────────────────────
interface StartGGGraphQLResponse<T> {
data?: T;
errors?: Array<{ message?: string }>;
}
interface RecentTournament {
id: number;
name: string;
slug: string;
startAt: number | null;
endAt: number | null;
}
interface ImportedPlayer {
id: string;
gamertag: string;
name: string;
team: string;
country: string;
twitter: string;
}
interface OAuthTokenResponse {
access_token?: string;
error?: string;
error_description?: string;
message?: string;
}
// ─── Config OAuth ──────────────────────────────────────────────────────────────
const getOAuthConfig = (): OAuthConfig | null => {
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
const clientId = String(bundleConfig.startggClientId ?? '').trim();
const clientSecret = String(bundleConfig.startggClientSecret ?? '').trim();
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
const callbackPort =
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret, callbackPort };
};
// ─── Intercambio de token (multi-endpoint) ─────────────────────────────────────
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
const rawBody = await response.text();
try {
return JSON.parse(rawBody) as OAuthTokenResponse;
} catch {
return { message: rawBody };
}
};
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
config: OAuthConfig,
): Promise<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: redirectUri,
});
let lastError = 'Unknown OAuth token exchange error';
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
const payload = await parseOAuthTokenPayload(response);
if (response.ok) {
const token = String(payload.access_token ?? '').trim();
if (token) return token;
lastError =
payload.error_description ??
payload.error ??
payload.message ??
'OAuth token response did not include an access token';
continue;
}
lastError =
payload.error_description ??
payload.error ??
payload.message ??
`OAuth token request failed (${response.status})`;
// Solo 404 justifica probar el siguiente endpoint
if (response.status !== 404) break;
}
throw new Error(lastError);
};
// ─── Servidor OAuth ────────────────────────────────────────────────────────────
const oauthServer = createOAuthServer({
provider: 'start.gg',
callbackPath: STARTGG_OAUTH_CALLBACK_PATH,
authorizeEndpoint: STARTGG_OAUTH_AUTHORIZE_ENDPOINT,
scope: STARTGG_OAUTH_SCOPES,
sessionTtlMs: STARTGG_OAUTH_SESSION_TTL_MS,
exchangeToken: exchangeOAuthCodeForToken,
});
// ─── GraphQL ───────────────────────────────────────────────────────────────────
const requestStartGG = async <T>(
query: string,
variables: Record<string, unknown>,
token: string,
): Promise<T> => {
const response = await fetch(STARTGG_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`start.gg responded with ${response.status} ${response.statusText}`.trim());
}
let payload: StartGGGraphQLResponse<T>;
try {
payload = (await response.json()) as StartGGGraphQLResponse<T>;
} catch {
throw new Error('Invalid JSON response from start.gg');
}
if (payload.errors?.length) {
throw new Error(payload.errors[0]?.message ?? 'Unknown start.gg error');
}
if (!payload.data) {
throw new Error('No data returned by start.gg');
}
return payload.data;
};
// ─── Resolución de países ──────────────────────────────────────────────────────
const countries = getData();
const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase()));
const countryByName = new Map(
countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]),
);
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
const raw = (country ?? '').trim();
if (!raw) return '';
const upper = raw.toUpperCase();
if (countryByCode.has(upper)) return upper;
return countryByName.get(raw.toLowerCase()) ?? '';
};
// ─── Utilidades ────────────────────────────────────────────────────────────────
const getStringProp = (payload: unknown, key: string): string => {
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
const value = (payload as Record<string, unknown>)[key];
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
};
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
if (typeof ack === 'function') ack(error, response);
};
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
const config = getOAuthConfig();
if (!config) {
sendAck(
ack,
'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.',
);
return;
}
try {
await oauthServer.ensureServer(config);
} catch (err) {
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
return;
}
const session = oauthServer.createSession(config);
sendAck(ack, null, session);
});
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
const sessionId = getStringProp(payload, 'sessionId');
if (!sessionId) {
sendAck(ack, 'Missing OAuth session id');
return;
}
const status = oauthServer.getSessionStatus(sessionId);
if (!status) {
sendAck(ack, 'OAuth session not found');
return;
}
sendAck(ack, null, status);
});
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
const token = getStringProp(payload, 'token');
if (!token) {
sendAck(ack, 'Missing start.gg API token');
return;
}
const query = `
query RecentTournaments($perPage: Int!) {
currentUser {
tournaments(query: { perPage: $perPage, filter: { tournamentView: "admin" } }) {
nodes {
id
name
slug
startAt
endAt
}
}
}
}
`;
try {
const data = await requestStartGG<{
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
const tournaments =
data.currentUser?.tournaments.nodes
.filter((item) => item.slug)
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
.map(({ id, name, slug, startAt, endAt }) => ({ id, name, slug, startAt, endAt })) ?? [];
sendAck(ack, null, tournaments);
} catch (error) {
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
}
});
nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => {
const token = getStringProp(payload, 'token');
const slug = getStringProp(payload, 'slug');
if (!token) { sendAck(ack, 'Missing start.gg API token'); return; }
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
const query = `
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
tournament(slug: $slug) {
participants(query: { page: $page, perPage: $perPage }) {
pageInfo {
totalPages
}
nodes {
id
gamerTag
prefix
user {
location {
country
}
}
}
}
}
}
`;
try {
let currentPage = 1;
let totalPages = 1;
const playersMap = new Map<string, ImportedPlayer>();
while (currentPage <= totalPages) {
const data = await requestStartGG<{
tournament: {
participants: {
pageInfo: { totalPages: number };
nodes: Array<{
id: number;
gamerTag: string | null;
prefix: string | null;
user: { location: { country: string | null } | null } | null;
}>;
};
} | null;
}>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token);
if (!data.tournament) throw new Error('Tournament not found');
const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1;
for (const participant of data.tournament.participants.nodes) {
const playerId = String(participant.id);
const gamertag = (participant.gamerTag ?? '').trim();
if (!gamertag) continue;
playersMap.set(playerId, {
id: playerId,
gamertag,
name: gamertag,
team: (participant.prefix ?? '').trim(),
country: resolveCountryCodeFromStartGG(participant.user?.location?.country),
twitter: '',
});
}
currentPage += 1;
}
sendAck(ack, null, Array.from(playersMap.values()));
} catch (error) {
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
}
});