mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
Merge pull request #94 from Pandipipas/integrate-challonge-v2.1-api
feat(players): integrar Challonge v2.1 OAuth e importación de jugadores
This commit is contained in:
@@ -22,6 +22,23 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 65535,
|
"maximum": 65535,
|
||||||
"description": "Puerto local para callback OAuth"
|
"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": [
|
"required": [
|
||||||
|
|||||||
@@ -30,8 +30,22 @@ interface StartGGImportedPlayer extends Player {
|
|||||||
id: string;
|
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 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_PLAYERS_STORAGE_KEY = 'scoreko-dev.startgg-temp-players';
|
||||||
|
const CHALLONGE_TEMP_PLAYERS_STORAGE_KEY = 'scoreko-dev.challonge-temp-players';
|
||||||
const STARTGG_TEMP_FALLBACK_DURATION_SECONDS = 12 * 60 * 60;
|
const STARTGG_TEMP_FALLBACK_DURATION_SECONDS = 12 * 60 * 60;
|
||||||
|
|
||||||
interface TemporaryStartGGPlayerMeta {
|
interface TemporaryStartGGPlayerMeta {
|
||||||
@@ -40,6 +54,7 @@ interface TemporaryStartGGPlayerMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TemporaryStartGGPlayersMap = Record<string, TemporaryStartGGPlayerMeta>;
|
type TemporaryStartGGPlayersMap = Record<string, TemporaryStartGGPlayerMeta>;
|
||||||
|
type TemporaryChallongePlayersMap = Record<string, TemporaryStartGGPlayerMeta>;
|
||||||
|
|
||||||
const playersStore = usePlayersStore();
|
const playersStore = usePlayersStore();
|
||||||
const rows = computed<PlayerRow[]>(() => playersStore.rows);
|
const rows = computed<PlayerRow[]>(() => playersStore.rows);
|
||||||
@@ -97,6 +112,7 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const startGGToken = ref(localStorage.getItem(STARTGG_TOKEN_STORAGE_KEY) ?? '');
|
const startGGToken = ref(localStorage.getItem(STARTGG_TOKEN_STORAGE_KEY) ?? '');
|
||||||
|
const challongeToken = ref(localStorage.getItem(CHALLONGE_TOKEN_STORAGE_KEY) ?? '');
|
||||||
const recentTournaments = ref<StartGGTournament[]>([]);
|
const recentTournaments = ref<StartGGTournament[]>([]);
|
||||||
const loadingTournaments = ref(false);
|
const loadingTournaments = ref(false);
|
||||||
const tournamentsError = ref('');
|
const tournamentsError = ref('');
|
||||||
@@ -108,13 +124,31 @@ const selectedStartGGPlayerIds = ref<string[]>([]);
|
|||||||
const selectedTournamentSlug = ref('');
|
const selectedTournamentSlug = ref('');
|
||||||
const tournamentInput = ref('');
|
const tournamentInput = ref('');
|
||||||
const temporaryStartGGPlayers = ref<TemporaryStartGGPlayersMap>({});
|
const temporaryStartGGPlayers = ref<TemporaryStartGGPlayersMap>({});
|
||||||
|
const temporaryChallongePlayers = ref<TemporaryChallongePlayersMap>({});
|
||||||
let temporaryCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
let temporaryCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const oauthLoading = ref(false);
|
const oauthLoading = ref(false);
|
||||||
|
const challongeOauthLoading = ref(false);
|
||||||
const isManualTokenDialogOpen = ref(false);
|
const isManualTokenDialogOpen = ref(false);
|
||||||
const manualTokenDraft = ref('');
|
const manualTokenDraft = ref('');
|
||||||
|
const isChallongeManualTokenDialogOpen = ref(false);
|
||||||
|
const challongeManualTokenDraft = ref('');
|
||||||
const oauthSessionId = ref('');
|
const oauthSessionId = ref('');
|
||||||
|
const challongeOauthSessionId = ref('');
|
||||||
let oauthPollingTimer: ReturnType<typeof setInterval> | null = null;
|
let oauthPollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let challongeOauthPollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const challongeRecentTournaments = ref<ChallongeTournament[]>([]);
|
||||||
|
const challongeLoadingTournaments = ref(false);
|
||||||
|
const challongeTournamentsError = ref('');
|
||||||
|
const selectedChallongeTournamentSlug = ref('');
|
||||||
|
const challongeTournamentInput = ref('');
|
||||||
|
const challongePlayers = ref<ChallongeImportedPlayer[]>([]);
|
||||||
|
const selectedChallongePlayerIds = ref<string[]>([]);
|
||||||
|
const challongeImportDialogOpen = ref(false);
|
||||||
|
const loadingChallongeTournamentPlayers = ref(false);
|
||||||
|
const importingChallongeTournament = ref<ChallongeTournament | null>(null);
|
||||||
|
const hasValidatedChallongeToken = ref(false);
|
||||||
|
|
||||||
interface OAuthSessionResponse {
|
interface OAuthSessionResponse {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -131,10 +165,19 @@ watch(startGGToken, (value) => {
|
|||||||
localStorage.setItem(STARTGG_TOKEN_STORAGE_KEY, value);
|
localStorage.setItem(STARTGG_TOKEN_STORAGE_KEY, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(challongeToken, (value) => {
|
||||||
|
localStorage.setItem(CHALLONGE_TOKEN_STORAGE_KEY, value);
|
||||||
|
hasValidatedChallongeToken.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
const persistTemporaryStartGGPlayers = () => {
|
const persistTemporaryStartGGPlayers = () => {
|
||||||
localStorage.setItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY, JSON.stringify(temporaryStartGGPlayers.value));
|
localStorage.setItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY, JSON.stringify(temporaryStartGGPlayers.value));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const persistTemporaryChallongePlayers = () => {
|
||||||
|
localStorage.setItem(CHALLONGE_TEMP_PLAYERS_STORAGE_KEY, JSON.stringify(temporaryChallongePlayers.value));
|
||||||
|
};
|
||||||
|
|
||||||
const loadTemporaryStartGGPlayers = (): TemporaryStartGGPlayersMap => {
|
const loadTemporaryStartGGPlayers = (): TemporaryStartGGPlayersMap => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY);
|
const raw = localStorage.getItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY);
|
||||||
@@ -169,6 +212,40 @@ const loadTemporaryStartGGPlayers = (): TemporaryStartGGPlayersMap => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadTemporaryChallongePlayers = (): TemporaryChallongePlayersMap => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CHALLONGE_TEMP_PLAYERS_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: TemporaryChallongePlayersMap = {};
|
||||||
|
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(() =>
|
const tournamentOptions = computed(() =>
|
||||||
recentTournaments.value.map((tournament) => ({
|
recentTournaments.value.map((tournament) => ({
|
||||||
label: tournament.name,
|
label: tournament.name,
|
||||||
@@ -224,6 +301,13 @@ const clearOAuthPolling = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearChallongeOAuthPolling = () => {
|
||||||
|
if (challongeOauthPollingTimer) {
|
||||||
|
clearInterval(challongeOauthPollingTimer);
|
||||||
|
challongeOauthPollingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const clearTemporaryCleanupTimer = () => {
|
const clearTemporaryCleanupTimer = () => {
|
||||||
if (temporaryCleanupTimer) {
|
if (temporaryCleanupTimer) {
|
||||||
clearInterval(temporaryCleanupTimer);
|
clearInterval(temporaryCleanupTimer);
|
||||||
@@ -233,22 +317,31 @@ const clearTemporaryCleanupTimer = () => {
|
|||||||
|
|
||||||
const cleanupExpiredTemporaryPlayers = () => {
|
const cleanupExpiredTemporaryPlayers = () => {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const expiredIds = Object.entries(temporaryStartGGPlayers.value)
|
const expiredStartGGIds = Object.entries(temporaryStartGGPlayers.value)
|
||||||
.filter(([, meta]) => meta.expiresAt <= now)
|
.filter(([, meta]) => meta.expiresAt <= now)
|
||||||
.map(([playerId]) => playerId);
|
.map(([playerId]) => playerId);
|
||||||
|
const expiredChallongeIds = Object.entries(temporaryChallongePlayers.value)
|
||||||
|
.filter(([, meta]) => meta.expiresAt <= now)
|
||||||
|
.map(([playerId]) => playerId);
|
||||||
|
|
||||||
|
const expiredIds = Array.from(new Set([...expiredStartGGIds, ...expiredChallongeIds]));
|
||||||
|
|
||||||
if (!expiredIds.length) {
|
if (!expiredIds.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMeta = { ...temporaryStartGGPlayers.value };
|
const nextStartGGMeta = { ...temporaryStartGGPlayers.value };
|
||||||
|
const nextChallongeMeta = { ...temporaryChallongePlayers.value };
|
||||||
expiredIds.forEach((playerId) => {
|
expiredIds.forEach((playerId) => {
|
||||||
playersStore.removePlayer(playerId);
|
playersStore.removePlayer(playerId);
|
||||||
delete nextMeta[playerId];
|
delete nextStartGGMeta[playerId];
|
||||||
|
delete nextChallongeMeta[playerId];
|
||||||
});
|
});
|
||||||
|
|
||||||
temporaryStartGGPlayers.value = nextMeta;
|
temporaryStartGGPlayers.value = nextStartGGMeta;
|
||||||
|
temporaryChallongePlayers.value = nextChallongeMeta;
|
||||||
persistTemporaryStartGGPlayers();
|
persistTemporaryStartGGPlayers();
|
||||||
|
persistTemporaryChallongePlayers();
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkOAuthStatus = async () => {
|
const checkOAuthStatus = async () => {
|
||||||
@@ -285,6 +378,40 @@ const checkOAuthStatus = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkChallongeOAuthStatus = async () => {
|
||||||
|
if (!challongeOauthSessionId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await sendNodeCGMessage<OAuthStatusResponse>('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 () => {
|
const connectWithStartGGOAuth = async () => {
|
||||||
oauthLoading.value = true;
|
oauthLoading.value = true;
|
||||||
tournamentsError.value = '';
|
tournamentsError.value = '';
|
||||||
@@ -304,6 +431,25 @@ const connectWithStartGGOAuth = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const connectWithChallongeOAuth = async () => {
|
||||||
|
challongeOauthLoading.value = true;
|
||||||
|
challongeTournamentsError.value = '';
|
||||||
|
clearChallongeOAuthPolling();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await sendNodeCGMessage<OAuthSessionResponse>('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 = () => {
|
const openManualTokenDialog = () => {
|
||||||
manualTokenDraft.value = startGGToken.value;
|
manualTokenDraft.value = startGGToken.value;
|
||||||
isManualTokenDialogOpen.value = true;
|
isManualTokenDialogOpen.value = true;
|
||||||
@@ -348,6 +494,158 @@ 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 challongeConnectionLabel = computed(() => (hasValidatedChallongeToken.value ? 'Connected' : 'Token set'));
|
||||||
|
|
||||||
|
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<ChallongeTournament[]>('challonge:fetchRecentTournaments', {
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
hasValidatedChallongeToken.value = true;
|
||||||
|
challongeRecentTournaments.value = tournaments;
|
||||||
|
if (!tournaments.length) {
|
||||||
|
challongeTournamentsError.value = 'There are no recent tournaments for this account.';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
hasValidatedChallongeToken.value = false;
|
||||||
|
const message = error instanceof Error ? error.message : 'Could not load tournaments.';
|
||||||
|
challongeTournamentsError.value = message.includes('401')
|
||||||
|
? 'Challonge rejected the token (401 Unauthorized). Re-connect OAuth so it grants scopes (me, tournaments:read, participants:read) or paste a valid personal API token.'
|
||||||
|
: message;
|
||||||
|
challongeRecentTournaments.value = [];
|
||||||
|
} finally {
|
||||||
|
challongeLoadingTournaments.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openChallongeManualTokenDialog = () => {
|
||||||
|
challongeManualTokenDraft.value = challongeToken.value;
|
||||||
|
isChallongeManualTokenDialogOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveChallongeManualToken = () => {
|
||||||
|
challongeToken.value = challongeManualTokenDraft.value.trim();
|
||||||
|
|
||||||
|
if (!challongeToken.value) {
|
||||||
|
challongeRecentTournaments.value = [];
|
||||||
|
selectedChallongeTournamentSlug.value = '';
|
||||||
|
challongeTournamentInput.value = '';
|
||||||
|
challongeTournamentsError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
isChallongeManualTokenDialogOpen.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<ChallongeImportedPlayer[]>('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),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextMeta = { ...temporaryChallongePlayers.value };
|
||||||
|
const tournament = importingChallongeTournament.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
temporaryChallongePlayers.value = nextMeta;
|
||||||
|
persistTemporaryChallongePlayers();
|
||||||
|
challongeImportDialogOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const openStartGGImportDialog = async (tournament: StartGGTournament) => {
|
const openStartGGImportDialog = async (tournament: StartGGTournament) => {
|
||||||
importingTournament.value = tournament;
|
importingTournament.value = tournament;
|
||||||
isImportDialogOpen.value = true;
|
isImportDialogOpen.value = true;
|
||||||
@@ -484,16 +782,21 @@ const handleImport = async (event: Event) => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
temporaryStartGGPlayers.value = loadTemporaryStartGGPlayers();
|
temporaryStartGGPlayers.value = loadTemporaryStartGGPlayers();
|
||||||
|
temporaryChallongePlayers.value = loadTemporaryChallongePlayers();
|
||||||
cleanupExpiredTemporaryPlayers();
|
cleanupExpiredTemporaryPlayers();
|
||||||
temporaryCleanupTimer = setInterval(cleanupExpiredTemporaryPlayers, 60 * 1000);
|
temporaryCleanupTimer = setInterval(cleanupExpiredTemporaryPlayers, 60 * 1000);
|
||||||
|
|
||||||
if (startGGToken.value.trim()) {
|
if (startGGToken.value.trim()) {
|
||||||
void loadRecentTournaments();
|
void loadRecentTournaments();
|
||||||
}
|
}
|
||||||
|
if (challongeToken.value.trim()) {
|
||||||
|
void loadChallongeRecentTournaments();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearOAuthPolling();
|
clearOAuthPolling();
|
||||||
|
clearChallongeOAuthPolling();
|
||||||
clearTemporaryCleanupTimer();
|
clearTemporaryCleanupTimer();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -583,119 +886,232 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-lg-4 players-startgg-column">
|
<div class="col-12 col-lg-4 players-startgg-column">
|
||||||
<QCard
|
<div class="players-integrations-stack">
|
||||||
flat
|
<QCard
|
||||||
bordered
|
flat
|
||||||
class="q-pa-md"
|
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 class="text-h6 q-mb-sm startgg-heading">
|
||||||
</div>
|
<svg
|
||||||
<div class="row items-center q-mt-md startgg-tournament-row">
|
class="startgg-heading__icon"
|
||||||
<QBtn
|
viewBox="0 0 24 24"
|
||||||
flat
|
aria-hidden="true"
|
||||||
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">
|
<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" />
|
||||||
<QItem v-bind="scope.itemProps">
|
</svg>
|
||||||
<QItemSection>
|
<span>StartGG</span>
|
||||||
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
|
</div>
|
||||||
<QItemLabel caption>
|
<div class="text-caption q-mb-md">
|
||||||
{{ scope.opt.caption }}
|
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.
|
||||||
</QItemLabel>
|
</div>
|
||||||
</QItemSection>
|
<div class="row q-col-gutter-sm items-center">
|
||||||
</QItem>
|
<div class="col-auto">
|
||||||
</template>
|
<QBtn
|
||||||
</QSelect>
|
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>
|
||||||
<div
|
<div
|
||||||
v-if="canImportSelectedTournament"
|
v-if="tournamentsError"
|
||||||
class="col-auto"
|
class="text-negative q-mt-sm"
|
||||||
>
|
>
|
||||||
<QBtn
|
{{ tournamentsError }}
|
||||||
color="primary"
|
|
||||||
unelevated
|
|
||||||
round
|
|
||||||
icon="person_add"
|
|
||||||
aria-label="Import players"
|
|
||||||
@click="openSelectedTournamentImportDialog"
|
|
||||||
>
|
|
||||||
<QTooltip>Import players</QTooltip>
|
|
||||||
</QBtn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="row items-center q-mt-md startgg-tournament-row">
|
||||||
</QCard>
|
<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>
|
||||||
|
|
||||||
|
<QCard
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
class="q-pa-md"
|
||||||
|
>
|
||||||
|
<div class="text-h6 q-mb-sm startgg-heading">
|
||||||
|
<img
|
||||||
|
class="challonge-heading__icon"
|
||||||
|
src="https://challonge.com/favicon.ico"
|
||||||
|
alt="Challonge"
|
||||||
|
>
|
||||||
|
<span>Challonge</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption q-mb-md">
|
||||||
|
Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants.
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm items-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<QBtn
|
||||||
|
v-if="!hasChallongeTokenConfigured"
|
||||||
|
color="primary"
|
||||||
|
icon="login"
|
||||||
|
label="Connect with Challonge"
|
||||||
|
:loading="challongeOauthLoading"
|
||||||
|
@click="connectWithChallongeOAuth"
|
||||||
|
/>
|
||||||
|
<QBtn
|
||||||
|
v-else
|
||||||
|
outline
|
||||||
|
:color="hasValidatedChallongeToken ? 'positive' : 'warning'"
|
||||||
|
icon="check_circle"
|
||||||
|
:label="challongeConnectionLabel"
|
||||||
|
@click="openChallongeManualTokenDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<QBtn
|
||||||
|
outline
|
||||||
|
color="white"
|
||||||
|
icon="vpn_key"
|
||||||
|
label="Use personal API"
|
||||||
|
@click="openChallongeManualTokenDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="challongeTournamentsError"
|
||||||
|
class="text-negative q-mt-sm"
|
||||||
|
>
|
||||||
|
{{ challongeTournamentsError }}
|
||||||
|
</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="challongeLoadingTournaments"
|
||||||
|
@click="loadChallongeRecentTournaments"
|
||||||
|
/>
|
||||||
|
<div class="col">
|
||||||
|
<QSelect
|
||||||
|
v-model="selectedChallongeTournamentSlug"
|
||||||
|
v-model:input-value="challongeTournamentInput"
|
||||||
|
:options="filteredChallongeTournamentOptions"
|
||||||
|
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="filterChallongeTournaments"
|
||||||
|
>
|
||||||
|
<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="canImportSelectedChallongeTournament"
|
||||||
|
class="col-auto"
|
||||||
|
>
|
||||||
|
<QBtn
|
||||||
|
color="primary"
|
||||||
|
unelevated
|
||||||
|
round
|
||||||
|
icon="person_add"
|
||||||
|
aria-label="Import players"
|
||||||
|
@click="openSelectedChallongeTournamentImportDialog"
|
||||||
|
>
|
||||||
|
<QTooltip>Import players</QTooltip>
|
||||||
|
</QBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</QCard>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -795,6 +1211,94 @@ onBeforeUnmount(() => {
|
|||||||
</QCard>
|
</QCard>
|
||||||
</QDialog>
|
</QDialog>
|
||||||
|
|
||||||
|
<QDialog v-model="challongeImportDialogOpen">
|
||||||
|
<QCard class="players-dialog">
|
||||||
|
<QCardSection>
|
||||||
|
<div class="text-h6">
|
||||||
|
Import from {{ importingChallongeTournament?.name || 'Challonge' }}
|
||||||
|
</div>
|
||||||
|
</QCardSection>
|
||||||
|
<QSeparator />
|
||||||
|
<QCardSection>
|
||||||
|
<div
|
||||||
|
v-if="loadingChallongeTournamentPlayers"
|
||||||
|
class="row items-center q-gutter-sm"
|
||||||
|
>
|
||||||
|
<QSpinner />
|
||||||
|
<span>Loading participants...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<QOptionGroup
|
||||||
|
v-model="selectedChallongePlayerIds"
|
||||||
|
type="checkbox"
|
||||||
|
:options="challongePlayers.map((player) => ({
|
||||||
|
label: `${player.gamertag}${player.team ? ` (${player.team})` : ''}`,
|
||||||
|
value: player.id,
|
||||||
|
}))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</QCardSection>
|
||||||
|
<QSeparator />
|
||||||
|
<QCardActions align="right">
|
||||||
|
<QBtn
|
||||||
|
flat
|
||||||
|
label="Cancel"
|
||||||
|
color="secondary"
|
||||||
|
@click="challongeImportDialogOpen = false"
|
||||||
|
/>
|
||||||
|
<QBtn
|
||||||
|
color="primary"
|
||||||
|
label="Import selected"
|
||||||
|
:disable="!selectedChallongePlayerIds.length"
|
||||||
|
@click="importSelectedChallongePlayers"
|
||||||
|
/>
|
||||||
|
</QCardActions>
|
||||||
|
</QCard>
|
||||||
|
</QDialog>
|
||||||
|
|
||||||
|
<QDialog v-model="isChallongeManualTokenDialogOpen">
|
||||||
|
<QCard class="players-dialog">
|
||||||
|
<QCardSection>
|
||||||
|
<div class="text-h6">
|
||||||
|
Personal Challonge API
|
||||||
|
</div>
|
||||||
|
</QCardSection>
|
||||||
|
<QSeparator />
|
||||||
|
<QCardSection>
|
||||||
|
<div class="text-body2 q-mb-sm">
|
||||||
|
If OAuth fails, paste a personal Challonge API token.
|
||||||
|
</div>
|
||||||
|
<QInput
|
||||||
|
v-model="challongeManualTokenDraft"
|
||||||
|
label="Paste your personal Challonge token"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</QCardSection>
|
||||||
|
<QSeparator />
|
||||||
|
<QCardActions align="right">
|
||||||
|
<QBtn
|
||||||
|
flat
|
||||||
|
label="Cancel"
|
||||||
|
color="secondary"
|
||||||
|
@click="isChallongeManualTokenDialogOpen = false"
|
||||||
|
/>
|
||||||
|
<QBtn
|
||||||
|
flat
|
||||||
|
color="negative"
|
||||||
|
label="Delete token"
|
||||||
|
@click="challongeManualTokenDraft = ''; saveChallongeManualToken()"
|
||||||
|
/>
|
||||||
|
<QBtn
|
||||||
|
color="primary"
|
||||||
|
label="Save token"
|
||||||
|
@click="saveChallongeManualToken"
|
||||||
|
/>
|
||||||
|
</QCardActions>
|
||||||
|
</QCard>
|
||||||
|
</QDialog>
|
||||||
|
|
||||||
<QDialog v-model="isDialogOpen">
|
<QDialog v-model="isDialogOpen">
|
||||||
<QCard class="players-dialog">
|
<QCard class="players-dialog">
|
||||||
<QCardSection>
|
<QCardSection>
|
||||||
@@ -905,6 +1409,12 @@ onBeforeUnmount(() => {
|
|||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.players-integrations-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.players-dialog {
|
.players-dialog {
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
@@ -924,6 +1434,12 @@ onBeforeUnmount(() => {
|
|||||||
fill: #2e75ba;
|
fill: #2e75ba;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.challonge-heading__icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.startgg-tournament-row {
|
.startgg-tournament-row {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,562 @@
|
|||||||
|
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_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;
|
||||||
|
|
||||||
|
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 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}`;
|
||||||
|
|
||||||
|
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 for personal API keys pasted manually (v1 auth style).
|
||||||
|
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 maybeError = v2Payload as { errors?: { detail?: string }; error?: string } | null;
|
||||||
|
if (!v2Response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
maybeError?.errors?.detail || maybeError?.error || `Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return v2Payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
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),
|
||||||
|
scope: CHALLONGE_OAUTH_SCOPES,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -10,4 +10,5 @@ export default async (nodecg: NodeCGServerAPI) => {
|
|||||||
await import('./util/replicants.js'); // make sure replicants are set up
|
await import('./util/replicants.js'); // make sure replicants are set up
|
||||||
await import('./example.js');
|
await import('./example.js');
|
||||||
await import('./startgg.js');
|
await import('./startgg.js');
|
||||||
|
await import('./challonge.js');
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+12
@@ -20,4 +20,16 @@ export interface Configschema {
|
|||||||
* Puerto local para callback OAuth
|
* Puerto local para callback OAuth
|
||||||
*/
|
*/
|
||||||
startggOAuthPort?: number;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user