Use published Discord collectibles catalog.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Successful in 6m54s
Build / build-linux (push) Successful in 3m5s
Build / finalize-release (push) Successful in 4s

Fetch and persist the public release catalog locally while retiring client-side Discord token handling.
This commit is contained in:
2026-08-24 19:29:53 +10:00
parent ce7b2104ad
commit 9d7ae1d63b
4 changed files with 59 additions and 127 deletions

View File

@@ -1,6 +1,5 @@
/**
* Discord shop collectibles: fetch catalog via API, download assets on demand from CDN.
* Token stays in the main process (electron-store or DISCORD_TOKEN env).
* Discord shop collectibles: fetch a published catalog and download selected assets from Discord CDN.
*/
const ITEM_TYPE_FOLDER = {
@@ -19,16 +18,14 @@ const CDN_BASE = 'https://cdn.discordapp.com';
const CDN_HOST_RE = /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/i;
const DEFAULT_USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) discord/1.0 Chrome/120 Electron/28 Safari/537.36';
const PUBLISHED_CATALOG_URLS = [
'https://github.com/litruv/discord-collectibles/releases/download/latest/profileeffects.json',
'https://github.com/litruv/discord-collectibles/releases/download/latest/nameplate.json',
'https://github.com/litruv/discord-collectibles/releases/download/latest/avatardecorations.json',
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function toAuthHeader(token) {
if (!token) return null;
const t = String(token).trim();
if (/^bot\s+/i.test(t)) return `Bot ${t.replace(/^bot\s+/i, '')}`;
return t;
}
function isCdnUrl(value) {
if (typeof value !== 'string' || !value.startsWith('http')) return false;
try {
@@ -252,63 +249,43 @@ function extractCollectibleItems(categories) {
return items;
}
async function apiRequest(path, authorization, { retries = 4 } = {}) {
if (!authorization) {
throw new Error('No Discord token configured. Add one in Profile settings.');
}
const url = `https://discord.com/api/v10${path}`;
const headers = {
Authorization: authorization,
'User-Agent': DEFAULT_USER_AGENT,
Accept: 'application/json',
};
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetch(url, { headers });
} catch (err) {
if (attempt >= retries) throw err;
await sleep(500 * 2 ** attempt);
continue;
}
if (res.status === 429) {
const retryAfter = Number(res.headers.get('retry-after')) || 1;
await sleep((retryAfter + 0.5) * 1000);
continue;
}
if (res.status === 401 || res.status === 403) {
throw new Error(
`Discord returned ${res.status}. Your token is missing, invalid, or expired.`
);
}
if (!res.ok) {
if (attempt >= retries) {
const body = await res.text().catch(() => '');
throw new Error(`Discord API error: ${res.status} ${res.statusText} ${body}`);
}
await sleep(500 * 2 ** attempt);
continue;
}
return res.json();
}
function isPublishedCatalog(document) {
return (
document &&
document.schema_version === 1 &&
typeof document.type === 'string' &&
Array.isArray(document.items) &&
document.items.every(
(item) =>
item &&
typeof item.id === 'string' &&
typeof item.skuId === 'string' &&
typeof item.type === 'string' &&
Array.isArray(item.assets)
)
);
}
async function fetchCatalog(authorization) {
const params = new URLSearchParams({
country_code: 'US',
include_bundles: 'true',
include_nameplates_on_mobile: 'true',
});
async function fetchPublishedCatalog() {
const documents = await Promise.all(
PUBLISHED_CATALOG_URLS.map(async (url) => {
const response = await fetch(url, { headers: { Accept: 'application/json' } });
if (!response.ok) {
throw new Error(`Published collectibles catalog request failed with HTTP ${response.status}.`);
}
const document = await response.json();
if (!isPublishedCatalog(document)) {
throw new Error('Published collectibles catalog has an unsupported schema.');
}
return document;
})
);
const data = await apiRequest(`/collectibles-categories?${params.toString()}`, authorization);
const categories = Array.isArray(data) ? data : data?.categories ?? [];
return { categories, items: extractCollectibleItems(categories) };
const items = documents.flatMap((document) => document.items);
if (items.length === 0) {
throw new Error('Published collectibles catalog is empty.');
}
return items;
}
async function downloadAsset(url, { retries = 4 } = {}) {
@@ -344,55 +321,37 @@ async function downloadAsset(url, { retries = 4 } = {}) {
}
function createDiscordCollectiblesService(store) {
let catalogCache = null;
let catalogCacheAt = 0;
const CATALOG_TTL_MS = 30 * 60 * 1000;
function getToken() {
const fromStore = store.get('discordCollectiblesToken');
if (fromStore) return String(fromStore);
if (process.env.DISCORD_TOKEN) return String(process.env.DISCORD_TOKEN);
return null;
}
function setToken(token) {
if (!token || !String(token).trim()) {
store.delete('discordCollectiblesToken');
catalogCache = null;
return { success: true };
}
store.set('discordCollectiblesToken', String(token).trim());
let catalogCache = store.get('discordCollectiblesCatalog') || null;
if (
!catalogCache ||
!Array.isArray(catalogCache.items) ||
typeof catalogCache.fetchedAt !== 'string'
) {
catalogCache = null;
return { success: true };
}
function clearToken() {
store.delete('discordCollectiblesToken');
catalogCache = null;
return { success: true };
}
function hasToken() {
return Boolean(getToken());
}
let catalogCacheAt = catalogCache?.fetchedAt ? Date.parse(catalogCache.fetchedAt) : 0;
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000;
store.delete('discordCollectiblesToken');
async function getCatalog({ force = false } = {}) {
const authorization = toAuthHeader(getToken());
if (!authorization) {
return { success: false, error: 'No Discord token configured.' };
}
const now = Date.now();
if (!force && catalogCache && now - catalogCacheAt < CATALOG_TTL_MS) {
return { success: true, data: catalogCache };
}
try {
const { items } = await fetchCatalog(authorization);
const items = await fetchPublishedCatalog();
catalogCache = { items, fetchedAt: new Date().toISOString() };
catalogCacheAt = now;
store.set('discordCollectiblesCatalog', catalogCache);
return { success: true, data: catalogCache };
} catch (err) {
if (catalogCache) {
return {
success: true,
data: { ...catalogCache, stale: true },
};
}
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
@@ -413,10 +372,6 @@ function createDiscordCollectiblesService(store) {
}
return {
getToken,
setToken,
clearToken,
hasToken,
getCatalog,
downloadAssets,
ITEM_TYPE_FOLDER,

View File

@@ -1903,26 +1903,6 @@ ipcMain.handle('plugin:read-code', async (event, pluginId) => {
}
});
ipcMain.handle('discord-collectibles:has-token', () => {
return { success: true, data: discordCollectibles.hasToken() };
});
ipcMain.handle('discord-collectibles:set-token', (event, { token }) => {
try {
return discordCollectibles.setToken(token);
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('discord-collectibles:clear-token', () => {
try {
return discordCollectibles.clearToken();
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('discord-collectibles:fetch-catalog', async (event, { force } = {}) => {
try {
return await discordCollectibles.getCatalog({ force: Boolean(force) });

View File

@@ -195,11 +195,8 @@ contextBridge.exposeInMainWorld('electron', {
repair: () => ipcRenderer.invoke('protocol:repair')
},
// Discord shop collectibles (catalog + on-demand CDN download)
// Discord shop collectibles (published catalog + on-demand CDN download)
discordCollectibles: {
hasToken: () => ipcRenderer.invoke('discord-collectibles:has-token'),
setToken: (token) => ipcRenderer.invoke('discord-collectibles:set-token', { token }),
clearToken: () => ipcRenderer.invoke('discord-collectibles:clear-token'),
fetchCatalog: (force = false) => ipcRenderer.invoke('discord-collectibles:fetch-catalog', { force }),
downloadAssets: (assets) => ipcRenderer.invoke('discord-collectibles:download-assets', { assets }),
},