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

506 lines
17 KiB
TypeScript

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;
// ─── URL del proxy OAuth ───────────────────────────────────────────────────────
// Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy.
// Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev'
//
// También puedes sobreescribirla en cfg/scoreko.json con "oauthProxyUrl"
// (útil para apuntar a un entorno de staging sin recompilar).
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
// ─── 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;
}
// ─── Modo OAuth ────────────────────────────────────────────────────────────────
//
// DEV: cfg/scoreko.json tiene challongeClientId + challongeClientSecret.
// El exchange se hace directamente contra Challonge.
//
// PROXY: No hay credenciales en la config local.
// El clientId se obtiene del Worker (es público, no secreto).
// El exchange lo hace el Worker, que guarda el clientSecret en sus env vars.
type OAuthMode =
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
| { type: 'proxy'; proxyBaseUrl: string; callbackPort: number };
const getOAuthMode = (): OAuthMode => {
const bundleConfig = nodecg.bundleConfig as Record<string, unknown>;
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;
const proxyBaseUrl =
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
if (clientId && clientSecret) {
nodecg.log.info('[Challonge] OAuth: modo dev (credenciales locales)');
return { type: 'dev', clientId, clientSecret, callbackPort };
}
nodecg.log.info(`[Challonge] OAuth: modo proxy → ${proxyBaseUrl}`);
return { type: 'proxy', proxyBaseUrl, callbackPort };
};
// ─── Exchange de token ─────────────────────────────────────────────────────────
/** Modo dev: exchange directo con Challonge usando credenciales locales */
const exchangeCodeDirectly = async (
code: string,
redirectUri: string,
clientId: string,
clientSecret: string,
): Promise<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: clientId,
client_secret: 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;
};
/** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */
const exchangeCodeViaProxy = async (
code: string,
redirectUri: string,
proxyBaseUrl: string,
): Promise<string> => {
const response = await fetch(`${proxyBaseUrl}/oauth/challonge/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirectUri }),
});
const rawBody = await response.text();
let payload: { access_token?: string; error?: string };
try {
payload = JSON.parse(rawBody) as typeof payload;
} catch {
payload = { error: rawBody };
}
if (!response.ok) {
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
}
const token = String(payload.access_token ?? '').trim();
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
return token;
};
/**
* Callback que recibe oauth-server.ts cuando llega el código de autorización.
* Delega al modo correcto; _config no se usa porque el modo ya está determinado.
*/
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
_config: OAuthConfig,
): Promise<string> => {
const mode = getOAuthMode();
if (mode.type === 'dev') {
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
}
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
};
// ─── 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<unknown> => {
const rawBody = await response.text();
if (!rawBody) return null;
try {
return JSON.parse(rawBody) as unknown;
} catch {
return null;
}
};
const requestChallonge = async (path: string, token: string): Promise<unknown> => {
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<string, unknown>, 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<string, unknown>) => {
const attributes =
typeof candidate.attributes === 'object' && candidate.attributes !== null
? (candidate.attributes as Record<string, unknown>)
: 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<string, unknown>;
const tournament =
typeof wrapper.tournament === 'object' && wrapper.tournament !== null
? (wrapper.tournament as Record<string, unknown>)
: wrapper;
push(tournament);
}
return rows;
}
if (typeof payload === 'object' && payload !== null) {
const data = (payload as Record<string, unknown>).data;
if (Array.isArray(data)) {
for (const row of data) {
if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>);
}
}
}
}
return rows;
};
const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
const map = new Map<string, ImportedPlayer>();
const push = (candidate: Record<string, unknown>) => {
const attributes =
typeof candidate.attributes === 'object' && candidate.attributes !== null
? (candidate.attributes as Record<string, unknown>)
: 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;
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
const team = String(attributes.team_name ?? '').trim() || teamFromName;
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<string, unknown>;
const participant =
typeof wrapper.participant === 'object' && wrapper.participant !== null
? (wrapper.participant as Record<string, unknown>)
: wrapper;
push(participant);
}
return Array.from(map.values());
}
if (typeof payload === 'object' && payload !== null) {
const data = (payload as Record<string, unknown>).data;
if (Array.isArray(data)) {
for (const row of data) {
if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>);
}
}
}
}
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<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('challonge:createOAuthSession', async (_payload: unknown, ack) => {
const mode = getOAuthMode();
let serverConfig: OAuthConfig;
if (mode.type === 'dev') {
serverConfig = {
clientId: mode.clientId,
callbackPort: mode.callbackPort,
};
} else {
// Modo proxy: el clientId viene del Worker (es público, no secreto)
try {
const res = await fetch(`${mode.proxyBaseUrl}/oauth/challonge/client-id`);
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
const data = await res.json() as { clientId?: string };
const clientId = String(data.clientId ?? '').trim();
if (!clientId) throw new Error('Proxy did not return a clientId');
serverConfig = { clientId, callbackPort: mode.callbackPort };
} catch (err) {
sendAck(
ack,
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
);
return;
}
}
try {
await oauthServer.ensureServer(serverConfig);
} catch (err) {
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
return;
}
sendAck(ack, null, oauthServer.createSession(serverConfig));
});
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');
}
});