feat: add PDF viewer command with page navigation and multi-project support improvements
This commit is contained in:
227
src/commands/handlers/PdfHandler.js
Normal file
227
src/commands/handlers/PdfHandler.js
Normal 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 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 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;
|
||||
Reference in New Issue
Block a user