Discord presence overhaul: game profiles, Spotify bar, Lanyard WS
Build & Push / Lint & Build (push) Successful in 1m19s
Build & Push / Build & Push Image (push) Successful in 31s

- Spotify: drop album line, normalize ";" separators to ",", add open-in-Spotify link and live progress bar
- Games: resolve missing artwork via public Discord RPC app icons with placeholder fallback; add in-site game profile modal (IGDB + Steam + ITAD: artwork, details, reviews, prices)
- Proxy game data through same-origin /api/game and /api/discord-app routes, CORS-locked to https://fristy.net
- Lanyard: SSR REST first paint, then live WebSocket with heartbeat, reconnect backoff and polling fallback
- Localize presence UI (tr/en/de): elapsed times, dates, numbers, currency, review labels, game descriptions
- Sort projects by last update, descending
- Slim dark scrollbars, compact Spotify progress bar
This commit is contained in:
fristy committed 2026-09-10 14:36:14 +03:00
1 parent ad943fff8c
commit bcc5662a44
16 files changed
+1888 -43

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
const ALLOWED_ORIGIN = "https://fristy.net";
export function isAllowedOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
return origin === null || origin === ALLOWED_ORIGIN;
}
export function corsHeaders(): HeadersInit {
return {
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
Vary: "Origin",
};
}
export function forbidden(): NextResponse {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
+615
View File
@@ -0,0 +1,615 @@
import { getDiscordAppInfo } from "@/lib/lanyard";
export interface GameExternalLink {
label: string;
url: string;
}
export interface GameReviewSummary {
label: string;
total: number | null;
positive: number | null;
negative: number | null;
url?: string;
}
export function steamdbRating(positive: number, total: number): number | null {
if (
!Number.isFinite(positive) ||
!Number.isFinite(total) ||
total <= 0 ||
positive < 0 ||
positive > total
) {
return null;
}
const score = positive / total;
return score - (score - 0.5) * 2 ** -Math.log10(total + 1);
}
export interface GamePricePoint {
price: number;
regular: number;
currency: string;
shop: string;
url: string;
}
export interface ItadReview {
score: number | null;
source: string;
count: number | null;
url: string;
}
export interface GameProfile {
applicationId: string;
gameId: string;
gameUrl: string;
name: string;
genres: string[];
description: string | null;
developers: string[];
publishers: string[];
releaseDate: string | null;
platforms: string[];
links: GameExternalLink[];
coverUrl: string | null;
artworkUrl: string | null;
screenshots: string[];
steamId: string | null;
steamUrl: string | null;
steamReviews: GameReviewSummary | null;
itad: {
prices: GamePricePoint | null;
historyLow: GamePricePoint | null;
pageUrl: string | null;
playerCount: number | null;
reviews: ItadReview[];
} | null;
sources: {
igdb: boolean;
steam: boolean;
itad: boolean;
};
}
interface DetectableEntry {
id: string;
name: string;
third_party_skus?: Array<{ distributor: string; id: string }>;
}
let detectablesCache: { data: DetectableEntry[]; expires: number } | null = null;
async function getDetectables(): Promise<DetectableEntry[]> {
if (detectablesCache && Date.now() < detectablesCache.expires) {
return detectablesCache.data;
}
try {
const response = await fetch("https://cdn.discordapp.com/detectables/games-v1.json", {
next: { revalidate: 86_400 },
});
if (!response.ok) {
return detectablesCache?.data ?? [];
}
const data = (await response.json()) as DetectableEntry[];
detectablesCache = { data, expires: Date.now() + 86_400_000 };
return data;
} catch {
return detectablesCache?.data ?? [];
}
}
const DISTRIBUTOR_PLATFORM: Record<string, string> = {
steam: "PC",
battlenet: "PC",
gog: "PC",
epic: "PC",
xbox: "Xbox",
xbox_title: "Xbox",
xbox_game_pass: "Xbox",
playstation: "PlayStation",
nintendo: "Switch",
};
function steamIdFromEntry(entry: DetectableEntry | undefined): string | null {
const sku = entry?.third_party_skus?.find(
(s) => s.distributor === "steam" && /^\d+$/.test(s.id),
);
return sku?.id ?? null;
}
interface SteamAppDetails {
success: boolean;
data?: {
name?: string;
short_description?: string;
header_image?: string;
genres?: Array<{ description?: string }>;
developers?: string[];
publishers?: string[];
platforms?: { windows?: boolean; mac?: boolean; linux?: boolean };
release_date?: { date?: string };
screenshots?: Array<{ path_full?: string }>;
};
}
const STEAM_LANG: Record<string, string> = {
tr: "turkish",
en: "english",
de: "german",
};
async function getSteamDetails(
steamId: string,
lang = "en",
): Promise<SteamAppDetails["data"] | null> {
try {
const steamLang = STEAM_LANG[lang] ?? "english";
const response = await fetch(
`https://store.steampowered.com/api/appdetails?appids=${steamId}&l=${steamLang}&cc=US`,
{ next: { revalidate: 86_400 } },
);
if (!response.ok) {
return null;
}
const json = (await response.json()) as Record<string, SteamAppDetails>;
const entry = json[steamId];
return entry?.success ? (entry.data ?? null) : null;
} catch {
return null;
}
}
interface SteamReviewQuery {
review_score_desc?: string;
total_positive?: number;
total_negative?: number;
total_reviews?: number;
}
async function getSteamReviewSummary(steamId: string): Promise<GameReviewSummary | null> {
try {
const url =
`https://store.steampowered.com/appreviews/${steamId}` +
"?json=1&language=all&purchase_type=all&num_per_page=0";
const response = await fetch(url, { next: { revalidate: 3600 } });
if (!response.ok) {
return null;
}
const json = (await response.json()) as { query_summary?: SteamReviewQuery };
const summary = json.query_summary;
if (!summary || typeof summary.total_reviews !== "number") {
return null;
}
return {
label: summary.review_score_desc ?? "",
total: summary.total_reviews,
positive: summary.total_positive ?? null,
negative: summary.total_negative ?? null,
url: `https://store.steampowered.com/app/${steamId}/#app_reviews_hash`,
};
} catch {
return null;
}
}
interface IgdbGame {
name?: string;
genres?: Array<{ name?: string }>;
summary?: string;
storyline?: string;
cover?: { url?: string };
artworks?: Array<{ url?: string }>;
screenshots?: Array<{ url?: string }>;
websites?: Array<{ url?: string; category?: number }>;
involved_companies?: Array<{
developer?: boolean;
publisher?: boolean;
company?: { name?: string };
}>;
platforms?: Array<{ abbreviation?: string; name?: string }>;
first_release_date?: number;
}
let igdbTokenCache: { token: string; expires: number } | null = null;
async function getIgdbToken(): Promise<string | null> {
const clientId = process.env.TWITCH_CLIENT_ID;
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return null;
}
if (igdbTokenCache && Date.now() < igdbTokenCache.expires) {
return igdbTokenCache.token;
}
try {
const response = await fetch("https://id.twitch.tv/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "client_credentials",
}),
});
if (!response.ok) {
console.error(`igdb token failed: ${response.status}`);
return null;
}
const json = (await response.json()) as { access_token?: string; expires_in?: number };
if (!json.access_token) {
return null;
}
igdbTokenCache = {
token: json.access_token,
expires: Date.now() + (json.expires_in ?? 3600) * 1000 - 60_000,
};
return json.access_token;
} catch {
return null;
}
}
async function igdbQuery<T>(token: string, endpoint: string, body: string): Promise<T | null> {
try {
const response = await fetch(`https://api.igdb.com/v4/${endpoint}`, {
method: "POST",
headers: {
"Client-ID": process.env.TWITCH_CLIENT_ID ?? "",
Authorization: `Bearer ${token}`,
"Content-Type": "text/plain",
},
body,
next: { revalidate: 86_400 },
});
if (!response.ok) {
console.error(`igdb query failed: ${endpoint} ${response.status}`);
return null;
}
return (await response.json()) as T;
} catch {
return null;
}
}
const IGDB_GAME_FIELDS =
"name,genres.name,summary,storyline,cover.url,artworks.url,screenshots.url," +
"websites.url,websites.category,involved_companies.developer," +
"involved_companies.publisher,involved_companies.company.name," +
"platforms.abbreviation,platforms.name,first_release_date;";
function igdbImage(url: string | undefined, size: string): string | null {
if (!url) {
return null;
}
const full = url.startsWith("//") ? `https:${url}` : url;
return full.replace(/t_thumb|t_cover_small|t_logo_med/, size);
}
async function getIgdbGame(name: string, steamId: string | null): Promise<IgdbGame | null> {
const token = await getIgdbToken();
if (!token) {
return null;
}
if (steamId) {
const external = await igdbQuery<Array<{ game?: number }>>(
token,
"external_games",
`fields game; where uid = "${steamId}"; limit 1;`,
);
const gameId = external?.[0]?.game;
if (gameId) {
const games = await igdbQuery<IgdbGame[]>(
token,
"games",
`fields ${IGDB_GAME_FIELDS} where id = ${gameId}; limit 1;`,
);
if (games?.[0]) {
return games[0];
}
}
}
const cleaned = name
.replace(/[®™©]/g, "")
.replace(/\s{2,}/g, " ")
.trim();
const norm = (v: string): string =>
v
.toLowerCase()
.replace(/[®™©]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
const escaped = cleaned.replace(/"/g, '\\"');
const games = await igdbQuery<IgdbGame[]>(
token,
"games",
`fields ${IGDB_GAME_FIELDS} search "${escaped}"; limit 5;`,
);
if (!games || games.length === 0) {
return null;
}
const wanted = norm(name);
return games.find((g) => g.name && norm(g.name) === wanted) ?? games[0] ?? null;
}
const IGDB_WEBSITE_LABELS: Record<number, string> = {
1: "Website",
4: "Facebook",
5: "X",
6: "Twitch",
8: "Instagram",
9: "YouTube",
13: "Steam",
14: "Reddit",
16: "Epic",
17: "GOG",
18: "Discord",
};
interface ItadGameInfo {
title?: string;
assets?: { boxart?: string; banner600?: string };
tags?: string[];
releaseDate?: string | null;
developers?: Array<{ name?: string }>;
publishers?: Array<{ name?: string }>;
reviews?: Array<{
score?: number | null;
source?: string;
count?: number | null;
url?: string;
}>;
players?: { recent?: number } | null;
urls?: { game?: string };
}
interface ItadOverviewPrice {
current?: {
shop?: { name?: string };
price?: { amount?: number; currency?: string };
regular?: { amount?: number };
cut?: number;
url?: string;
} | null;
lowest?: {
shop?: { name?: string };
price?: { amount?: number; currency?: string };
regular?: { amount?: number };
cut?: number;
} | null;
}
async function itadFetch<T>(path: string, init?: RequestInit): Promise<T | null> {
const key = process.env.ITAD_API_KEY;
if (!key) {
return null;
}
try {
const separator = path.includes("?") ? "&" : "?";
const response = await fetch(
`https://api.isthereanydeal.com${path}${separator}key=${key}`,
{
...init,
next: { revalidate: 3600 },
},
);
if (!response.ok) {
return null;
}
return (await response.json()) as T;
} catch {
return null;
}
}
async function getItadId(title: string): Promise<string | null> {
const results = await itadFetch<Array<{ id?: string; title?: string }>>(
`/games/search/v1?title=${encodeURIComponent(title)}&results=5`,
);
if (!results || results.length === 0) {
return null;
}
const norm = (value: string): string =>
value
.toLowerCase()
.replace(/[®™©]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
const wanted = norm(title);
return results.find((r) => r.title && norm(r.title) === wanted)?.id ?? null;
}
function toIsoDate(unixSeconds: number | undefined): string | null {
if (!unixSeconds) {
return null;
}
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
}
export async function getGameProfile(
applicationId: string,
country = "TR",
lang = "en",
): Promise<GameProfile | null> {
if (!/^\d+$/.test(applicationId)) {
return null;
}
const appInfo = await getDiscordAppInfo(applicationId);
if (!appInfo) {
return null;
}
const detectables = await getDetectables();
const entry = detectables.find((e) => e.id === appInfo.gameId || e.id === applicationId);
const steamId = steamIdFromEntry(entry);
const [steam, steamReviews, igdb] = await Promise.all([
steamId ? getSteamDetails(steamId, lang) : Promise.resolve(null),
steamId ? getSteamReviewSummary(steamId) : Promise.resolve(null),
getIgdbGame(appInfo.name || entry?.name || "", steamId),
]);
const itadId = process.env.ITAD_API_KEY
? await getItadId(igdb?.name ?? steam?.name ?? appInfo.name)
: null;
const [itadInfo, itadOverview] = itadId
? await Promise.all([
itadFetch<ItadGameInfo>(`/games/info/v2?id=${itadId}`),
itadFetch<{ prices?: ItadOverviewPrice[] }>(`/games/overview/v2?country=${country}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify([itadId]),
}),
])
: [null, null];
const companies = igdb?.involved_companies ?? [];
const devList: string[] =
companies.length > 0
? [
...new Set(
companies
.filter((c) => c.developer)
.map((c) => c.company?.name)
.filter((n): n is string => !!n),
),
]
: (steam?.developers ??
(itadInfo?.developers?.map((d) => d.name).filter((n): n is string => !!n) || []));
const pubList: string[] =
companies.length > 0
? [
...new Set(
companies
.filter((c) => c.publisher)
.map((c) => c.company?.name)
.filter((n): n is string => !!n),
),
]
: (steam?.publishers ??
(itadInfo?.publishers?.map((d) => d.name).filter((n): n is string => !!n) || []));
const platforms = new Set<string>();
for (const p of igdb?.platforms ?? []) {
const label = `${p.abbreviation ?? ""} ${p.name ?? ""}`.toUpperCase();
if (/PS|PLAYSTATION/.test(label)) {
platforms.add("PlayStation");
} else if (/XBOX|XONE|XSX/.test(label)) {
platforms.add("Xbox");
} else if (/PC|WIN|MAC|LIN/.test(label)) {
platforms.add("PC");
} else if (/SWITCH/.test(label)) {
platforms.add("Switch");
}
}
if (
steam?.platforms &&
(steam.platforms.windows || steam.platforms.mac || steam.platforms.linux)
) {
platforms.add("PC");
}
for (const sku of entry?.third_party_skus ?? []) {
const mapped = DISTRIBUTOR_PLATFORM[sku.distributor];
if (mapped) {
platforms.add(mapped);
}
}
const links: GameExternalLink[] = [];
for (const site of igdb?.websites ?? []) {
if (!site.url) {
continue;
}
const url = site.url.startsWith("http") ? site.url : `https://${site.url}`;
let label = site.category ? (IGDB_WEBSITE_LABELS[site.category] ?? null) : null;
if (!label) {
try {
label = new URL(url).hostname.replace(/^www\./, "");
} catch {
continue;
}
}
if (!links.some((l) => l.url === url)) {
links.push({ label, url });
}
}
const screenshots: string[] = [
...(igdb?.screenshots ?? [])
.map((s) => igdbImage(s.url, "t_screenshot_big"))
.filter((u): u is string => !!u),
...(steam?.screenshots ?? []).map((s) => s.path_full).filter((u): u is string => !!u),
].slice(0, 8);
const priceEntry = itadOverview?.prices?.[0];
const toPoint = (p: ItadOverviewPrice["current"]): GamePricePoint | null => {
if (!p?.price || typeof p.price.amount !== "number") {
return null;
}
return {
price: p.price.amount,
regular: p.regular?.amount ?? p.price.amount,
currency: p.price.currency ?? "",
shop: p.shop?.name ?? "",
url: p.url ?? "",
};
};
return {
applicationId,
gameId: appInfo.gameId,
gameUrl: appInfo.gameUrl,
name: igdb?.name ?? steam?.name ?? appInfo.name,
genres: [
...(igdb?.genres ?? []).map((g) => g.name).filter((n): n is string => !!n),
...(igdb?.genres?.length ? [] : (itadInfo?.tags ?? []).slice(0, 5)),
].slice(0, 6),
description:
lang === "en"
? (igdb?.summary ?? steam?.short_description ?? null)
: (steam?.short_description ?? igdb?.summary ?? null),
developers: devList,
publishers: pubList,
releaseDate:
toIsoDate(igdb?.first_release_date) ??
itadInfo?.releaseDate ??
steam?.release_date?.date ??
null,
platforms: [...platforms],
links,
coverUrl: igdbImage(igdb?.cover?.url, "t_cover_big"),
artworkUrl:
igdbImage(igdb?.artworks?.[0]?.url, "t_1080p") ??
steam?.header_image ??
itadInfo?.assets?.banner600 ??
null,
screenshots,
steamId,
steamUrl: steamId ? `https://store.steampowered.com/app/${steamId}/` : null,
steamReviews,
itad:
process.env.ITAD_API_KEY && (itadInfo || priceEntry)
? {
prices: toPoint(priceEntry?.current),
historyLow: toPoint(priceEntry?.lowest),
pageUrl: itadInfo?.urls?.game ?? null,
playerCount: itadInfo?.players?.recent ?? null,
reviews: (itadInfo?.reviews ?? [])
.filter((r) => r.source && r.url)
.map((r) => ({
score: r.score ?? null,
source: r.source as string,
count: r.count ?? null,
url: r.url as string,
})),
}
: null,
sources: {
igdb: !!igdb,
steam: !!steam,
itad: !!itadInfo,
},
};
}
+2 -1
View File
@@ -53,7 +53,8 @@ export async function getRepos(): Promise<GiteaRepo[]> {
stars: repo.stars_count,
forks: repo.forks_count,
updatedAt: repo.updated_at,
}));
}))
.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt));
} catch (error) {
console.error("cannot fetch gitea repos:", error);
return [];
+38 -1
View File
@@ -25,7 +25,44 @@
"listening": "Hört",
"customStatus": "Status",
"noActivity": "Gerade still... Hier läuft wohl nichts.",
"profile": "Profil Ansehen"
"profile": "Profil Ansehen",
"openOnSpotify": "Auf Spotify öffnen",
"gameProfile": "Spielprofil",
"gameModal": {
"close": "Schließen",
"developers": "Entwickler",
"publishers": "Publisher",
"releaseDate": "Erscheinungsdatum",
"platforms": "Plattformen",
"links": "Links",
"reviews": "Bewertungen",
"prices": "Preise",
"bestPrice": "Bester Preis",
"historyLow": "Historischer Tiefstpreis",
"allPrices": "ITAD",
"openOnDiscord": "Discord-Profil",
"openOnSteam": "Steam",
"openOnSteamDb": "SteamDB",
"loading": "Spieldaten werden geladen...",
"loadError": "Spieldaten konnten nicht geladen werden.",
"screenshots": "Screenshots",
"reviewsUnit": "Bewertungen",
"scrollBack": "Zurück",
"scrollForward": "Weiter",
"totalReviews": "Bewertungen insgesamt",
"steamdbRating": "SteamDB-Wertung",
"reviewScores": {
"overwhelminglyPositive": "Äußerst positiv",
"veryPositive": "Sehr positiv",
"positive": "Positiv",
"mostlyPositive": "Größtenteils positiv",
"mixed": "Ausgeglichen",
"mostlyNegative": "Größtenteils negativ",
"negative": "Negativ",
"veryNegative": "Sehr negativ",
"overwhelminglyNegative": "Äußerst negativ"
}
}
},
"projects": {
"title": "Projekte",
+38 -1
View File
@@ -25,7 +25,44 @@
"listening": "Listening To",
"customStatus": "Status",
"noActivity": "Quiet right now... Nothing seems to be going on.",
"profile": "View Profile"
"profile": "View Profile",
"openOnSpotify": "Open on Spotify",
"gameProfile": "Game Profile",
"gameModal": {
"close": "Close",
"developers": "Developers",
"publishers": "Publishers",
"releaseDate": "Release Date",
"platforms": "Platforms",
"links": "Links",
"reviews": "Reviews",
"prices": "Prices",
"bestPrice": "Best Price",
"historyLow": "All-Time Low",
"allPrices": "ITAD",
"openOnDiscord": "Discord Profile",
"openOnSteam": "Steam",
"openOnSteamDb": "SteamDB",
"loading": "Loading game info...",
"loadError": "Couldn't load game info.",
"screenshots": "Screenshots",
"reviewsUnit": "reviews",
"scrollBack": "Back",
"scrollForward": "Forward",
"totalReviews": "Total Reviews",
"steamdbRating": "SteamDB Rating",
"reviewScores": {
"overwhelminglyPositive": "Overwhelmingly Positive",
"veryPositive": "Very Positive",
"positive": "Positive",
"mostlyPositive": "Mostly Positive",
"mixed": "Mixed",
"mostlyNegative": "Mostly Negative",
"negative": "Negative",
"veryNegative": "Very Negative",
"overwhelminglyNegative": "Overwhelmingly Negative"
}
}
},
"projects": {
"title": "Projects",
+38 -1
View File
@@ -25,7 +25,44 @@
"listening": "Dinliyor",
"customStatus": "Durum",
"noActivity": "Şu an sessizlik... Ortalıkta bir şey yok gibi.",
"profile": "Profile Git"
"profile": "Profile Git",
"openOnSpotify": "Spotify'da Aç",
"gameProfile": "Oyun Profili",
"gameModal": {
"close": "Kapat",
"developers": "Geliştiriciler",
"publishers": "Yayıncılar",
"releaseDate": "Çıkış Tarihi",
"platforms": "Platformlar",
"links": "Bağlantılar",
"reviews": "İncelemeler",
"prices": "Fiyatlar",
"bestPrice": "En İyi Fiyat",
"historyLow": "Tarihi En Düşük",
"allPrices": "ITAD",
"openOnDiscord": "Discord Profili",
"openOnSteam": "Steam",
"openOnSteamDb": "SteamDB",
"loading": "Oyun bilgileri yükleniyor...",
"loadError": "Oyun bilgileri yüklenemedi.",
"screenshots": "Görseller",
"reviewsUnit": "değerlendirme",
"scrollBack": "Geri",
"scrollForward": "İleri",
"totalReviews": "Toplam Değerlendirme",
"steamdbRating": "SteamDB Puanı",
"reviewScores": {
"overwhelminglyPositive": "Son Derece Olumlu",
"veryPositive": "Çok Olumlu",
"positive": "Olumlu",
"mostlyPositive": "Çoğunlukla Olumlu",
"mixed": "Karışık",
"mostlyNegative": "Çoğunlukla Olumsuz",
"negative": "Olumsuz",
"veryNegative": "Çok Olumsuz",
"overwhelminglyNegative": "Son Derece Olumsuz"
}
}
},
"projects": {
"title": "Projeler",
+90
View File
@@ -2,6 +2,8 @@ export const DISCORD_ID = "1014549805608992901";
const LANYARD_API = `https://api.lanyard.rest/v1/users/${DISCORD_ID}`;
export const LANYARD_WS = "wss://api.lanyard.rest/socket";
export type DiscordStatus = "online" | "idle" | "dnd" | "offline";
export interface LanyardEmoji {
@@ -48,12 +50,100 @@ export function activityImageUrl(activity: LanyardActivity, small: boolean): str
export interface LanyardSpotify {
track_id: string;
timestamps: {
start: number;
end: number;
};
song: string;
artist: string;
album: string;
album_art_url: string | null;
}
export function formatSpotifyList(value: string | null | undefined): string {
if (!value) {
return "";
}
return value
.replace(/;/g, ",")
.replace(/,[ \t]+,/g, ",")
.replace(/\s{2,}/g, " ")
.trim();
}
export function spotifyTrackUrl(trackId: string): string {
return `https://open.spotify.com/track/${trackId}`;
}
export function discordAppIconUrl(
applicationId: string,
iconHash: string | null,
size = 128,
): string | null {
if (!iconHash) {
return null;
}
const extension = iconHash.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/app-icons/${applicationId}/${iconHash}.${extension}?size=${size}`;
}
export function discordGameUrl(gameId: string): string {
return `https://discord.com/games/${gameId}`;
}
export interface DiscordAppInfo {
id: string;
name: string;
icon: string | null;
iconUrl: string | null;
coverImage: string | null;
coverUrl: string | null;
gameId: string;
gameUrl: string;
}
interface DiscordRpcResponse {
id: string;
name: string;
icon?: string | null;
cover_image?: string | null;
linked_games?: Array<{ id: string }> | null;
}
export async function getDiscordAppInfo(applicationId: string): Promise<DiscordAppInfo | null> {
if (!/^\d+$/.test(applicationId)) {
return null;
}
try {
const response = await fetch(
`https://discord.com/api/v10/applications/${applicationId}/rpc`,
{
headers: { "User-Agent": "fristy.net (personal site)" },
next: { revalidate: 86_400 },
},
);
if (!response.ok) {
return null;
}
const json = (await response.json()) as DiscordRpcResponse;
const gameId = json.linked_games?.[0]?.id ?? json.id ?? applicationId;
return {
id: json.id ?? applicationId,
name: json.name ?? "",
icon: json.icon ?? null,
iconUrl: discordAppIconUrl(applicationId, json.icon ?? null),
coverImage: json.cover_image ?? null,
coverUrl: json.cover_image
? `https://cdn.discordapp.com/app-icons/${applicationId}/${json.cover_image}.png?size=512`
: null,
gameId,
gameUrl: discordGameUrl(gameId),
};
} catch {
return null;
}
}
export interface LanyardData {
discord_user: {
id: string;