mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
435 lines
15 KiB
TypeScript
435 lines
15 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;
|
|
|
|
// ─── 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<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;
|
|
|
|
if (!clientId || !clientSecret) return null;
|
|
|
|
return { clientId, clientSecret, callbackPort };
|
|
};
|
|
|
|
// ─── Intercambio de token ──────────────────────────────────────────────────────
|
|
|
|
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,
|
|
});
|
|
|
|
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<unknown> => {
|
|
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<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;
|
|
|
|
// 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<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 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');
|
|
}
|
|
});
|