Files

122 lines
4.1 KiB
TypeScript

import { steam2ToSteam64 } from './steam';
export interface SteamSummary {
avatarUrl: string;
personaName: string | null;
}
// Steam's generic "no avatar set" question-mark image — used whenever a
// player has no avatar, or the lookup fails for any reason.
export const DEFAULT_AVATAR_URL =
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg';
/**
* Fetches avatar + persona name from Steam's GetPlayerSummaries endpoint.
* Always resolves to a usable avatarUrl (falls back to the default
* question-mark image on any failure — including a missing STEAM_API_KEY)
* rather than null, so callers don't need their own fallback logic.
*
* Cached for an hour via Next's extended fetch() — avatars rarely change,
* and this keeps calls well within Steam's rate limits without needing a
* separate DB cache table.
*/
export async function getSteamSummary(steamId64: string): Promise<SteamSummary> {
const apiKey = process.env.STEAM_API_KEY;
if (!apiKey) {
return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null };
}
try {
const res = await fetch(
`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${steamId64}`,
{ next: { revalidate: 3600 } },
);
if (!res.ok) {
return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null };
}
const data = await res.json();
const player = data?.response?.players?.[0];
return {
avatarUrl: player?.avatarfull || DEFAULT_AVATAR_URL,
personaName: player?.personaname ?? null,
};
} catch {
return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null };
}
}
/**
* Same as getSteamSummary, but for many players in one go — Steam's
* GetPlayerSummaries accepts up to 100 comma-separated steamids per call,
* so fetching a list of players (e.g. "who was online at this point in
* time") is one HTTP call per 100 players rather than one per player.
* Returns a map keyed by SteamID64; missing entries mean "use the default
* avatar" at the call site.
*/
export async function getSteamSummariesBatch(
steamId64List: string[],
): Promise<Map<string, SteamSummary>> {
const result = new Map<string, SteamSummary>();
const apiKey = process.env.STEAM_API_KEY;
if (!apiKey || steamId64List.length === 0) {
return result;
}
const chunks: string[][] = [];
for (let i = 0; i < steamId64List.length; i += 100) {
chunks.push(steamId64List.slice(i, i + 100));
}
await Promise.all(
chunks.map(async (chunk) => {
try {
const res = await fetch(
`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${chunk.join(',')}`,
{ next: { revalidate: 3600 } },
);
if (!res.ok) return;
const data = await res.json();
const players = data?.response?.players ?? [];
for (const player of players) {
result.set(player.steamid, {
avatarUrl: player.avatarfull || DEFAULT_AVATAR_URL,
personaName: player.personaname ?? null,
});
}
} catch {
// this chunk's players just fall back to the default avatar downstream
}
}),
);
return result;
}
/**
* Attaches avatarUrl to any list of objects that have a `steamid` (Steam2
* format) field — the shared enrichment step used everywhere a list of
* players gets rendered, so every list in the app shows avatars
* consistently without duplicating the batch-fetch/fallback logic per call
* site.
*/
export async function attachAvatars<T extends { steamid: string }>(
players: T[],
): Promise<(T & { avatarUrl: string })[]> {
const steam64ByPlayer = new Map<string, string>();
for (const p of players) {
const id64 = steam2ToSteam64(p.steamid);
if (id64) steam64ByPlayer.set(p.steamid, id64);
}
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
return players.map((p) => {
const id64 = steam64ByPlayer.get(p.steamid);
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
return { ...p, avatarUrl };
});
}