diff --git a/cinny b/cinny index d59945e..87c9f59 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit d59945eb4ceb56b7a6a3e37ab00b05d1327b3238 +Subproject commit 87c9f598264d20ab19427fbb969253ebfdf26815 diff --git a/electron/discord-collectibles.js b/electron/discord-collectibles.js index 56e613f..469d953 100644 --- a/electron/discord-collectibles.js +++ b/electron/discord-collectibles.js @@ -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, diff --git a/electron/main.js b/electron/main.js index afa7606..64b4cdc 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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) }); diff --git a/electron/preload.js b/electron/preload.js index a1ada2b..32e2cd4 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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 }), },