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 { 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; 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 => { 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 => { 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 ( query: string, variables: Record, token: string, ): Promise => { 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; try { payload = (await response.json()) as StartGGGraphQLResponse; } 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)[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(); 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'); } });