From 9a5b64b2c0b93ffccf1a06c46b3ca0294cd58587 Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:33:44 +0100 Subject: [PATCH] feat(players): add Challonge v2.1 OAuth and import integration --- configschema.json | 17 + src/dashboard/scoreko-dev/views/Players.vue | 352 +++++++++++++ src/extension/challonge.ts | 525 ++++++++++++++++++++ src/extension/index.ts | 1 + src/types/schemas/configschema.d.ts | 12 + 5 files changed, 907 insertions(+) create mode 100644 src/extension/challonge.ts diff --git a/configschema.json b/configschema.json index 9488047..d0a093b 100644 --- a/configschema.json +++ b/configschema.json @@ -22,6 +22,23 @@ "minimum": 1, "maximum": 65535, "description": "Puerto local para callback OAuth" + }, + "challongeClientId": { + "type": "string", + "default": "", + "description": "Client ID de tu OAuth app de Challonge" + }, + "challongeClientSecret": { + "type": "string", + "default": "", + "description": "Client Secret de tu OAuth app de Challonge" + }, + "challongeOAuthPort": { + "type": "integer", + "default": 34921, + "minimum": 1, + "maximum": 65535, + "description": "Puerto local para callback OAuth de Challonge" } }, "required": [ diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index deec974..2839fda 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -30,7 +30,20 @@ interface StartGGImportedPlayer extends Player { id: string; } +interface ChallongeTournament { + id: string; + name: string; + slug: string; + startAt: number | null; + endAt: number | null; +} + +interface ChallongeImportedPlayer extends Player { + id: string; +} + const STARTGG_TOKEN_STORAGE_KEY = 'scoreko-dev.startgg-token'; +const CHALLONGE_TOKEN_STORAGE_KEY = 'scoreko-dev.challonge-token'; const STARTGG_TEMP_PLAYERS_STORAGE_KEY = 'scoreko-dev.startgg-temp-players'; const STARTGG_TEMP_FALLBACK_DURATION_SECONDS = 12 * 60 * 60; @@ -97,6 +110,7 @@ watch( ); const startGGToken = ref(localStorage.getItem(STARTGG_TOKEN_STORAGE_KEY) ?? ''); +const challongeToken = ref(localStorage.getItem(CHALLONGE_TOKEN_STORAGE_KEY) ?? ''); const recentTournaments = ref([]); const loadingTournaments = ref(false); const tournamentsError = ref(''); @@ -111,10 +125,24 @@ const temporaryStartGGPlayers = ref({}); let temporaryCleanupTimer: ReturnType | null = null; const oauthLoading = ref(false); +const challongeOauthLoading = ref(false); const isManualTokenDialogOpen = ref(false); const manualTokenDraft = ref(''); const oauthSessionId = ref(''); +const challongeOauthSessionId = ref(''); let oauthPollingTimer: ReturnType | null = null; +let challongeOauthPollingTimer: ReturnType | null = null; + +const challongeRecentTournaments = ref([]); +const challongeLoadingTournaments = ref(false); +const challongeTournamentsError = ref(''); +const selectedChallongeTournamentSlug = ref(''); +const challongeTournamentInput = ref(''); +const challongePlayers = ref([]); +const selectedChallongePlayerIds = ref([]); +const challongeImportDialogOpen = ref(false); +const loadingChallongeTournamentPlayers = ref(false); +const importingChallongeTournament = ref(null); interface OAuthSessionResponse { sessionId: string; @@ -131,6 +159,10 @@ watch(startGGToken, (value) => { localStorage.setItem(STARTGG_TOKEN_STORAGE_KEY, value); }); +watch(challongeToken, (value) => { + localStorage.setItem(CHALLONGE_TOKEN_STORAGE_KEY, value); +}); + const persistTemporaryStartGGPlayers = () => { localStorage.setItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY, JSON.stringify(temporaryStartGGPlayers.value)); }; @@ -224,6 +256,13 @@ const clearOAuthPolling = () => { } }; +const clearChallongeOAuthPolling = () => { + if (challongeOauthPollingTimer) { + clearInterval(challongeOauthPollingTimer); + challongeOauthPollingTimer = null; + } +}; + const clearTemporaryCleanupTimer = () => { if (temporaryCleanupTimer) { clearInterval(temporaryCleanupTimer); @@ -285,6 +324,40 @@ const checkOAuthStatus = async () => { } }; +const checkChallongeOAuthStatus = async () => { + if (!challongeOauthSessionId.value) { + return; + } + + try { + const status = await sendNodeCGMessage('challonge:getOAuthSessionStatus', { + sessionId: challongeOauthSessionId.value, + }); + + if (status.status === 'completed' && status.token) { + challongeToken.value = status.token; + challongeOauthLoading.value = false; + clearChallongeOAuthPolling(); + challongeOauthSessionId.value = ''; + challongeTournamentsError.value = ''; + await loadChallongeRecentTournaments(); + return; + } + + if (status.status === 'error' || status.status === 'expired') { + challongeOauthLoading.value = false; + clearChallongeOAuthPolling(); + challongeOauthSessionId.value = ''; + challongeTournamentsError.value = status.error || 'Could not complete OAuth login with Challonge.'; + } + } catch (error) { + challongeOauthLoading.value = false; + clearChallongeOAuthPolling(); + challongeOauthSessionId.value = ''; + challongeTournamentsError.value = error instanceof Error ? error.message : 'Could not verify OAuth status.'; + } +}; + const connectWithStartGGOAuth = async () => { oauthLoading.value = true; tournamentsError.value = ''; @@ -304,6 +377,25 @@ const connectWithStartGGOAuth = async () => { } }; +const connectWithChallongeOAuth = async () => { + challongeOauthLoading.value = true; + challongeTournamentsError.value = ''; + clearChallongeOAuthPolling(); + + try { + const session = await sendNodeCGMessage('challonge:createOAuthSession', {}); + challongeOauthSessionId.value = session.sessionId; + window.open(session.authUrl, '_blank', 'noopener,noreferrer'); + + challongeOauthPollingTimer = setInterval(() => { + void checkChallongeOAuthStatus(); + }, 1500); + } catch (error) { + challongeOauthLoading.value = false; + challongeTournamentsError.value = error instanceof Error ? error.message : 'Could not start OAuth with Challonge.'; + } +}; + const openManualTokenDialog = () => { manualTokenDraft.value = startGGToken.value; isManualTokenDialogOpen.value = true; @@ -348,6 +440,119 @@ const loadRecentTournaments = async () => { } }; +const challongeTournamentOptions = computed(() => + challongeRecentTournaments.value.map((tournament) => ({ + label: tournament.name, + value: tournament.slug, + caption: tournament.slug, + })), +); +const filteredChallongeTournamentOptions = ref(challongeTournamentOptions.value); + +watch(challongeTournamentOptions, (value) => { + filteredChallongeTournamentOptions.value = value; + if (selectedChallongeTournamentSlug.value && !challongeRecentTournaments.value.some((item) => item.slug === selectedChallongeTournamentSlug.value)) { + selectedChallongeTournamentSlug.value = ''; + challongeTournamentInput.value = ''; + } +}); + +const selectedChallongeTournamentOption = computed(() => + challongeRecentTournaments.value.find((item) => item.slug === selectedChallongeTournamentSlug.value) ?? null, +); +const canImportSelectedChallongeTournament = computed(() => Boolean(selectedChallongeTournamentOption.value)); +const hasChallongeTokenConfigured = computed(() => Boolean(challongeToken.value.trim())); + +const filterChallongeTournaments = (value: string, update: (callback: () => void) => void) => { + update(() => { + const needle = value.toLowerCase().trim(); + if (!needle) { + filteredChallongeTournamentOptions.value = challongeTournamentOptions.value; + return; + } + + filteredChallongeTournamentOptions.value = challongeTournamentOptions.value.filter((option) => + option.label.toLowerCase().includes(needle) || option.caption.toLowerCase().includes(needle), + ); + }); +}; + +const loadChallongeRecentTournaments = async () => { + const token = challongeToken.value.trim(); + if (!token) { + challongeTournamentsError.value = 'Add your Challonge token to load tournaments.'; + challongeRecentTournaments.value = []; + return; + } + + challongeTournamentsError.value = ''; + challongeLoadingTournaments.value = true; + try { + const tournaments = await sendNodeCGMessage('challonge:fetchRecentTournaments', { + token, + }); + challongeRecentTournaments.value = tournaments; + if (!tournaments.length) { + challongeTournamentsError.value = 'There are no recent tournaments for this account.'; + } + } catch (error) { + challongeTournamentsError.value = error instanceof Error ? error.message : 'Could not load tournaments.'; + challongeRecentTournaments.value = []; + } finally { + challongeLoadingTournaments.value = false; + } +}; + +const openChallongeImportDialog = async (tournament: ChallongeTournament) => { + importingChallongeTournament.value = tournament; + challongeImportDialogOpen.value = true; + loadingChallongeTournamentPlayers.value = true; + selectedChallongePlayerIds.value = []; + selectedChallongeTournamentSlug.value = tournament.slug; + challongeTournamentInput.value = tournament.name; + challongePlayers.value = []; + + try { + const importedPlayers = await sendNodeCGMessage('challonge:fetchTournamentPlayers', { + token: challongeToken.value.trim(), + slug: tournament.slug, + }); + challongePlayers.value = importedPlayers; + selectedChallongePlayerIds.value = importedPlayers.map((player) => player.id); + } catch (error) { + const message = error instanceof Error ? error.message : 'Could not load players'; + window.alert(message); + challongeImportDialogOpen.value = false; + } finally { + loadingChallongeTournamentPlayers.value = false; + } +}; + +const openSelectedChallongeTournamentImportDialog = () => { + if (!selectedChallongeTournamentOption.value) { + return; + } + void openChallongeImportDialog(selectedChallongeTournamentOption.value); +}; + +const importSelectedChallongePlayers = () => { + const selectedPlayers = challongePlayers.value.filter((player) => + selectedChallongePlayerIds.value.includes(player.id), + ); + + selectedPlayers.forEach((player) => { + playersStore.upsertPlayer(player.id, { + gamertag: player.gamertag, + name: player.name, + team: player.team, + country: player.country, + twitter: player.twitter, + }); + }); + + challongeImportDialogOpen.value = false; +}; + const openStartGGImportDialog = async (tournament: StartGGTournament) => { importingTournament.value = tournament; isImportDialogOpen.value = true; @@ -490,10 +695,14 @@ onMounted(() => { if (startGGToken.value.trim()) { void loadRecentTournaments(); } + if (challongeToken.value.trim()) { + void loadChallongeRecentTournaments(); + } }); onBeforeUnmount(() => { clearOAuthPolling(); + clearChallongeOAuthPolling(); clearTemporaryCleanupTimer(); }); @@ -697,6 +906,104 @@ onBeforeUnmount(() => { + +
+ +
+ Challonge +
+
+ Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants. +
+
+
+ + +
+
+
+ {{ challongeTournamentsError }} +
+
+ +
+ + + +
+
+ + Import players + +
+
+
+
@@ -795,6 +1102,51 @@ onBeforeUnmount(() => { + + + +
+ Import from {{ importingChallongeTournament?.name || 'Challonge' }} +
+
+ + +
+ + Loading participants... +
+
+ +
+
+ + + + + +
+
+ diff --git a/src/extension/challonge.ts b/src/extension/challonge.ts new file mode 100644 index 0000000..7984bf0 --- /dev/null +++ b/src/extension/challonge.ts @@ -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(); +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)[key]; + return typeof value === 'string' ? value.trim() : String(value || '').trim(); +}; + +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 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.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) => { + 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) => ` + + + + ${title} + + + +
+

${title}

+

${message}

+

You can close this tab and return to Scoreko.

+
+ +`; + +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 => { + 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, + }); + + 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 => { + 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) => { + 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)) { + payload.forEach((row) => { + 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 root = payload as Record; + const data = root.data; + if (Array.isArray(data)) { + data.forEach((row) => { + if (typeof row === 'object' && row !== null) { + push(row as Record); + } + }); + return rows; + } + } + + 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 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; + 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 root = payload as Record; + const data = root.data; + if (Array.isArray(data)) { + data.forEach((row) => { + if (typeof row === 'object' && row !== null) { + push(row as Record); + } + }); + } + } + + 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((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); + } +}); diff --git a/src/extension/index.ts b/src/extension/index.ts index f890cdf..d8ce32c 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -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'); }; diff --git a/src/types/schemas/configschema.d.ts b/src/types/schemas/configschema.d.ts index f5f456d..eb4b9ad 100644 --- a/src/types/schemas/configschema.d.ts +++ b/src/types/schemas/configschema.d.ts @@ -20,4 +20,16 @@ export interface Configschema { * Puerto local para callback OAuth */ startggOAuthPort?: number; + /** + * Client ID de tu OAuth app de Challonge + */ + challongeClientId?: string; + /** + * Client Secret de tu OAuth app de Challonge + */ + challongeClientSecret?: string; + /** + * Puerto local para callback OAuth de Challonge + */ + challongeOAuthPort?: number; }