feat: add PDF viewer command with page navigation and multi-project support improvements

This commit is contained in:
ɧσℓσ
2025-12-10 19:12:58 +01:00
parent 8c454bb0dc
commit e223be9452
10 changed files with 1255 additions and 38 deletions

View File

@@ -0,0 +1,227 @@
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.sessions = new Map(); // messageId -> { pdfPath, totalPages, ownerId, createdAt, downloadUrl, isPublic }
pdfjsLib.GlobalWorkerOptions.workerSrc = require('pdfjs-dist/build/pdf.worker.min.js');
}
/**
* 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
});
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 lauteur 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 tmpPath = path.join(os.tmpdir(), `pdf-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`);
fs.writeFileSync(tmpPath, response.data);
return tmpPath;
}
/**
* 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';
}
}
}
module.exports = PdfHandler;