feat: add PDF viewer command with page navigation and multi-project support improvements
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
938
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -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"
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
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;
|
||||
@@ -51,11 +51,18 @@ const config = {
|
||||
}
|
||||
// Aucune configuration trouvée
|
||||
else {
|
||||
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.');
|
||||
}
|
||||
|
||||
|
||||
16
src/index.js
16
src/index.js
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user