275 lines
8.9 KiB
JavaScript
275 lines
8.9 KiB
JavaScript
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;
|