const fs = require('fs'); const os = require('os'); const path = require('path'); const axios = require('axios'); const sharp = require('sharp'); const { createCanvas } = require('canvas'); const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder } = require('discord.js'); const pdfjsLib = require('pdfjs-dist/legacy/build/pdf.js'); const logger = require('../../utils/logger'); /** * Handler pour la commande /pdf */ class PdfHandler { constructor() { this.storageDir = process.env.PDF_STORAGE_DIR || path.join(process.cwd(), 'data', 'pdfs'); this.sessionFile = path.join(this.storageDir, 'sessions.json'); this.sessions = new Map(); // messageId -> { pdfPath, totalPages, ownerId, createdAt, downloadUrl, isPublic } pdfjsLib.GlobalWorkerOptions.workerSrc = require('pdfjs-dist/build/pdf.worker.min.js'); this.ensureStorageDir(); this.loadSessions(); } /** * Commande principale */ async handle(interaction) { try { const attachment = interaction.options.getAttachment('file'); const urlOption = interaction.options.getString('url'); const isPublic = interaction.options.getBoolean('public') === true; const sourceUrl = attachment ? attachment.url : urlOption; if (!attachment && !urlOption) { await interaction.editReply({ content: '❌ Fournis un PDF (fichier) ou une URL https:// vers un PDF.' }); return; } if (urlOption && !urlOption.toLowerCase().startsWith('http')) { await interaction.editReply({ content: '❌ URL invalide. Utilise http(s)://...' }); return; } if (attachment && attachment.contentType && !attachment.contentType.includes('pdf')) { await interaction.editReply({ content: '❌ Le fichier doit être un PDF.' }); return; } if (attachment && attachment.size > 15 * 1024 * 1024) { await interaction.editReply({ content: '❌ PDF trop lourd (max 15 Mo).' }); return; } const tmpPath = await this.downloadPdf(sourceUrl); const { pdf, totalPages } = await this.loadPdf(tmpPath); if (totalPages > 100) { await interaction.editReply({ content: '❌ PDF trop long (max 100 pages).' }); fs.unlink(tmpPath, () => {}); return; } const pageBuffer = await this.renderPage(pdf, 1); const title = this.getPdfTitle(sourceUrl); const messageOptions = this.buildMessageOptions(pageBuffer, 1, totalPages, interaction.user.id, sourceUrl, title); // Visibilité du message (ephemeral si non public) if (!isPublic) { messageOptions.flags = require('discord.js').MessageFlags.Ephemeral; } const reply = await interaction.editReply(messageOptions); // Sauvegarde de la session pour la navigation this.sessions.set(reply.id, { pdfPath: tmpPath, totalPages, ownerId: interaction.user.id, createdAt: Date.now(), downloadUrl: sourceUrl, isPublic }); this.saveSessions(); logger.info(`PDF affiché (${totalPages} pages) par ${interaction.user.tag}`); } catch (error) { logger.error('Erreur lors du traitement du PDF:', error); await interaction.editReply({ content: '❌ Erreur lors du traitement du PDF.' }); } } /** * Gestion des boutons */ async handleButton(interaction) { const parts = (interaction.customId || '').split(':'); // pdf-nav:{userId}:{page}:{total} if (parts.length !== 4) return; const [, ownerId, pageStr, totalStr] = parts; const requestedPage = parseInt(pageStr, 10); const totalPages = parseInt(totalStr, 10); const session = this.sessions.get(interaction.message.id); if (!session) { await interaction.reply({ content: '❌ Session expirée ou introuvable.', flags: require('discord.js').MessageFlags.Ephemeral }); return; } if (!session.isPublic && interaction.user.id !== ownerId) { await interaction.reply({ content: '❌ Seul l’auteur de la commande peut naviguer.', flags: require('discord.js').MessageFlags.Ephemeral }); return; } const clampedPage = Math.min(Math.max(1, requestedPage), totalPages); if (clampedPage === requestedPage && interaction.isRepliable()) { await interaction.deferUpdate(); } try { const { pdf } = await this.loadPdf(session.pdfPath); const pageBuffer = await this.renderPage(pdf, clampedPage); const title = this.getPdfTitle(session.downloadUrl); const messageOptions = this.buildMessageOptions(pageBuffer, clampedPage, totalPages, ownerId, session.downloadUrl, title); await interaction.editReply(messageOptions); } catch (error) { logger.error('Erreur lors de la navigation PDF:', error); await interaction.editReply({ content: '❌ Erreur lors du rendu de la page.' }); } } /** * Télécharge le PDF dans un fichier temporaire */ async downloadPdf(url) { const response = await axios.get(url, { responseType: 'arraybuffer' }); const fileName = `pdf-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`; const targetPath = path.join(this.storageDir, fileName); fs.writeFileSync(targetPath, response.data); return targetPath; } /** * Charge le PDF avec pdfjs */ async loadPdf(pdfPath) { const data = new Uint8Array(fs.readFileSync(pdfPath)); const loadingTask = pdfjsLib.getDocument({ data }); const pdf = await loadingTask.promise; return { pdf, totalPages: pdf.numPages }; } /** * Rendu d'une page en buffer PNG */ async renderPage(pdf, pageNumber) { const page = await pdf.getPage(pageNumber); const viewport = page.getViewport({ scale: 1.5 }); // Ajuster si besoin const canvas = createCanvas(viewport.width, viewport.height); const context = canvas.getContext('2d'); await page.render({ canvasContext: context, viewport, }).promise; const buffer = canvas.toBuffer('image/png'); // Recompression légère pour réduire la taille return sharp(buffer).png({ quality: 90 }).toBuffer(); } /** * Construit le message Discord avec image + boutons */ buildMessageOptions(pageBuffer, pageIndex, totalPages, ownerId, downloadUrl, title) { const file = new AttachmentBuilder(pageBuffer, { name: `page-${pageIndex}.png` }); const embed = new EmbedBuilder() .setTitle(title || 'Visualisation PDF') .setDescription(`Page ${pageIndex} / ${totalPages}`) .setImage(`attachment://page-${pageIndex}.png`) .setColor(0x5865F2); const prevDisabled = pageIndex <= 1; const nextDisabled = pageIndex >= totalPages; const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`pdf-nav:${ownerId}:${pageIndex - 1}:${totalPages}`) .setLabel('◀️ Précédent') .setStyle(ButtonStyle.Primary) .setDisabled(prevDisabled), new ButtonBuilder() .setCustomId(`pdf-nav:${ownerId}:${pageIndex + 1}:${totalPages}`) .setLabel('Suivant ▶️') .setStyle(ButtonStyle.Primary) .setDisabled(nextDisabled), new ButtonBuilder() .setLabel('Télécharger PDF') .setStyle(ButtonStyle.Link) .setURL(downloadUrl || 'https://') ); return { embeds: [embed], files: [file], components: [row] }; } /** * Déduit un titre à partir de l'URL du PDF */ getPdfTitle(downloadUrl) { try { if (!downloadUrl) return 'Visualisation PDF'; const url = new URL(downloadUrl); const pathname = url.pathname || ''; const name = pathname.split('/').filter(Boolean).pop(); if (name) return name; return 'Visualisation PDF'; } catch (e) { return 'Visualisation PDF'; } } /** * Persistance des sessions pour survivre aux redémarrages */ ensureStorageDir() { fs.mkdirSync(this.storageDir, { recursive: true }); } loadSessions() { try { if (!fs.existsSync(this.sessionFile)) return; const raw = fs.readFileSync(this.sessionFile, 'utf8'); const data = JSON.parse(raw); data.forEach(entry => { if (entry.pdfPath && fs.existsSync(entry.pdfPath)) { this.sessions.set(entry.messageId, { pdfPath: entry.pdfPath, totalPages: entry.totalPages, ownerId: entry.ownerId, createdAt: entry.createdAt, downloadUrl: entry.downloadUrl, isPublic: entry.isPublic }); } }); } catch (e) { logger.error('Erreur lors du chargement des sessions PDF persistées:', e); } } saveSessions() { try { const data = []; for (const [messageId, session] of this.sessions.entries()) { data.push({ messageId, ...session }); } fs.writeFileSync(this.sessionFile, JSON.stringify(data, null, 2), 'utf8'); } catch (e) { logger.error('Erreur lors de la sauvegarde des sessions PDF:', e); } } } module.exports = PdfHandler;