diff --git a/index.js b/index.js index d761846..75fc31e 100644 --- a/index.js +++ b/index.js @@ -52,6 +52,7 @@ client.once('clientReady', async () => { deploy(process.env.DISCORD_TOKEN); clean(client); + await update(client); }); client.on(Events.InteractionCreate, async interaction => { diff --git a/src/pterodactyl/displayer.js b/src/pterodactyl/displayer.js index 531b7b2..5ca8de0 100644 --- a/src/pterodactyl/displayer.js +++ b/src/pterodactyl/displayer.js @@ -1,9 +1,11 @@ -const { EmbedBuilder, ActionRowBuilder, StringSelectMenuBuilder } = require('discord.js'); +const { EmbedBuilder, ActionRowBuilder, StringSelectMenuBuilder, AttachmentBuilder } = require('discord.js'); const fs = require('node:fs'); const path = require('node:path'); const PINBOARD_SELECT_CUSTOM_ID = 'pinboard-image-select'; const selectedImageIndexByMessage = new Map(); +const preloadedImagesByUrl = new Map(); +let lastKnownStatus = null; const getCacheBustValue = () => { const bucketSeconds = Number.parseInt(process.env.PINBOARD_CACHE_BUST_SECONDS || '60', 10); @@ -95,12 +97,71 @@ const clampIndex = (index, length) => { return index; }; -const buildSelectedImageEmbed = (item, index, total) => ( - new EmbedBuilder() +const sanitizeAttachmentName = (name) => ( + (name || 'pinboard-image') + .toLowerCase() + .replace(/[^a-z0-9._-]/g, '-') + .replace(/-+/g, '-') + .slice(0, 64) +); + +const guessFileExtensionFromContentType = (contentType) => { + if (!contentType) return 'jpg'; + if (contentType.includes('png')) return 'png'; + if (contentType.includes('webp')) return 'webp'; + if (contentType.includes('gif')) return 'gif'; + if (contentType.includes('jpeg') || contentType.includes('jpg')) return 'jpg'; + return 'jpg'; +}; + +const preloadPinboardImages = async (items) => { + const uniqueUrls = [...new Set(items.map(item => item.url))]; + + await Promise.all(uniqueUrls.map(async (url, index) => { + try { + const response = await fetch(toCacheBustedUrl(url)); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const contentType = response.headers.get('content-type') || ''; + const ext = guessFileExtensionFromContentType(contentType.toLowerCase()); + const attachmentName = `${sanitizeAttachmentName(`pinboard-${index + 1}`)}.${ext}`; + + preloadedImagesByUrl.set(url, { + buffer, + attachmentName, + loadedAt: Date.now() + }); + } catch (error) { + // Keep previous successful cache entry if refresh fails. + if (!preloadedImagesByUrl.has(url)) { + preloadedImagesByUrl.set(url, null); + } + console.log(`⚠️ Preload image impossible (${url}): ${error.message}`); + } + })); + + const knownUrls = new Set(uniqueUrls); + for (const cachedUrl of preloadedImagesByUrl.keys()) { + if (!knownUrls.has(cachedUrl)) { + preloadedImagesByUrl.delete(cachedUrl); + } + } +}; + +const buildSelectedImageEmbed = (item, imageReference) => { + const imageUrl = imageReference?.attachmentName + ? `attachment://${imageReference.attachmentName}` + : toCacheBustedUrl(item.url); + + return new EmbedBuilder() .setColor('#f59e0b') .setTitle(item.name) - .setImage(toCacheBustedUrl(item.url)) -); + .setImage(imageUrl); +}; const buildImageSelectorRow = (items, selectedIndex) => { const options = items.slice(0, 25).map((item, index) => ({ @@ -159,24 +220,31 @@ const buildPanelEmbed = (status) => { return message; }; -const buildPanelPayload = (status, messageId) => { +const buildPanelPayload = (status, messageId, pinboardItems = null) => { const panelEmbed = buildPanelEmbed(status); - const pinboardItems = parsePinboardImageItems().slice(0, 25); + const items = (pinboardItems || parsePinboardImageItems()).slice(0, 25); - if (pinboardItems.length === 0) { + if (items.length === 0) { return { content: '', embeds: [panelEmbed], components: [] }; } - const currentIndex = clampIndex(selectedImageIndexByMessage.get(messageId) ?? 0, pinboardItems.length); + const currentIndex = clampIndex(selectedImageIndexByMessage.get(messageId) ?? 0, items.length); selectedImageIndexByMessage.set(messageId, currentIndex); - const selectedImageEmbed = buildSelectedImageEmbed(pinboardItems[currentIndex], currentIndex, pinboardItems.length); - const selectorRow = buildImageSelectorRow(pinboardItems, currentIndex); + const selectedItem = items[currentIndex]; + const cachedImage = preloadedImagesByUrl.get(selectedItem.url); + const selectedImageEmbed = buildSelectedImageEmbed(selectedItem, cachedImage); + const selectorRow = buildImageSelectorRow(items, currentIndex); + + const files = cachedImage?.buffer + ? [new AttachmentBuilder(cachedImage.buffer, { name: cachedImage.attachmentName })] + : []; return { content: '', embeds: [panelEmbed, selectedImageEmbed], - components: [selectorRow] + components: [selectorRow], + files }; }; @@ -215,10 +283,15 @@ const update = async (client) => { return; } - const status = await getMinecraftStatus(); + const pinboardItems = parsePinboardImageItems().slice(0, 25); + const [status] = await Promise.all([ + getMinecraftStatus(), + preloadPinboardImages(pinboardItems) + ]); + lastKnownStatus = status; const { message } = await resolvePanelMessage(client, channelId, messageId); - const payload = buildPanelPayload(status, message.id); + const payload = buildPanelPayload(status, message.id, pinboardItems); await message.edit(payload); @@ -245,8 +318,8 @@ const handlePinboardSelection = async (interaction) => { const selectedIndex = clampIndex(selectedRaw, pinboardItems.length); selectedImageIndexByMessage.set(interaction.message.id, selectedIndex); - const status = await getMinecraftStatus(); - const payload = buildPanelPayload(status, interaction.message.id); + const status = lastKnownStatus || await getMinecraftStatus(); + const payload = buildPanelPayload(status, interaction.message.id, pinboardItems); await interaction.update(payload); return true; } catch (error) {