import { createServer, type Server } from 'node:http'; import { randomUUID } from 'node:crypto'; import { getData, type CountryRecord } from 'country-list'; import { nodecg } from './util/nodecg.js'; 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; 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 OAuthConfig { clientId: string; clientSecret: string; callbackPort: number; } interface OAuthSession { sessionId: string; state: string; createdAt: number; expiresAt: number; status: 'pending' | 'completed' | 'error' | 'expired'; token?: string; error?: string; } interface OAuthTokenResponse { access_token?: string; error?: string; error_description?: string; message?: string; } const oauthSessions = new Map(); let oauthCallbackServer: Server | null = null; 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}`); } const payload = (await response.json()) as StartGGGraphQLResponse; 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; }; const countryByCode = new Set(getData().map((country: CountryRecord) => country.code.toUpperCase())); const countryByName = new Map(getData().map((country: CountryRecord) => [country.name.toLowerCase(), country.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()) ?? ''; }; const sendAck = (ack: unknown, error: string | null, response?: unknown) => { if (typeof ack !== 'function') { return; } ack(error, response); }; 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, }; }; const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${STARTGG_OAUTH_CALLBACK_PATH}`; const cleanupExpiredOAuthSessions = () => { const now = Date.now(); oauthSessions.forEach((session) => { if (session.expiresAt <= now && session.status === 'pending') { oauthSessions.set(session.sessionId, { ...session, status: 'expired', }); } }); }; const renderCallbackHtml = (title: string, message: string) => ` ${title}

${title}

${message}

Puedes cerrar esta pestaña y volver a Scoreko.

`; 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, oauthConfig: OAuthConfig, ): Promise => { const params = new URLSearchParams({ grant_type: 'authorization_code', code, client_id: oauthConfig.clientId, client_secret: oauthConfig.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})`; if (response.status !== 404) { break; } } throw new Error(lastError); }; const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => { if (oauthCallbackServer) { return; } const callbackUrl = getCallbackUrl(oauthConfig.callbackPort); const server = createServer((req, res) => { if (!req.url) { res.statusCode = 400; res.end('Bad request'); return; } const requestUrl = new URL(req.url, callbackUrl); if (requestUrl.pathname !== STARTGG_OAUTH_CALLBACK_PATH) { res.statusCode = 404; res.end('Not found'); return; } cleanupExpiredOAuthSessions(); const state = requestUrl.searchParams.get('state') || ''; const code = requestUrl.searchParams.get('code') || ''; const error = requestUrl.searchParams.get('error') || ''; const session = Array.from(oauthSessions.values()).find((candidate) => candidate.state === state); if (!session) { res.statusCode = 400; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(renderCallbackHtml('OAuth inválido', 'No se encontró una sesión activa para esta autorización.')); return; } if (session.expiresAt <= Date.now()) { oauthSessions.set(session.sessionId, { ...session, status: 'expired', }); res.statusCode = 400; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(renderCallbackHtml('Sesión expirada', 'La sesión OAuth expiró. Vuelve a iniciar el proceso desde Scoreko.')); return; } if (error) { oauthSessions.set(session.sessionId, { ...session, status: 'error', error, }); res.statusCode = 400; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(renderCallbackHtml('OAuth cancelado', `start.gg devolvió el error: ${error}`)); return; } if (!code) { oauthSessions.set(session.sessionId, { ...session, status: 'error', error: 'Missing authorization code', }); res.statusCode = 400; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(renderCallbackHtml('OAuth incompleto', 'No se recibió un código de autorización.')); return; } void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig) .then((token) => { oauthSessions.set(session.sessionId, { ...session, status: 'completed', token, error: undefined, }); }) .catch((exchangeError) => { const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code'; oauthSessions.set(session.sessionId, { ...session, status: 'error', error: message, }); }); res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(renderCallbackHtml('Autorización recibida', 'Se recibió tu autorización. Finalizando el login en segundo plano...')); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(oauthConfig.callbackPort, '127.0.0.1', () => { server.off('error', reject); resolve(); }); }); oauthCallbackServer = server; }; nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => { const oauthConfig = getOAuthConfig(); if (!oauthConfig) { sendAck(ack, 'OAuth no está configurado en esta instalación (faltan startggClientId/startggClientSecret). Usa el Client ID y Client Secret del OAuth app de start.gg.'); return; } try { await ensureOAuthCallbackServer(oauthConfig); } catch (serverError) { const message = serverError instanceof Error ? serverError.message : 'No se pudo iniciar el callback OAuth local'; sendAck(ack, message); return; } cleanupExpiredOAuthSessions(); const sessionId = randomUUID(); const state = randomUUID(); const now = Date.now(); const session: OAuthSession = { sessionId, state, createdAt: now, expiresAt: now + STARTGG_OAUTH_SESSION_TTL_MS, status: 'pending', }; oauthSessions.set(sessionId, session); const params = new URLSearchParams({ response_type: 'code', client_id: oauthConfig.clientId, redirect_uri: getCallbackUrl(oauthConfig.callbackPort), scope: STARTGG_OAUTH_SCOPES, state, }); sendAck(ack, null, { sessionId, authUrl: `${STARTGG_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`, }); }); nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => { cleanupExpiredOAuthSessions(); const sessionId = typeof payload === 'object' && payload !== null && 'sessionId' in payload ? String((payload as { sessionId?: string }).sessionId || '').trim() : ''; if (!sessionId) { sendAck(ack, 'Missing OAuth session id'); return; } const session = oauthSessions.get(sessionId); if (!session) { sendAck(ack, 'OAuth session not found'); return; } sendAck(ack, null, { status: session.status, token: session.status === 'completed' ? session.token : undefined, error: session.status === 'error' ? session.error : undefined, }); }); nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => { const token = typeof payload === 'object' && payload !== null && 'token' in payload ? String((payload as { token?: string }).token || '').trim() : ''; 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((item) => ({ id: item.id, name: item.name, slug: item.slug, startAt: item.startAt, endAt: item.endAt, })) ?? []; sendAck(ack, null, tournaments); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments'; sendAck(ack, message); } }); nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => { const candidate = typeof payload === 'object' && payload !== null ? payload as { token?: string; slug?: string; } : {}; const token = String(candidate.token || '').trim(); const slug = String(candidate.slug || '').trim(); 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: Record = {}; 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'); } totalPages = Math.max(data.tournament.participants.pageInfo.totalPages || 1, 1); data.tournament.participants.nodes.forEach((participant) => { const playerId = String(participant.id); const gamertag = (participant.gamerTag || '').trim(); if (!gamertag) { return; } const country = resolveCountryCodeFromStartGG(participant.user?.location?.country); playersMap[playerId] = { id: playerId, gamertag, name: gamertag, team: (participant.prefix || '').trim(), country, twitter: '', }; }); currentPage += 1; } sendAck(ack, null, Object.values(playersMap)); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error while importing players'; sendAck(ack, message); } });