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

@@ -7,6 +7,7 @@ GITLAB_URL=https://gitlab.example.com
GITLAB_TOKEN=your_gitlab_private_token_here
# Configuration Bot
# ALLOW_NO_PROJECTS=true
SYNC_INTERVAL_MINUTES=5
LOG_LEVEL=info

View File

@@ -7,8 +7,10 @@ WORKDIR /app
# Copie les fichiers de dépendances
COPY package*.json ./
# Installe les dépendances
RUN npm ci --only=production && npm cache clean --force
# Dépendances natives pour sharp/canvas/pdfjs sur Alpine
RUN apk add --no-cache python3 make g++ cairo-dev pango-dev libjpeg-turbo-dev giflib-dev freetype-dev pkgconfig \
&& npm ci --only=production \
&& npm cache clean --force
# Copie le code source
COPY src/ ./src/
@@ -37,4 +39,4 @@ CMD ["node", "src/index.js"]
# Labels pour la documentation
LABEL maintainer="paulinsegura79@gmail.com"
LABEL description="Bot Discord pour synchroniser les issue boards GitLab"
LABEL version="1.0.0"
LABEL version="1.1.0"

938
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "gitlab-issue-discord-bot",
"version": "1.0.0",
"version": "1.1.0",
"description": "Bot Discord pour synchroniser les issue boards GitLab avec Discord",
"main": "src/index.js",
"scripts": {
@@ -10,19 +10,28 @@
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix"
},
"keywords": ["discord", "gitlab", "kanban", "bot", "issue-board"],
"keywords": [
"discord",
"gitlab",
"kanban",
"bot",
"issue-board"
],
"author": "Holo795",
"license": "MIT",
"dependencies": {
"discord.js": "^14.14.1",
"axios": "^1.6.2",
"canvas": "^2.11.2",
"discord.js": "^14.14.1",
"dotenv": "^16.3.1",
"node-cron": "^3.0.3"
"node-cron": "^3.0.3",
"pdfjs-dist": "^3.11.174",
"sharp": "^0.33.3"
},
"devDependencies": {
"nodemon": "^3.0.2",
"eslint": "^8.56.0",
"jest": "^29.7.0"
"jest": "^29.7.0",
"nodemon": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"

View File

@@ -2,6 +2,7 @@ const CommandDefinitions = require('./definitions/CommandDefinitions');
const IssueInfoHandler = require('./handlers/IssueInfoHandler');
const SyncHandler = require('./handlers/SyncHandler');
const StatsHandler = require('./handlers/StatsHandler');
const PdfHandler = require('./handlers/PdfHandler');
const AutocompleteService = require('./autocomplete/AutocompleteService');
/**
@@ -16,6 +17,7 @@ class Commands {
this.issueInfoHandler = new IssueInfoHandler(gitlabClient, syncService);
this.syncHandler = new SyncHandler(syncService);
this.statsHandler = new StatsHandler(syncService);
this.pdfHandler = new PdfHandler();
this.autocompleteService = new AutocompleteService(gitlabClient, syncService);
}
@@ -47,6 +49,20 @@ class Commands {
return await this.statsHandler.handle(interaction);
}
/**
* Gère la commande de visualisation PDF
*/
async handlePdf(interaction) {
return await this.pdfHandler.handle(interaction);
}
/**
* Gère les interactions bouton pour la navigation PDF
*/
async handlePdfButton(interaction) {
return await this.pdfHandler.handleButton(interaction);
}
/**
* Gère l'auto-complétion pour les commandes
*/

View File

@@ -11,7 +11,8 @@ class CommandDefinitions {
return [
this.getIssueInfoCommand(),
this.getSyncCommand(),
this.getBotStatsCommand()
this.getBotStatsCommand(),
this.getPdfCommand()
];
}
@@ -56,6 +57,27 @@ class CommandDefinitions {
.setName('bot-stats')
.setDescription('Affiche les statistiques du bot');
}
/**
* Commande pour visualiser un PDF page par page
*/
static getPdfCommand() {
return new SlashCommandBuilder()
.setName('pdf')
.setDescription('Visualise un PDF et navigue page par page')
.addAttachmentOption(option =>
option.setName('file')
.setDescription('PDF à afficher')
.setRequired(false))
.addStringOption(option =>
option.setName('url')
.setDescription('URL HTTP(S) vers un PDF')
.setRequired(false))
.addBooleanOption(option =>
option.setName('public')
.setDescription('Afficher pour tout le monde (sinon seulement pour toi)')
.setRequired(false));
}
}
module.exports = CommandDefinitions;

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;

View File

@@ -51,11 +51,18 @@ const config = {
}
// Aucune configuration trouvée
else {
throw new Error('Aucune configuration trouvée. Créez le fichier config/projects.json ou définissez les variables legacy (GITLAB_PROJECT_ID + DISCORD_CATEGORY_ID)');
if (process.env.ALLOW_NO_PROJECTS === 'true') {
console.log('Aucun projet configuré, mais ALLOW_NO_PROJECTS=true -> démarrage sans projets.');
this.projects = [];
} else {
throw new Error('Aucune configuration trouvée. Créez le fichier config/projects.json ou définissez les variables legacy (GITLAB_PROJECT_ID + DISCORD_CATEGORY_ID)');
}
}
// Validation des projets avant encoding
if (!this.projects || this.projects.length === 0) {
if ((!this.projects || this.projects.length === 0) && process.env.ALLOW_NO_PROJECTS === 'true') {
console.log('Avertissement: démarrage sans projet (ALLOW_NO_PROJECTS=true). Certaines fonctionnalités seront désactivées.');
} else if (!this.projects || this.projects.length === 0) {
throw new Error('Aucun projet configuré');
}
@@ -87,6 +94,9 @@ const config = {
// Validation des projets
if (this.projects.length === 0) {
if (process.env.ALLOW_NO_PROJECTS === 'true') {
return;
}
throw new Error('Aucun projet configuré. Définissez PROJECTS_CONFIG ou les variables legacy.');
}

View File

@@ -1,3 +1,4 @@
const { Client, GatewayIntentBits } = require('discord.js');
const config = require('./config/config');
const logger = require('./utils/logger');
const SyncService = require('./services/SyncService');
@@ -39,10 +40,23 @@ class KanbanBot {
// Démarrage du service de synchronisation
await this.syncService.initialize();
// Initialisation des commandes Discord (récupère le client du premier projet)
// Initialisation des commandes Discord
const firstProjectSync = Array.from(this.syncService.projectSyncs.values())[0];
if (firstProjectSync && firstProjectSync.discordService.client) {
await this.commandService.initialize(firstProjectSync.discordService.client);
} else if (process.env.ALLOW_NO_PROJECTS === 'true') {
logger.info('Aucun projet configuré : initialisation d\'un client Discord minimal pour les commandes.');
const commandOnlyClient = new Client({
intents: [GatewayIntentBits.Guilds]
});
await new Promise((resolve, reject) => {
commandOnlyClient.once('ready', () => resolve());
commandOnlyClient.on('error', reject);
commandOnlyClient.login(config.discord.token).catch(reject);
});
await this.commandService.initialize(commandOnlyClient);
}
this.isRunning = true;

View File

@@ -69,6 +69,8 @@ class CommandService {
await this.handleSlashCommand(interaction);
} else if (interaction.isAutocomplete()) {
await this.handleAutocomplete(interaction);
} else if (interaction.isButton()) {
await this.handleButton(interaction);
}
});
@@ -98,6 +100,10 @@ class CommandService {
await this.commands.handleBotStats(interaction);
break;
case 'pdf':
await this.commands.handlePdf(interaction);
break;
default:
await interaction.editReply({
content: '❌ Commande non reconnue'
@@ -155,6 +161,28 @@ class CommandService {
logger.error('Erreur lors de la suppression des commandes:', error);
}
}
/**
* Gère les interactions bouton
*/
async handleButton(interaction) {
try {
const customId = interaction.customId || '';
if (customId.startsWith('pdf-nav:')) {
await this.commands.handlePdfButton(interaction);
}
} catch (error) {
logger.error('Erreur lors du traitement du bouton:', error);
try {
await interaction.reply({
content: '❌ Erreur lors du traitement du bouton',
flags: require('discord.js').MessageFlags.Ephemeral
});
} catch (replyError) {
logger.error('Erreur lors de la réponse à l\'interaction bouton:', replyError);
}
}
}
}
module.exports = CommandService;