Merge pull request #83 from Pandipipas/integrate-with-startgg-for-tournament-syncing

Add start.gg tournament sync and player import workflow
This commit is contained in:
Pandipipas
2026-02-17 18:23:44 +01:00
committed by GitHub
5 changed files with 1240 additions and 54 deletions
+17
View File
@@ -5,6 +5,23 @@
"properties": {
"exampleProperty": {
"type": "string"
},
"startggClientId": {
"type": "string",
"default": "",
"description": "Client ID de tu OAuth app de start.gg"
},
"startggClientSecret": {
"type": "string",
"default": "",
"description": "Client Secret de tu OAuth app de start.gg"
},
"startggOAuthPort": {
"type": "integer",
"default": 34920,
"minimum": 1,
"maximum": 65535,
"description": "Puerto local para callback OAuth"
}
},
"required": [
+617 -1
View File
@@ -4,7 +4,7 @@ import { useHead } from '@unhead/vue';
defineOptions({ name: 'PlayersView' });
import type { QTableColumn } from 'quasar';
import { computed, reactive, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { countryOptions, getCountryLabel } from '../../../shared/countries';
import type { Schemas } from '../../../types';
import { usePlayersStore } from '../stores/players';
@@ -18,6 +18,29 @@ interface PlayerRow extends Player {
id: string;
}
interface StartGGTournament {
id: number;
name: string;
slug: string;
startAt: number | null;
endAt: number | null;
}
interface StartGGImportedPlayer extends Player {
id: string;
}
const STARTGG_TOKEN_STORAGE_KEY = 'scoreko-dev.startgg-token';
const STARTGG_TEMP_PLAYERS_STORAGE_KEY = 'scoreko-dev.startgg-temp-players';
const STARTGG_TEMP_FALLBACK_DURATION_SECONDS = 12 * 60 * 60;
interface TemporaryStartGGPlayerMeta {
expiresAt: number;
tournamentSlug: string;
}
type TemporaryStartGGPlayersMap = Record<string, TemporaryStartGGPlayerMeta>;
const playersStore = usePlayersStore();
const rows = computed<PlayerRow[]>(() => playersStore.rows);
@@ -73,6 +96,323 @@ watch(
{ immediate: true },
);
const startGGToken = ref(localStorage.getItem(STARTGG_TOKEN_STORAGE_KEY) ?? '');
const recentTournaments = ref<StartGGTournament[]>([]);
const loadingTournaments = ref(false);
const tournamentsError = ref('');
const isImportDialogOpen = ref(false);
const loadingTournamentPlayers = ref(false);
const importingTournament = ref<StartGGTournament | null>(null);
const startGGPlayers = ref<StartGGImportedPlayer[]>([]);
const selectedStartGGPlayerIds = ref<string[]>([]);
const selectedTournamentSlug = ref('');
const tournamentInput = ref('');
const temporaryStartGGPlayers = ref<TemporaryStartGGPlayersMap>({});
let temporaryCleanupTimer: ReturnType<typeof setInterval> | null = null;
const oauthLoading = ref(false);
const isManualTokenDialogOpen = ref(false);
const manualTokenDraft = ref('');
const oauthSessionId = ref('');
let oauthPollingTimer: ReturnType<typeof setInterval> | null = null;
interface OAuthSessionResponse {
sessionId: string;
authUrl: string;
}
interface OAuthStatusResponse {
status: 'pending' | 'completed' | 'error' | 'expired';
token?: string;
error?: string;
}
watch(startGGToken, (value) => {
localStorage.setItem(STARTGG_TOKEN_STORAGE_KEY, value);
});
const persistTemporaryStartGGPlayers = () => {
localStorage.setItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY, JSON.stringify(temporaryStartGGPlayers.value));
};
const loadTemporaryStartGGPlayers = (): TemporaryStartGGPlayersMap => {
try {
const raw = localStorage.getItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== 'object' || parsed === null) {
return {};
}
const result: TemporaryStartGGPlayersMap = {};
Object.entries(parsed as Record<string, unknown>).forEach(([playerId, value]) => {
if (!playerId || typeof value !== 'object' || value === null) {
return;
}
const candidate = value as Record<string, unknown>;
const expiresAt = Number(candidate.expiresAt);
const tournamentSlug = String(candidate.tournamentSlug || '').trim();
if (!Number.isFinite(expiresAt) || expiresAt <= 0 || !tournamentSlug) {
return;
}
result[playerId] = {
expiresAt,
tournamentSlug,
};
});
return result;
} catch {
return {};
}
};
const tournamentOptions = computed(() =>
recentTournaments.value.map((tournament) => ({
label: tournament.name,
value: tournament.slug,
caption: tournament.slug,
})),
);
const filteredTournamentOptions = ref(tournamentOptions.value);
watch(tournamentOptions, (value) => {
filteredTournamentOptions.value = value;
if (selectedTournamentSlug.value && !recentTournaments.value.some((item) => item.slug === selectedTournamentSlug.value)) {
selectedTournamentSlug.value = '';
tournamentInput.value = '';
}
});
const selectedTournamentOption = computed(() =>
recentTournaments.value.find((item) => item.slug === selectedTournamentSlug.value) ?? null,
);
const canImportSelectedTournament = computed(() => Boolean(selectedTournamentOption.value));
const hasStartGGTokenConfigured = computed(() => Boolean(startGGToken.value.trim()));
const filterTournaments = (value: string, update: (callback: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
if (!needle) {
filteredTournamentOptions.value = tournamentOptions.value;
return;
}
filteredTournamentOptions.value = tournamentOptions.value.filter((option) =>
option.label.toLowerCase().includes(needle) || option.caption.toLowerCase().includes(needle),
);
});
};
const sendNodeCGMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
new Promise((resolve, reject) => {
nodecg.sendMessage(messageName, payload, (error, response) => {
if (error) {
reject(new Error(String(error)));
return;
}
resolve(response as T);
});
});
const clearOAuthPolling = () => {
if (oauthPollingTimer) {
clearInterval(oauthPollingTimer);
oauthPollingTimer = null;
}
};
const clearTemporaryCleanupTimer = () => {
if (temporaryCleanupTimer) {
clearInterval(temporaryCleanupTimer);
temporaryCleanupTimer = null;
}
};
const cleanupExpiredTemporaryPlayers = () => {
const now = Math.floor(Date.now() / 1000);
const expiredIds = Object.entries(temporaryStartGGPlayers.value)
.filter(([, meta]) => meta.expiresAt <= now)
.map(([playerId]) => playerId);
if (!expiredIds.length) {
return;
}
const nextMeta = { ...temporaryStartGGPlayers.value };
expiredIds.forEach((playerId) => {
playersStore.removePlayer(playerId);
delete nextMeta[playerId];
});
temporaryStartGGPlayers.value = nextMeta;
persistTemporaryStartGGPlayers();
};
const checkOAuthStatus = async () => {
if (!oauthSessionId.value) {
return;
}
try {
const status = await sendNodeCGMessage<OAuthStatusResponse>('startgg:getOAuthSessionStatus', {
sessionId: oauthSessionId.value,
});
if (status.status === 'completed' && status.token) {
startGGToken.value = status.token;
oauthLoading.value = false;
clearOAuthPolling();
oauthSessionId.value = '';
tournamentsError.value = '';
await loadRecentTournaments();
return;
}
if (status.status === 'error' || status.status === 'expired') {
oauthLoading.value = false;
clearOAuthPolling();
oauthSessionId.value = '';
tournamentsError.value = status.error || 'Could not complete OAuth login with start.gg.';
}
} catch (error) {
oauthLoading.value = false;
clearOAuthPolling();
oauthSessionId.value = '';
tournamentsError.value = error instanceof Error ? error.message : 'Could not verify OAuth status.';
}
};
const connectWithStartGGOAuth = async () => {
oauthLoading.value = true;
tournamentsError.value = '';
clearOAuthPolling();
try {
const session = await sendNodeCGMessage<OAuthSessionResponse>('startgg:createOAuthSession', {});
oauthSessionId.value = session.sessionId;
window.open(session.authUrl, '_blank', 'noopener,noreferrer');
oauthPollingTimer = setInterval(() => {
void checkOAuthStatus();
}, 1500);
} catch (error) {
oauthLoading.value = false;
tournamentsError.value = error instanceof Error ? error.message : 'Could not start OAuth with start.gg.';
}
};
const openManualTokenDialog = () => {
manualTokenDraft.value = startGGToken.value;
isManualTokenDialogOpen.value = true;
};
const saveManualToken = () => {
startGGToken.value = manualTokenDraft.value.trim();
if (!startGGToken.value) {
recentTournaments.value = [];
selectedTournamentSlug.value = '';
tournamentInput.value = '';
tournamentsError.value = '';
}
isManualTokenDialogOpen.value = false;
};
const loadRecentTournaments = async () => {
const token = startGGToken.value.trim();
if (!token) {
tournamentsError.value = 'Add your start.gg token to load tournaments.';
recentTournaments.value = [];
return;
}
tournamentsError.value = '';
loadingTournaments.value = true;
try {
const tournaments = await sendNodeCGMessage<StartGGTournament[]>('startgg:fetchRecentTournaments', {
token,
});
recentTournaments.value = tournaments;
if (!tournaments.length) {
tournamentsError.value = 'There are no recent tournaments for this account.';
}
} catch (error) {
tournamentsError.value = error instanceof Error ? error.message : 'Could not load tournaments.';
recentTournaments.value = [];
} finally {
loadingTournaments.value = false;
}
};
const openStartGGImportDialog = async (tournament: StartGGTournament) => {
importingTournament.value = tournament;
isImportDialogOpen.value = true;
loadingTournamentPlayers.value = true;
selectedStartGGPlayerIds.value = [];
selectedTournamentSlug.value = tournament.slug;
tournamentInput.value = tournament.name;
startGGPlayers.value = [];
try {
const importedPlayers = await sendNodeCGMessage<StartGGImportedPlayer[]>('startgg:fetchTournamentPlayers', {
token: startGGToken.value.trim(),
slug: tournament.slug,
});
startGGPlayers.value = importedPlayers;
selectedStartGGPlayerIds.value = importedPlayers.map((player) => player.id);
} catch (error) {
const message = error instanceof Error ? error.message : 'Could not load players';
window.alert(message);
isImportDialogOpen.value = false;
} finally {
loadingTournamentPlayers.value = false;
}
};
const openSelectedTournamentImportDialog = () => {
if (!selectedTournamentOption.value) {
return;
}
void openStartGGImportDialog(selectedTournamentOption.value);
};
const importSelectedStartGGPlayers = () => {
const selectedPlayers = startGGPlayers.value.filter((player) =>
selectedStartGGPlayerIds.value.includes(player.id),
);
const nextMeta = { ...temporaryStartGGPlayers.value };
const tournament = importingTournament.value;
const fallbackEndAt = (tournament?.startAt ?? Math.floor(Date.now() / 1000)) + STARTGG_TEMP_FALLBACK_DURATION_SECONDS;
const expiresAt = tournament?.endAt ?? fallbackEndAt;
selectedPlayers.forEach((player) => {
playersStore.upsertPlayer(player.id, {
gamertag: player.gamertag,
name: player.name,
team: player.team,
country: player.country,
twitter: player.twitter,
});
if (tournament) {
nextMeta[player.id] = {
expiresAt,
tournamentSlug: tournament.slug,
};
}
});
temporaryStartGGPlayers.value = nextMeta;
persistTemporaryStartGGPlayers();
isImportDialogOpen.value = false;
};
const generateId = () => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
@@ -141,6 +481,21 @@ const handleImport = async (event: Event) => {
}
}
};
onMounted(() => {
temporaryStartGGPlayers.value = loadTemporaryStartGGPlayers();
cleanupExpiredTemporaryPlayers();
temporaryCleanupTimer = setInterval(cleanupExpiredTemporaryPlayers, 60 * 1000);
if (startGGToken.value.trim()) {
void loadRecentTournaments();
}
});
onBeforeUnmount(() => {
clearOAuthPolling();
clearTemporaryCleanupTimer();
});
</script>
<template>
@@ -159,6 +514,8 @@ const handleImport = async (event: Event) => {
/>
</div>
<div class="players-content row q-col-gutter-md">
<div class="col-12">
<div class="row items-center q-gutter-sm q-mb-md">
<QInput
v-model="filter"
@@ -193,7 +550,9 @@ const handleImport = async (event: Event) => {
@change="handleImport"
>
</div>
</div>
<div class="col-12 col-lg-8 players-main-column">
<QTable
flat
bordered
@@ -221,6 +580,220 @@ const handleImport = async (event: Event) => {
</QTd>
</template>
</QTable>
</div>
<div class="col-12 col-lg-4 players-startgg-column">
<QCard
flat
bordered
class="q-pa-md"
>
<div class="text-h6 q-mb-sm startgg-heading">
<svg
class="startgg-heading__icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 0A5.999 5.999 0 00.002 6v5.252a.75.75 0 00.75.748H5.25a.748.748 0 00.75-.747V6.749C6 6.334 6.336 6 6.748 6h16.497a.748.748 0 00.749-.748V.749A.743.743 0 0023.247 0zm12.75 12a.748.748 0 00-.75.75v4.5a.748.748 0 01-.747.748H.753a.754.754 0 00-.75.751v4.5a.75.75 0 00.75.751H18a5.999 5.999 0 005.999-6v-5.25a.75.75 0 00-.75-.75z" />
</svg>
<span>StartGG</span>
</div>
<div class="text-caption q-mb-md">
Connect via OAuth (recommended) or paste your personal token to load tournaments you created or administrate. If you see "Client authentication failed", verify your config uses the Client ID/Secret from a start.gg OAuth App.
</div>
<div class="row q-col-gutter-sm items-center">
<div class="col-auto">
<QBtn
v-if="!hasStartGGTokenConfigured"
color="primary"
icon="login"
label="Connect with start.gg"
:loading="oauthLoading"
@click="connectWithStartGGOAuth"
/>
<QBtn
v-else
outline
color="positive"
icon="check_circle"
label="Connected"
class="startgg-connected-btn"
@click="openManualTokenDialog"
/>
</div>
<div class="col-auto">
<QBtn
outline
color="white"
icon="vpn_key"
label="Use personal API"
@click="openManualTokenDialog"
/>
</div>
</div>
<div
v-if="tournamentsError"
class="text-negative q-mt-sm"
>
{{ tournamentsError }}
</div>
<div class="row items-center q-mt-md startgg-tournament-row">
<QBtn
flat
round
dense
text-color="white"
icon="sync"
class="startgg-refresh-btn"
:loading="loadingTournaments"
@click="loadRecentTournaments"
/>
<div class="col">
<QSelect
v-model="selectedTournamentSlug"
v-model:input-value="tournamentInput"
:options="filteredTournamentOptions"
option-value="value"
option-label="label"
emit-value
map-options
use-input
hide-selected
fill-input
input-debounce="0"
clearable
dense
label="Tournament"
class="players-underlined-field"
@filter="filterTournaments"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
<QItemLabel caption>
{{ scope.opt.caption }}
</QItemLabel>
</QItemSection>
</QItem>
</template>
</QSelect>
</div>
<div
v-if="canImportSelectedTournament"
class="col-auto"
>
<QBtn
color="primary"
unelevated
round
icon="person_add"
aria-label="Import players"
@click="openSelectedTournamentImportDialog"
>
<QTooltip>Import players</QTooltip>
</QBtn>
</div>
</div>
</QCard>
</div>
</div>
<QDialog v-model="isManualTokenDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
Personal start.gg API
</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div class="text-body2 q-mb-sm">
If OAuth fails, you can create your personal token manually with these steps:
</div>
<ol class="q-pl-md q-mb-md manual-token-steps">
<li>Go to https://start.gg/admin/profile/developer</li>
<li>Sign in with your account</li>
<li>From the 3 access tokens, click <strong>Third Party</strong></li>
<li>Create a new one and fill the description with any name you want</li>
<li>Copy the generated token and paste it into Scoreko</li>
</ol>
<QInput
v-model="manualTokenDraft"
label="Paste your personal token"
dense
outlined
type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn
flat
label="Cancel"
color="secondary"
@click="isManualTokenDialogOpen = false"
/>
<QBtn
flat
color="negative"
label="Delete token"
@click="manualTokenDraft = ''; saveManualToken()"
/>
<QBtn
color="primary"
label="Save token"
@click="saveManualToken"
/>
</QCardActions>
</QCard>
</QDialog>
<QDialog v-model="isImportDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
Import from {{ importingTournament?.name || 'start.gg' }}
</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div
v-if="loadingTournamentPlayers"
class="row items-center q-gutter-sm"
>
<QSpinner />
<span>Loading participants...</span>
</div>
<div v-else>
<QOptionGroup
v-model="selectedStartGGPlayerIds"
type="checkbox"
:options="startGGPlayers.map((player) => ({
label: `${player.gamertag}${player.team ? ` (${player.team})` : ''}${player.country ? ` - ${getCountryLabel(player.country)}` : ''}`,
value: player.id,
}))"
/>
</div>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn
flat
label="Cancel"
color="secondary"
@click="isImportDialogOpen = false"
/>
<QBtn
color="primary"
label="Import selected"
:disable="!selectedStartGGPlayerIds.length"
@click="importSelectedStartGGPlayers"
/>
</QCardActions>
</QCard>
</QDialog>
<QDialog v-model="isDialogOpen">
<QCard class="players-dialog">
@@ -319,11 +892,50 @@ const handleImport = async (event: Event) => {
min-width: 240px;
}
.players-content {
align-items: flex-start;
}
.players-main-column {
min-width: 0;
flex: 1 1 auto;
}
.players-startgg-column {
min-width: 320px;
}
.players-dialog {
min-width: 320px;
width: min(720px, 90vw);
}
.startgg-heading {
display: inline-flex;
align-items: center;
gap: 8px;
}
.startgg-heading__icon {
width: 20px;
height: 20px;
fill: #2e75ba;
}
.startgg-tournament-row {
gap: 4px;
}
.startgg-refresh-btn:hover {
background: rgba(255, 255, 255, 0.12);
}
.startgg-connected-btn {
font-weight: 600;
}
.players-underlined-field :deep(.q-field__control) {
min-height: 28px;
padding: 0;
@@ -337,6 +949,10 @@ const handleImport = async (event: Event) => {
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
}
.manual-token-steps {
line-height: 1.5;
}
.visually-hidden {
position: absolute;
width: 1px;
+1
View File
@@ -9,4 +9,5 @@ export default async (nodecg: NodeCGServerAPI) => {
set(nodecg); // set nodecg "context" before anything else
await import('./util/replicants.js'); // make sure replicants are set up
await import('./example.js');
await import('./startgg.js');
};
+540
View File
@@ -0,0 +1,540 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { getData, type CountryRecord } from 'country-list';
import { nodecg } from './util/nodecg.js';
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize';
const STARTGG_OAUTH_TOKEN_ENDPOINTS = [
'https://www.start.gg/api/-/rest/oauth/access_token',
'https://api.start.gg/oauth/access_token',
];
const STARTGG_OAUTH_SCOPES = 'user.identity tournament.manager';
const STARTGG_OAUTH_CALLBACK_PATH = '/startgg/callback';
const STARTGG_OAUTH_DEFAULT_PORT = 34920;
const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 12;
const PARTICIPANTS_PAGE_SIZE = 120;
interface StartGGGraphQLResponse<T> {
data?: T;
errors?: Array<{ message?: string }>;
}
interface RecentTournament {
id: number;
name: string;
slug: string;
startAt: number | null;
endAt: number | null;
}
interface ImportedPlayer {
id: string;
gamertag: string;
name: string;
team: string;
country: string;
twitter: string;
}
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;
}
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 updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => {
const session = oauthSessions.get(sessionId);
if (!session) {
return;
}
oauthSessions.set(sessionId, {
...session,
...update,
});
};
const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => {
const response = await fetch(STARTGG_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`start.gg responded with ${response.status} ${response.statusText}`.trim());
}
let payload: StartGGGraphQLResponse<T>;
try {
payload = (await response.json()) as StartGGGraphQLResponse<T>;
} catch {
throw new Error('Invalid JSON response from start.gg');
}
if (payload.errors?.length) {
throw new Error(payload.errors[0]?.message || 'Unknown start.gg error');
}
if (!payload.data) {
throw new Error('No data returned by start.gg');
}
return payload.data;
};
const countries = getData();
const countryByCode = new Set(countries.map((country: CountryRecord) => country.code.toUpperCase()));
const countryByName = new Map(countries.map((country: CountryRecord) => [country.name.toLowerCase(), country.code.toUpperCase()]));
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
const raw = (country || '').trim();
if (!raw) {
return '';
}
const upper = raw.toUpperCase();
if (countryByCode.has(upper)) {
return upper;
}
return countryByName.get(raw.toLowerCase()) ?? '';
};
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.startggClientId || '').trim();
const clientSecret = String(bundleConfig.startggClientSecret || '').trim();
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
if (!clientId || !clientSecret) {
return null;
}
return {
clientId,
clientSecret,
callbackPort,
};
};
const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${STARTGG_OAUTH_CALLBACK_PATH}`;
const cleanupExpiredOAuthSessions = () => {
const now = Date.now();
oauthSessions.forEach((session, sessionId) => {
if (session.expiresAt <= now && session.status === 'pending') {
updateOAuthSession(sessionId, { status: 'expired' });
}
});
};
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 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; }
.ok { color: #66bb6a; }
.ko { color: #ef5350; }
</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 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,
});
let lastError = 'Unknown OAuth token exchange error';
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
const payload = await parseOAuthTokenPayload(response);
if (response.ok) {
const token = String(payload.access_token || '').trim();
if (token) {
return token;
}
lastError = payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token';
continue;
}
lastError = payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`;
if (response.status !== 404) {
break;
}
}
throw new Error(lastError);
};
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 !== STARTGG_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', `start.gg 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('startgg:createOAuthSession', async (_payload: unknown, ack) => {
const oauthConfig = getOAuthConfig();
if (!oauthConfig) {
sendAck(ack, 'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg 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();
const session: OAuthSession = {
sessionId,
state,
expiresAt: Date.now() + STARTGG_OAUTH_SESSION_TTL_MS,
status: 'pending',
};
oauthSessions.set(sessionId, session);
const params = new URLSearchParams({
response_type: 'code',
client_id: oauthConfig.clientId,
redirect_uri: getCallbackUrl(oauthConfig.callbackPort),
scope: STARTGG_OAUTH_SCOPES,
state,
});
sendAck(ack, null, {
sessionId,
authUrl: `${STARTGG_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`,
});
});
nodecg.listenFor('startgg: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('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
const token = getStringProp(payload, 'token');
if (!token) {
sendAck(ack, 'Missing start.gg API token');
return;
}
const query = `
query RecentTournaments($perPage: Int!) {
currentUser {
tournaments(query: { perPage: $perPage, filter: { tournamentView: "admin" } }) {
nodes {
id
name
slug
startAt
endAt
}
}
}
}
`;
try {
const data = await requestStartGG<{
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
const tournaments = data.currentUser?.tournaments.nodes
.filter((item) => item.slug)
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
.map((item) => ({
id: item.id,
name: item.name,
slug: item.slug,
startAt: item.startAt,
endAt: item.endAt,
})) ?? [];
sendAck(ack, null, tournaments);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments';
sendAck(ack, message);
}
});
nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => {
const token = getStringProp(payload, 'token');
const slug = getStringProp(payload, 'slug');
if (!token) {
sendAck(ack, 'Missing start.gg API token');
return;
}
if (!slug) {
sendAck(ack, 'Missing tournament slug');
return;
}
const query = `
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
tournament(slug: $slug) {
participants(query: { page: $page, perPage: $perPage }) {
pageInfo {
totalPages
}
nodes {
id
gamerTag
prefix
user {
location {
country
}
}
}
}
}
}
`;
try {
let currentPage = 1;
let totalPages = 1;
const playersMap = new Map<string, ImportedPlayer>();
while (currentPage <= totalPages) {
const data = await requestStartGG<{
tournament: {
participants: {
pageInfo: { totalPages: number };
nodes: Array<{
id: number;
gamerTag: string | null;
prefix: string | null;
user: {
location: {
country: string | null;
} | null;
} | null;
}>;
};
} | null;
}>(query, {
slug,
page: currentPage,
perPage: PARTICIPANTS_PAGE_SIZE,
}, token);
if (!data.tournament) {
throw new Error('Tournament not found');
}
const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1;
data.tournament.participants.nodes.forEach((participant) => {
const playerId = String(participant.id);
const gamertag = (participant.gamerTag || '').trim();
if (!gamertag) {
return;
}
const country = resolveCountryCodeFromStartGG(participant.user?.location?.country);
playersMap.set(playerId, {
id: playerId,
gamertag,
name: gamertag,
team: (participant.prefix || '').trim(),
country,
twitter: '',
});
});
currentPage += 1;
}
sendAck(ack, null, Array.from(playersMap.values()));
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while importing players';
sendAck(ack, message);
}
});
+12
View File
@@ -8,4 +8,16 @@
export interface Configschema {
exampleProperty: string;
/**
* Client ID de tu OAuth app de start.gg
*/
startggClientId?: string;
/**
* Client Secret de tu OAuth app de start.gg
*/
startggClientSecret?: string;
/**
* Puerto local para callback OAuth
*/
startggOAuthPort?: number;
}