import { nodecg } from './util/nodecg.js'; import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js'; // ─── Constantes ──────────────────────────────────────────────────────────────── const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1'; const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize'; const CHALLONGE_OAUTH_TOKEN_ENDPOINT = 'https://api.challonge.com/oauth/token'; const CHALLONGE_OAUTH_SCOPES = [ 'me', 'tournaments:read', 'tournaments:write', 'matches:read', 'matches:write', 'participants:read', 'participants:write', ].join(' '); const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback'; const CHALLONGE_OAUTH_DEFAULT_PORT = 34921; const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; const RECENT_TOURNAMENTS_LIMIT = 20; // ─── Tipos ───────────────────────────────────────────────────────────────────── interface OAuthTokenResponse { access_token?: string; error?: string; error_description?: string; message?: string; } interface RecentTournament { id: string; name: string; slug: string; startAt: number | null; endAt: number | null; } interface ImportedPlayer { id: string; gamertag: string; name: string; team: string; country: string; twitter: string; } // ─── Config OAuth ────────────────────────────────────────────────────────────── const getOAuthConfig = (): OAuthConfig | null => { const bundleConfig = nodecg.bundleConfig as unknown as Record; const clientId = String(bundleConfig.challongeClientId ?? '').trim(); const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim(); const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT); const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT; if (!clientId || !clientSecret) return null; return { clientId, clientSecret, callbackPort }; }; // ─── Intercambio de token ────────────────────────────────────────────────────── 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, }); const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), }); const rawBody = await response.text(); let payload: OAuthTokenResponse; try { payload = JSON.parse(rawBody) as OAuthTokenResponse; } catch { payload = { message: rawBody }; } if (!response.ok) { throw new Error( payload.error_description ?? payload.error ?? payload.message ?? `OAuth token request failed (${response.status})`, ); } const token = String(payload.access_token ?? '').trim(); if (!token) { throw new Error( payload.error_description ?? payload.error ?? payload.message ?? 'OAuth token response did not include an access token', ); } return token; }; // ─── Servidor OAuth ──────────────────────────────────────────────────────────── const oauthServer = createOAuthServer({ provider: 'Challonge', callbackPath: CHALLONGE_OAUTH_CALLBACK_PATH, authorizeEndpoint: CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT, scope: CHALLONGE_OAUTH_SCOPES, sessionTtlMs: CHALLONGE_OAUTH_SESSION_TTL_MS, exchangeToken: exchangeOAuthCodeForToken, }); // ─── API de Challonge ────────────────────────────────────────────────────────── type ChallongeErrorPayload = { errors?: { detail?: string }; error?: string } | null; const parseJsonResponse = async (response: Response): Promise => { const rawBody = await response.text(); if (!rawBody) return null; try { return JSON.parse(rawBody) as unknown; } catch { return null; } }; /** * Realiza una petición autenticada a la API de Challonge. * * Intenta primero con OAuth v2 (Bearer token). * Si recibe 401, reintenta con autenticación v1 (API key personal pegada manualmente). * En cualquier otro error no-2xx, lanza inmediatamente. * * CORRECCIÓN: en la versión anterior, el bloque de error final era dead code * porque el body de v2 ya había sido consumido y la condición `!v2Response.ok` * nunca se alcanzaba tras el fallback v1. */ const requestChallonge = async (path: string, token: string): Promise => { const requestUrl = `${CHALLONGE_API_BASE}${path}`; // ── Intento v2 (OAuth Bearer) ───────────────────────────────────────────── const v2Response = await fetch(requestUrl, { headers: { Accept: 'application/json', 'Content-Type': 'application/vnd.api+json', 'Authorization-Type': 'v2', Authorization: `Bearer ${token}`, }, }); const v2Payload = await parseJsonResponse(v2Response); if (v2Response.ok) { return v2Payload; } // ── Fallback v1 (API key personal pegada manualmente) ───────────────────── if (v2Response.status === 401) { const v1Response = await fetch(requestUrl, { headers: { Accept: 'application/json', 'Content-Type': 'application/vnd.api+json', 'Authorization-Type': 'v1', Authorization: token, }, }); const v1Payload = await parseJsonResponse(v1Response); if (v1Response.ok) { return v1Payload; } const v1Error = v1Payload as ChallongeErrorPayload; throw new Error( v1Error?.errors?.detail ?? v1Error?.error ?? `Challonge responded with ${v1Response.status} ${v1Response.statusText}`.trim(), ); } // ── Otros errores v2 (4xx/5xx que no sean 401) ──────────────────────────── const v2Error = v2Payload as ChallongeErrorPayload; throw new Error( v2Error?.errors?.detail ?? v2Error?.error ?? `Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(), ); }; // ─── Parsers de respuesta ────────────────────────────────────────────────────── const normalizeTournamentSlug = (value: string): string => { const trimmed = value.trim(); if (!trimmed) return ''; return trimmed .replace(/^https?:\/\/[^/]+\//i, '') .replace(/^tournaments\//i, '') .replace(/^\/+/, ''); }; const getNumberProp = (payload: Record, keys: string[]): number | null => { for (const key of keys) { const raw = payload[key]; if (typeof raw === 'number' && Number.isFinite(raw)) return raw; if (typeof raw === 'string') { const parsed = Number(raw); if (Number.isFinite(parsed)) return parsed; } } return null; }; const parseRecentTournaments = (payload: unknown): RecentTournament[] => { const rows: RecentTournament[] = []; const push = (candidate: Record) => { const attributes = typeof candidate.attributes === 'object' && candidate.attributes !== null ? (candidate.attributes as Record) : candidate; const id = String(candidate.id ?? attributes.id ?? attributes.tournament_id ?? '').trim(); const name = String(attributes.name ?? attributes.full_name ?? '').trim(); const slug = normalizeTournamentSlug( String(attributes.url ?? attributes.slug ?? attributes.identifier ?? id), ); if (!id || !name || !slug) return; rows.push({ id, name, slug, startAt: getNumberProp(attributes, ['start_at', 'started_at', 'startAt']), endAt: getNumberProp(attributes, ['completed_at', 'end_at', 'ended_at', 'endAt']), }); }; if (Array.isArray(payload)) { for (const row of payload) { const wrapper = row as Record; const tournament = typeof wrapper.tournament === 'object' && wrapper.tournament !== null ? (wrapper.tournament as Record) : wrapper; push(tournament); } return rows; } if (typeof payload === 'object' && payload !== null) { const data = (payload as Record).data; if (Array.isArray(data)) { for (const row of data) { if (typeof row === 'object' && row !== null) { push(row as Record); } } } } return rows; }; const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => { const map = new Map(); const push = (candidate: Record) => { const attributes = typeof candidate.attributes === 'object' && candidate.attributes !== null ? (candidate.attributes as Record) : candidate; const id = String( candidate.id ?? attributes.id ?? attributes.participant_id ?? '', ).trim(); const rawDisplayName = String( attributes.display_name ?? attributes.name ?? attributes.username ?? attributes.gamer_tag ?? '', ).trim(); if (!id || !rawDisplayName) return; // Detectar patrón "TEAM | Gamertag" o "TEAM |Gamertag" (muy común en fighting games). // Si se detecta, extraer el equipo del propio nombre y limpiar el gamertag. const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/; const pipeMatch = PIPE_PATTERN.exec(rawDisplayName); const teamFromName = pipeMatch ? pipeMatch[1].trim() : ''; const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName; // team_name de la API tiene prioridad; si no existe, usar el extraído del nombre. const team = String(attributes.team_name ?? '').trim() || teamFromName; // Challonge no expone un campo de nombre real separado del username/display_name. // Se deja vacío para no duplicar el gamertag en el campo name. map.set(id, { id, gamertag, name: '', team, country: '', twitter: String(attributes.twitter_handle ?? attributes.twitter ?? '').trim(), }); }; if (Array.isArray(payload)) { for (const row of payload) { const wrapper = row as Record; const participant = typeof wrapper.participant === 'object' && wrapper.participant !== null ? (wrapper.participant as Record) : wrapper; push(participant); } return Array.from(map.values()); } if (typeof payload === 'object' && payload !== null) { const data = (payload as Record).data; if (Array.isArray(data)) { for (const row of data) { if (typeof row === 'object' && row !== null) { push(row as Record); } } } } return Array.from(map.values()); }; // ─── 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('challonge:createOAuthSession', async (_payload: unknown, ack) => { const config = getOAuthConfig(); if (!config) { sendAck( ack, 'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge 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('challonge: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('challonge:fetchRecentTournaments', async (payload: unknown, ack) => { const token = getStringProp(payload, 'token'); if (!token) { sendAck(ack, 'Missing Challonge API token'); return; } try { const raw = await requestChallonge('/tournaments.json', token); const tournaments = parseRecentTournaments(raw) .sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0)) .slice(0, RECENT_TOURNAMENTS_LIMIT); sendAck(ack, null, tournaments); } catch (error) { sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments'); } }); nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ack) => { const token = getStringProp(payload, 'token'); const slug = normalizeTournamentSlug(getStringProp(payload, 'slug')); if (!token) { sendAck(ack, 'Missing Challonge API token'); return; } if (!slug) { sendAck(ack, 'Missing tournament slug'); return; } try { const raw = await requestChallonge( `/tournaments/${encodeURIComponent(slug)}/participants.json`, token, ); sendAck(ack, null, parseImportedPlayers(raw)); } catch (error) { sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players'); } });