mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
import { getData, type CountryRecord } from 'country-list';
|
|
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
|
|
import { nodecg } from './util/nodecg.js';
|
|
|
|
// ─── Constantes ────────────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
|
|
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
|
|
|
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 OAuthTokenResponse {
|
|
access_token?: string;
|
|
error?: string;
|
|
error_description?: string;
|
|
message?: string;
|
|
}
|
|
|
|
// ─── Config OAuth ──────────────────────────────────────────────────────────────
|
|
|
|
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 };
|
|
};
|
|
|
|
// ─── Intercambio de token (multi-endpoint) ─────────────────────────────────────
|
|
|
|
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,
|
|
config: OAuthConfig,
|
|
): Promise<string> => {
|
|
const params = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: config.clientId,
|
|
client_secret: config.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})`;
|
|
|
|
// Solo 404 justifica probar el siguiente endpoint
|
|
if (response.status !== 404) break;
|
|
}
|
|
|
|
throw new Error(lastError);
|
|
};
|
|
|
|
// ─── Servidor OAuth ────────────────────────────────────────────────────────────
|
|
|
|
const oauthServer = createOAuthServer({
|
|
provider: 'start.gg',
|
|
callbackPath: STARTGG_OAUTH_CALLBACK_PATH,
|
|
authorizeEndpoint: STARTGG_OAUTH_AUTHORIZE_ENDPOINT,
|
|
scope: STARTGG_OAUTH_SCOPES,
|
|
sessionTtlMs: STARTGG_OAUTH_SESSION_TTL_MS,
|
|
exchangeToken: exchangeOAuthCodeForToken,
|
|
});
|
|
|
|
// ─── GraphQL ───────────────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
};
|
|
|
|
// ─── Resolución de países ──────────────────────────────────────────────────────
|
|
|
|
const countries = getData();
|
|
const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase()));
|
|
const countryByName = new Map(
|
|
countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.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()) ?? '';
|
|
};
|
|
|
|
// ─── Utilidades ────────────────────────────────────────────────────────────────
|
|
|
|
const getStringProp = (payload: unknown, key: string): string => {
|
|
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
|
|
const value = (payload as Record<string, unknown>)[key];
|
|
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
|
};
|
|
|
|
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
|
if (typeof ack === 'function') ack(error, response);
|
|
};
|
|
|
|
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
|
|
|
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
|
|
const config = getOAuthConfig();
|
|
if (!config) {
|
|
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 oauthServer.ensureServer(config);
|
|
} catch (err) {
|
|
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
|
|
return;
|
|
}
|
|
|
|
const session = oauthServer.createSession(config);
|
|
sendAck(ack, null, session);
|
|
});
|
|
|
|
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
|
|
const sessionId = getStringProp(payload, 'sessionId');
|
|
if (!sessionId) {
|
|
sendAck(ack, 'Missing OAuth session id');
|
|
return;
|
|
}
|
|
|
|
const status = oauthServer.getSessionStatus(sessionId);
|
|
if (!status) {
|
|
sendAck(ack, 'OAuth session not found');
|
|
return;
|
|
}
|
|
|
|
sendAck(ack, null, status);
|
|
});
|
|
|
|
nodecg.listenFor('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(({ id, name, slug, startAt, endAt }) => ({ id, name, slug, startAt, endAt })) ?? [];
|
|
|
|
sendAck(ack, null, tournaments);
|
|
} catch (error) {
|
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
|
}
|
|
});
|
|
|
|
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;
|
|
|
|
for (const participant of data.tournament.participants.nodes) {
|
|
const playerId = String(participant.id);
|
|
const gamertag = (participant.gamerTag ?? '').trim();
|
|
if (!gamertag) continue;
|
|
|
|
playersMap.set(playerId, {
|
|
id: playerId,
|
|
gamertag,
|
|
name: gamertag,
|
|
team: (participant.prefix ?? '').trim(),
|
|
country: resolveCountryCodeFromStartGG(participant.user?.location?.country),
|
|
twitter: '',
|
|
});
|
|
}
|
|
|
|
currentPage += 1;
|
|
}
|
|
|
|
sendAck(ack, null, Array.from(playersMap.values()));
|
|
} catch (error) {
|
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
|
}
|
|
});
|