feat(players): add Challonge v2.1 OAuth and import integration

This commit is contained in:
Pandipipas
2026-02-17 18:33:44 +01:00
parent 58c0c01e46
commit 9a5b64b2c0
5 changed files with 907 additions and 0 deletions
+525
View File
@@ -0,0 +1,525 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { nodecg } from './util/nodecg.js';
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_CALLBACK_PATH = '/challonge/callback';
const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
interface OAuthConfig {
clientId: string;
clientSecret: string;
callbackPort: number;
}
interface OAuthSession {
sessionId: string;
state: string;
expiresAt: number;
status: 'pending' | 'completed' | 'error' | 'expired';
token?: string;
error?: string;
}
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;
}
const oauthSessions = new Map<string, OAuthSession>();
let oauthCallbackServer: Server | null = null;
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 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 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<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,
};
};
const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${CHALLONGE_OAUTH_CALLBACK_PATH}`;
const updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => {
const session = oauthSessions.get(sessionId);
if (!session) {
return;
}
oauthSessions.set(sessionId, {
...session,
...update,
});
};
const cleanupExpiredOAuthSessions = () => {
const now = Date.now();
oauthSessions.forEach((session, sessionId) => {
if (session.expiresAt <= now && session.status === 'pending') {
updateOAuthSession(sessionId, { status: 'expired' });
}
});
};
const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>${title}</title>
<style>
body { font-family: Arial, sans-serif; margin: 2rem; background: #121212; color: #fff; }
.box { max-width: 680px; padding: 1rem 1.2rem; border: 1px solid #444; border-radius: 8px; }
</style>
</head>
<body>
<div class="box">
<h2>${title}</h2>
<p>${message}</p>
<p>You can close this tab and return to Scoreko.</p>
</div>
</body>
</html>`;
const respondWithCallbackHtml = (res: ServerResponse, statusCode: number, title: string, message: string) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCallbackHtml(title, message));
};
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
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<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: oauthConfig.clientId,
client_secret: oauthConfig.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 payload = await parseOAuthTokenPayload(response);
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;
};
const requestChallonge = async (path: string, token: string): Promise<unknown> => {
const response = await fetch(`${CHALLONGE_API_BASE}${path}`, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/vnd.api+json',
Authorization: `Bearer ${token}`,
},
});
const rawBody = await response.text();
let payload: unknown = null;
if (rawBody) {
try {
payload = JSON.parse(rawBody) as unknown;
} catch {
if (!response.ok) {
throw new Error(`Challonge responded with ${response.status} ${response.statusText}`.trim());
}
}
}
if (!response.ok) {
const maybeError = payload as { errors?: { detail?: string }; error?: string } | null;
throw new Error(
maybeError?.errors?.detail || maybeError?.error || `Challonge responded with ${response.status} ${response.statusText}`.trim(),
);
}
return payload;
};
const normalizeTournamentSlug = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
return trimmed.replace(/^https?:\/\/[^/]+\//i, '').replace(/^tournaments\//i, '').replace(/^\/+/, '');
};
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)) {
payload.forEach((row) => {
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 root = payload as Record<string, unknown>;
const data = root.data;
if (Array.isArray(data)) {
data.forEach((row) => {
if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>);
}
});
return rows;
}
}
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 gamertag = String(
attributes.display_name
|| attributes.name
|| attributes.username
|| attributes.gamer_tag
|| '',
).trim();
if (!id || !gamertag) {
return;
}
map.set(id, {
id,
gamertag,
name: gamertag,
team: String(attributes.group_player_ids || attributes.team_name || '').trim(),
country: '',
twitter: String(attributes.twitter_handle || attributes.twitter || '').trim(),
});
};
if (Array.isArray(payload)) {
payload.forEach((row) => {
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 root = payload as Record<string, unknown>;
const data = root.data;
if (Array.isArray(data)) {
data.forEach((row) => {
if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>);
}
});
}
}
return Array.from(map.values());
};
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 !== CHALLONGE_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) {
respondWithCallbackHtml(res, 400, 'Invalid OAuth', 'No active session was found for this authorization.');
return;
}
if (session.expiresAt <= Date.now()) {
updateOAuthSession(session.sessionId, { status: 'expired' });
respondWithCallbackHtml(res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.');
return;
}
if (error) {
updateOAuthSession(session.sessionId, { status: 'error', error });
respondWithCallbackHtml(res, 400, 'OAuth canceled', `Challonge returned this error: ${error}`);
return;
}
if (!code) {
updateOAuthSession(session.sessionId, {
status: 'error',
error: 'Missing authorization code',
});
respondWithCallbackHtml(res, 400, 'Incomplete OAuth', 'No authorization code was received.');
return;
}
void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig)
.then((token) => {
updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined });
})
.catch((exchangeError) => {
const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code';
updateOAuthSession(session.sessionId, { status: 'error', error: message });
});
respondWithCallbackHtml(res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...');
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(oauthConfig.callbackPort, '127.0.0.1', () => {
server.off('error', reject);
resolve();
});
});
oauthCallbackServer = server;
};
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
const oauthConfig = getOAuthConfig();
if (!oauthConfig) {
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 ensureOAuthCallbackServer(oauthConfig);
} catch (serverError) {
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback';
sendAck(ack, message);
return;
}
cleanupExpiredOAuthSessions();
const sessionId = randomUUID();
const state = randomUUID();
oauthSessions.set(sessionId, {
sessionId,
state,
expiresAt: Date.now() + CHALLONGE_OAUTH_SESSION_TTL_MS,
status: 'pending',
});
const params = new URLSearchParams({
response_type: 'code',
client_id: oauthConfig.clientId,
redirect_uri: getCallbackUrl(oauthConfig.callbackPort),
state,
});
sendAck(ack, null, {
sessionId,
authUrl: `${CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`,
});
});
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
cleanupExpiredOAuthSessions();
const sessionId = getStringProp(payload, 'sessionId');
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('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, 20);
sendAck(ack, null, tournaments);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments';
sendAck(ack, message);
}
});
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);
const players = parseImportedPlayers(raw);
sendAck(ack, null, players);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while importing players';
sendAck(ack, message);
}
});
+1
View File
@@ -10,4 +10,5 @@ export default async (nodecg: NodeCGServerAPI) => {
await import('./util/replicants.js'); // make sure replicants are set up
await import('./example.js');
await import('./startgg.js');
await import('./challonge.js');
};