Use published Discord collectibles catalog.
All checks were successful
All checks were successful
Fetch and persist the public release catalog locally while retiring client-side Discord token handling.
This commit is contained in:
2
cinny
2
cinny
Submodule cinny updated: d59945eb4c...87c9f59826
@@ -1,6 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Discord shop collectibles: fetch catalog via API, download assets on demand from CDN.
|
* Discord shop collectibles: fetch a published catalog and download selected assets from Discord CDN.
|
||||||
* Token stays in the main process (electron-store or DISCORD_TOKEN env).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ITEM_TYPE_FOLDER = {
|
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 CDN_HOST_RE = /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/i;
|
||||||
const DEFAULT_USER_AGENT =
|
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';
|
'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));
|
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) {
|
function isCdnUrl(value) {
|
||||||
if (typeof value !== 'string' || !value.startsWith('http')) return false;
|
if (typeof value !== 'string' || !value.startsWith('http')) return false;
|
||||||
try {
|
try {
|
||||||
@@ -252,63 +249,43 @@ function extractCollectibleItems(categories) {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiRequest(path, authorization, { retries = 4 } = {}) {
|
function isPublishedCatalog(document) {
|
||||||
if (!authorization) {
|
return (
|
||||||
throw new Error('No Discord token configured. Add one in Profile settings.');
|
document &&
|
||||||
}
|
document.schema_version === 1 &&
|
||||||
|
typeof document.type === 'string' &&
|
||||||
const url = `https://discord.com/api/v10${path}`;
|
Array.isArray(document.items) &&
|
||||||
const headers = {
|
document.items.every(
|
||||||
Authorization: authorization,
|
(item) =>
|
||||||
'User-Agent': DEFAULT_USER_AGENT,
|
item &&
|
||||||
Accept: 'application/json',
|
typeof item.id === 'string' &&
|
||||||
};
|
typeof item.skuId === 'string' &&
|
||||||
|
typeof item.type === 'string' &&
|
||||||
for (let attempt = 0; ; attempt++) {
|
Array.isArray(item.assets)
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCatalog(authorization) {
|
async function fetchPublishedCatalog() {
|
||||||
const params = new URLSearchParams({
|
const documents = await Promise.all(
|
||||||
country_code: 'US',
|
PUBLISHED_CATALOG_URLS.map(async (url) => {
|
||||||
include_bundles: 'true',
|
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
include_nameplates_on_mobile: 'true',
|
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 items = documents.flatMap((document) => document.items);
|
||||||
const categories = Array.isArray(data) ? data : data?.categories ?? [];
|
if (items.length === 0) {
|
||||||
return { categories, items: extractCollectibleItems(categories) };
|
throw new Error('Published collectibles catalog is empty.');
|
||||||
|
}
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadAsset(url, { retries = 4 } = {}) {
|
async function downloadAsset(url, { retries = 4 } = {}) {
|
||||||
@@ -344,55 +321,37 @@ async function downloadAsset(url, { retries = 4 } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDiscordCollectiblesService(store) {
|
function createDiscordCollectiblesService(store) {
|
||||||
let catalogCache = null;
|
let catalogCache = store.get('discordCollectiblesCatalog') || null;
|
||||||
let catalogCacheAt = 0;
|
if (
|
||||||
const CATALOG_TTL_MS = 30 * 60 * 1000;
|
!catalogCache ||
|
||||||
|
!Array.isArray(catalogCache.items) ||
|
||||||
function getToken() {
|
typeof catalogCache.fetchedAt !== 'string'
|
||||||
const fromStore = store.get('discordCollectiblesToken');
|
) {
|
||||||
if (fromStore) return String(fromStore);
|
catalogCache = null;
|
||||||
if (process.env.DISCORD_TOKEN) return String(process.env.DISCORD_TOKEN);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
let catalogCacheAt = catalogCache?.fetchedAt ? Date.parse(catalogCache.fetchedAt) : 0;
|
||||||
function setToken(token) {
|
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
if (!token || !String(token).trim()) {
|
|
||||||
store.delete('discordCollectiblesToken');
|
store.delete('discordCollectiblesToken');
|
||||||
catalogCache = null;
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
store.set('discordCollectiblesToken', String(token).trim());
|
|
||||||
catalogCache = null;
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearToken() {
|
|
||||||
store.delete('discordCollectiblesToken');
|
|
||||||
catalogCache = null;
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasToken() {
|
|
||||||
return Boolean(getToken());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCatalog({ force = false } = {}) {
|
async function getCatalog({ force = false } = {}) {
|
||||||
const authorization = toAuthHeader(getToken());
|
|
||||||
if (!authorization) {
|
|
||||||
return { success: false, error: 'No Discord token configured.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!force && catalogCache && now - catalogCacheAt < CATALOG_TTL_MS) {
|
if (!force && catalogCache && now - catalogCacheAt < CATALOG_TTL_MS) {
|
||||||
return { success: true, data: catalogCache };
|
return { success: true, data: catalogCache };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { items } = await fetchCatalog(authorization);
|
const items = await fetchPublishedCatalog();
|
||||||
catalogCache = { items, fetchedAt: new Date().toISOString() };
|
catalogCache = { items, fetchedAt: new Date().toISOString() };
|
||||||
catalogCacheAt = now;
|
catalogCacheAt = now;
|
||||||
|
store.set('discordCollectiblesCatalog', catalogCache);
|
||||||
return { success: true, data: catalogCache };
|
return { success: true, data: catalogCache };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (catalogCache) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { ...catalogCache, stale: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,10 +372,6 @@ function createDiscordCollectiblesService(store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken,
|
|
||||||
setToken,
|
|
||||||
clearToken,
|
|
||||||
hasToken,
|
|
||||||
getCatalog,
|
getCatalog,
|
||||||
downloadAssets,
|
downloadAssets,
|
||||||
ITEM_TYPE_FOLDER,
|
ITEM_TYPE_FOLDER,
|
||||||
|
|||||||
@@ -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 } = {}) => {
|
ipcMain.handle('discord-collectibles:fetch-catalog', async (event, { force } = {}) => {
|
||||||
try {
|
try {
|
||||||
return await discordCollectibles.getCatalog({ force: Boolean(force) });
|
return await discordCollectibles.getCatalog({ force: Boolean(force) });
|
||||||
|
|||||||
@@ -195,11 +195,8 @@ contextBridge.exposeInMainWorld('electron', {
|
|||||||
repair: () => ipcRenderer.invoke('protocol:repair')
|
repair: () => ipcRenderer.invoke('protocol:repair')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Discord shop collectibles (catalog + on-demand CDN download)
|
// Discord shop collectibles (published catalog + on-demand CDN download)
|
||||||
discordCollectibles: {
|
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 }),
|
fetchCatalog: (force = false) => ipcRenderer.invoke('discord-collectibles:fetch-catalog', { force }),
|
||||||
downloadAssets: (assets) => ipcRenderer.invoke('discord-collectibles:download-assets', { assets }),
|
downloadAssets: (assets) => ipcRenderer.invoke('discord-collectibles:download-assets', { assets }),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user