🤖 Bot Discord
📋 Vue d'ensemble
Le bot Discord LIPAIX est un outil essentiel pour la gestion des équipes et des disponibilités. Il permet aux joueurs de s'inscrire aux événements, de vérifier leur disponibilité et de recevoir des notifications importantes.
🏗️ Architecture du bot
Stack technologique
- Discord.js v14 - API Discord officielle
- Node.js - Runtime JavaScript
- TypeScript - Typage statique
- Express - Health checks et monitoring
- tsup - Bundler pour la production
Structure du projet
discord-bot/
├── 📁 src/
│ ├── 📁 commands/ # Commandes slash
│ ├── 📁 events/ # Gestionnaires d'événements Discord
│ ├── 📁 services/ # Services métier
│ ├── 📁 core/ # Logique principale
│ └── 📁 health/ # Health checks
├── 📁 dist/ # Code compilé
├── 📄 package.json # Dépendances
└── 📄 tsconfig.json # Configuration TypeScript🎯 Fonctionnalités principales
Commandes slash
Le bot expose plusieurs commandes slash pour interagir avec les utilisateurs :
/dispos - Gestion des disponibilités
Description : Permet aux joueurs de gérer leur disponibilité pour les événements
Options :
event- Sélection de l'événement (requis)status- Statut de disponibilité (requis)notes- Notes additionnelles (optionnel)
Exemple d'utilisation :
// Commande /dispos
export const disposCommand: SlashCommandBuilder = {
name: 'dispos',
description: 'Gérer ma disponibilité pour un événement',
options: [
{
name: 'event',
description: 'Événement pour lequel indiquer la disponibilité',
type: ApplicationCommandOptionType.String,
required: true,
autocomplete: true
},
{
name: 'status',
description: 'Statut de disponibilité',
type: ApplicationCommandOptionType.String,
required: true,
choices: [
{ name: '✅ Disponible', value: 'available' },
{ name: '❌ Indisponible', value: 'unavailable' },
{ name: '🤔 Peut-être', value: 'maybe' }
]
},
{
name: 'notes',
description: 'Notes additionnelles (optionnel)',
type: ApplicationCommandOptionType.String,
required: false
}
]
};Gestionnaire de la commande :
// Gestionnaire de la commande /dispos
export async function execute(interaction: ChatInputCommandInteraction) {
try {
const eventId = interaction.options.getString('event', true);
const status = interaction.options.getString('status', true);
const notes = interaction.options.getString('notes');
// Vérifier que l'utilisateur est connecté
const user = await getUserFromDiscord(interaction.user.id);
if (!user) {
return interaction.reply({
content: '❌ Vous devez d\'abord vous connecter sur le site web LIPAIX.',
ephemeral: true
});
}
// Créer ou mettre à jour la disponibilité
const availability = await createOrUpdateAvailability({
playerId: user.id,
showId: eventId,
status,
notes
});
// Récupérer les détails de l'événement
const event = await getShowById(eventId);
const statusEmoji = {
'available': '✅',
'unavailable': '❌',
'maybe': '🤔'
};
const embed = new EmbedBuilder()
.setColor(status === 'available' ? '#10B981' : status === 'unavailable' ? '#EF4444' : '#F59E0B')
.setTitle('📅 Disponibilité mise à jour')
.setDescription(`**${event.title}**`)
.addFields(
{ name: 'Statut', value: `${statusEmoji[status]} ${status}`, inline: true },
{ name: 'Date', value: new Date(event.date).toLocaleDateString('fr-FR'), inline: true },
{ name: 'Joueur', value: user.name, inline: true }
);
if (notes) {
embed.addFields({ name: 'Notes', value: notes, inline: false });
}
await interaction.reply({ embeds: [embed] });
} catch (error) {
console.error('Erreur lors de la gestion de la disponibilité:', error);
await interaction.reply({
content: '❌ Une erreur est survenue lors de la mise à jour de votre disponibilité.',
ephemeral: true
});
}
}/selecs - Consultation des sélections
Description : Permet de consulter les disponibilités pour un événement
Options :
event- Sélection de l'événement (requis)
Exemple d'utilisation :
// Commande /selecs
export const selecsCommand: SlashCommandBuilder = {
name: 'selecs',
description: 'Consulter les disponibilités pour un événement',
options: [
{
name: 'event',
description: 'Événement pour lequel consulter les disponibilités',
type: ApplicationCommandOptionType.String,
required: true,
autocomplete: true
}
]
};Gestionnaire de la commande :
// Gestionnaire de la commande /selecs
export async function execute(interaction: ChatInputCommandInteraction) {
try {
const eventId = interaction.options.getString('event', true);
// Récupérer l'événement et les disponibilités
const [event, availabilities] = await Promise.all([
getShowById(eventId),
getAvailabilitiesForShow(eventId)
]);
if (!event) {
return interaction.reply({
content: '❌ Événement non trouvé.',
ephemeral: true
});
}
// Grouper les disponibilités par statut
const grouped = availabilities.reduce((acc, availability) => {
const status = availability.status;
if (!acc[status]) acc[status] = [];
acc[status].push(availability.player.name);
return acc;
}, {} as Record<string, string[]>);
const embed = new EmbedBuilder()
.setColor('#3B82F6')
.setTitle(`📊 Disponibilités - ${event.title}`)
.setDescription(`**Date :** ${new Date(event.date).toLocaleDateString('fr-FR')}`)
.addFields(
{
name: `✅ Disponibles (${grouped.available?.length || 0})`,
value: grouped.available?.join(', ') || 'Aucun',
inline: false
},
{
name: `❌ Indisponibles (${grouped.unavailable?.length || 0})`,
value: grouped.unavailable?.join(', ') || 'Aucun',
inline: false
},
{
name: `🤔 Peut-être (${grouped.maybe?.length || 0})`,
value: grouped.maybe?.join(', ') || 'Aucun',
inline: false
}
)
.setFooter({ text: `Total: ${availabilities.length} réponses` });
await interaction.reply({ embeds: [embed] });
} catch (error) {
console.error('Erreur lors de la consultation des disponibilités:', error);
await interaction.reply({
content: '❌ Une erreur est survenue lors de la consultation des disponibilités.',
ephemeral: true
});
}
}Autocomplétion
Les commandes utilisent l'autocomplétion pour faciliter la sélection des événements :
// Gestionnaire d'autocomplétion pour les événements
export async function autocomplete(interaction: AutocompleteInteraction) {
try {
const query = interaction.options.getFocused().toLowerCase();
// Récupérer les événements à venir
const upcomingShows = await getUpcomingShows();
// Filtrer et formater les résultats
const filtered = upcomingShows
.filter(show =>
show.title.toLowerCase().includes(query) ||
show.description.toLowerCase().includes(query)
)
.slice(0, 25) // Limiter à 25 résultats
.map(show => ({
name: `${show.title} - ${new Date(show.date).toLocaleDateString('fr-FR')}`,
value: show.id
}));
await interaction.respond(filtered);
} catch (error) {
console.error('Erreur lors de l\'autocomplétion:', error);
await interaction.respond([]);
}
}🔌 Intégration avec le backend
Service d'intégration
Le bot communique avec le backend LIPAIX via des appels API :
// Service d'intégration avec le backend
export class LIPAIXIntegrationService {
private baseUrl: string;
private apiKey: string;
constructor() {
this.baseUrl = process.env.LIPAIX_API_URL!;
this.apiKey = process.env.LIPAIX_API_KEY!;
}
// Récupérer un utilisateur par son ID Discord
async getUserFromDiscord(discordId: string): Promise<User | null> {
try {
const response = await fetch(`${this.baseUrl}/api/users/by-discord/${discordId}`, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Erreur lors de la récupération de l\'utilisateur:', error);
return null;
}
}
// Créer ou mettre à jour une disponibilité
async createOrUpdateAvailability(data: {
playerId: string;
showId: string;
status: string;
notes?: string;
}): Promise<Availability> {
try {
const response = await fetch(`${this.baseUrl}/api/availabilities`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Erreur lors de la création/mise à jour de la disponibilité:', error);
throw error;
}
}
// Récupérer un événement par ID
async getShowById(id: string): Promise<Show | null> {
try {
const response = await fetch(`${this.baseUrl}/api/shows/${id}`, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Erreur lors de la récupération de l\'événement:', error);
return null;
}
}
// Récupérer les événements à venir
async getUpcomingShows(): Promise<Show[]> {
try {
const response = await fetch(`${this.baseUrl}/api/shows?status=published&limit=50`, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
return data.data || [];
} catch (error) {
console.error('Erreur lors de la récupération des événements:', error);
return [];
}
}
// Récupérer les disponibilités pour un événement
async getAvailabilitiesForShow(showId: string): Promise<Availability[]> {
try {
const response = await fetch(`${this.baseUrl}/api/availabilities?show=${showId}`, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
return data.data || [];
} catch (error) {
console.error('Erreur lors de la récupération des disponibilités:', error);
return [];
}
}
}Gestion des erreurs
Le bot gère gracieusement les erreurs d'API :
// Gestionnaire d'erreurs centralisé
export class ErrorHandler {
static async handleCommandError(
interaction: ChatInputCommandInteraction | AutocompleteInteraction,
error: Error,
context: string
) {
console.error(`Erreur dans ${context}:`, error);
// Log détaillé pour le debugging
const errorLog = {
timestamp: new Date().toISOString(),
context,
userId: interaction.user.id,
guildId: interaction.guildId,
error: {
message: error.message,
stack: error.stack,
name: error.name
}
};
console.error('Log d\'erreur détaillé:', JSON.stringify(errorLog, null, 2));
// Réponse utilisateur appropriée
if (interaction.isAutocomplete()) {
await interaction.respond([]);
} else if (interaction.isChatInputCommand()) {
const isEphemeral = interaction.replied || interaction.deferred;
const errorMessage = {
content: '❌ Une erreur est survenue. Veuillez réessayer plus tard.',
ephemeral: true
};
if (isEphemeral) {
await interaction.followUp(errorMessage);
} else {
await interaction.reply(errorMessage);
}
}
}
}🔐 Sécurité et permissions
Vérification des permissions
Le bot vérifie les permissions des utilisateurs :
// Vérification des permissions Discord
export class PermissionChecker {
static async checkUserPermissions(
interaction: ChatInputCommandInteraction,
requiredPermissions: bigint[]
): Promise<boolean> {
const member = interaction.member;
if (!member || !('permissions' in member)) {
return false;
}
// Vérifier les permissions du membre
for (const permission of requiredPermissions) {
if (!member.permissions.has(permission)) {
return false;
}
}
return true;
}
static async checkRolePermissions(
interaction: ChatInputCommandInteraction,
requiredRoles: string[]
): Promise<boolean> {
const member = interaction.member;
if (!member || !('roles' in member)) {
return false;
}
// Vérifier si l'utilisateur a au moins un des rôles requis
const userRoles = member.roles.cache.map(role => role.name);
return requiredRoles.some(role => userRoles.includes(role));
}
}
// Utilisation dans les commandes
export async function execute(interaction: ChatInputCommandInteraction) {
// Vérifier les permissions
const hasPermission = await PermissionChecker.checkRolePermissions(
interaction,
['Admin', 'Modérateur']
);
if (!hasPermission) {
return interaction.reply({
content: '❌ Vous n\'avez pas les permissions nécessaires pour utiliser cette commande.',
ephemeral: true
});
}
// ... logique de la commande
}Validation des entrées
Le bot valide toutes les entrées utilisateur :
// Validateur d'entrées
export class InputValidator {
static validateEventId(eventId: string): boolean {
// Vérifier que l'ID est un UUID valide
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(eventId);
}
static validateStatus(status: string): boolean {
const validStatuses = ['available', 'unavailable', 'maybe'];
return validStatuses.includes(status);
}
static validateNotes(notes?: string): boolean {
if (!notes) return true;
return notes.length <= 500; // Limite de 500 caractères
}
static sanitizeInput(input: string): string {
// Supprimer les caractères dangereux
return input
.replace(/[<>]/g, '') // Supprimer < et >
.replace(/javascript:/gi, '') // Supprimer javascript:
.trim();
}
}📊 Monitoring et observabilité
Health checks
Le bot expose des endpoints de santé pour le monitoring :
// Serveur Express pour les health checks
import express from 'express';
import { Client } from 'discord.js';
export class HealthServer {
private app: express.Application;
private client: Client;
constructor(client: Client) {
this.app = express();
this.client = client;
this.setupRoutes();
}
private setupRoutes() {
// Endpoint de santé principal
this.app.get('/health', async (req, res) => {
try {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
discord: {
status: this.client.isReady() ? 'connected' : 'disconnected',
guilds: this.client.guilds.cache.size,
users: this.client.users.cache.size
},
memory: {
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024)
}
};
res.json(health);
} catch (error) {
res.status(500).json({
status: 'unhealthy',
error: error.message
});
}
});
// Endpoint de métriques
this.app.get('/metrics', async (req, res) => {
try {
const metrics = {
commands: {
total: this.client.application?.commands.cache.size || 0,
guild: this.client.guilds.cache.reduce((acc, guild) => {
return acc + (guild.commands.cache.size || 0);
}, 0)
},
interactions: {
total: 0, // À implémenter avec un compteur
today: 0
}
};
res.json(metrics);
} catch (error) {
res.status(500).json({
error: error.message
});
}
});
}
start(port: number = 3000) {
this.app.listen(port, () => {
console.log(`🚀 Serveur de santé démarré sur le port ${port}`);
});
}
}Logs structurés
Le bot utilise des logs structurés pour le debugging :
// Logger structuré
export class Logger {
static info(message: string, meta?: any) {
const logEntry = {
level: 'info',
timestamp: new Date().toISOString(),
message,
...meta
};
console.log(JSON.stringify(logEntry));
}
static error(message: string, error?: Error, meta?: any) {
const logEntry = {
level: 'error',
timestamp: new Date().toISOString(),
message,
error: error ? {
name: error.name,
message: error.message,
stack: error.stack
} : undefined,
...meta
};
console.error(JSON.stringify(logEntry));
}
static warn(message: string, meta?: any) {
const logEntry = {
level: 'warn',
timestamp: new Date().toISOString(),
message,
...meta
};
console.warn(JSON.stringify(logEntry));
}
static debug(message: string, meta?: any) {
if (process.env.NODE_ENV === 'development') {
const logEntry = {
level: 'debug',
timestamp: new Date().toISOString(),
message,
...meta
};
console.log(JSON.stringify(logEntry));
}
}
}
// Utilisation
Logger.info('Commande /dispos exécutée', {
userId: interaction.user.id,
guildId: interaction.guildId,
eventId: eventId,
status: status
});
Logger.error('Erreur lors de la création de la disponibilité', error, {
userId: interaction.user.id,
eventId: eventId
});🚀 Déploiement et configuration
Variables d'environnement
# Configuration Discord
DISCORD_TOKEN=votre-token-bot
DISCORD_CLIENT_ID=votre-client-id
DISCORD_GUILD_ID=votre-guild-id
# Configuration LIPAIX
LIPAIX_API_URL=https://api.lipaix.com
LIPAIX_API_KEY=votre-clé-api
# Configuration du serveur de santé
HEALTH_PORT=3000
NODE_ENV=productionConfiguration de production
// config/production.ts
export const productionConfig = {
discord: {
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMembers
],
partials: [
Partials.Channel,
Partials.Message,
Partials.User
]
},
health: {
port: parseInt(process.env.HEALTH_PORT || '3000'),
enabled: true
},
logging: {
level: 'info',
structured: true
},
rateLimit: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // Limite par IP
}
};Scripts de déploiement
// package.json
{
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"health": "tsx src/health.ts"
}
}🧪 Tests et qualité
Tests unitaires
// Tests des commandes
describe('DisposCommand', () => {
let mockInteraction: jest.Mocked<ChatInputCommandInteraction>;
let mockLIPAIXService: jest.Mocked<LIPAIXIntegrationService>;
beforeEach(() => {
mockInteraction = createMockInteraction();
mockLIPAIXService = createMockLIPAIXService();
});
it('should create availability successfully', async () => {
// Arrange
const eventId = 'valid-uuid';
const status = 'available';
const notes = 'Je peux arriver en avance';
mockInteraction.options.getString.mockReturnValueOnce(eventId);
mockInteraction.options.getString.mockReturnValueOnce(status);
mockInteraction.options.getString.mockReturnValueOnce(notes);
mockLIPAIXService.getUserFromDiscord.mockResolvedValue({
id: 'user-id',
name: 'John Doe',
discordId: 'discord-id'
});
mockLIPAIXService.createOrUpdateAvailability.mockResolvedValue({
id: 'availability-id',
playerId: 'user-id',
showId: eventId,
status,
notes
});
// Act
await execute(mockInteraction);
// Assert
expect(mockLIPAIXService.createOrUpdateAvailability).toHaveBeenCalledWith({
playerId: 'user-id',
showId: eventId,
status,
notes
});
expect(mockInteraction.reply).toHaveBeenCalledWith(
expect.objectContaining({
embeds: [expect.any(EmbedBuilder)]
})
);
});
});Tests d'intégration
// Tests d'intégration avec Discord
describe('Discord Integration', () => {
let client: Client;
beforeAll(async () => {
client = new Client({
intents: [GatewayIntentBits.Guilds]
});
await client.login(process.env.DISCORD_TOKEN);
});
afterAll(async () => {
await client.destroy();
});
it('should register slash commands', async () => {
// Vérifier que les commandes sont enregistrées
const commands = await client.application?.commands.fetch();
expect(commands?.size).toBeGreaterThan(0);
expect(commands?.has('dispos')).toBe(true);
expect(commands?.has('selecs')).toBe(true);
});
});🔄 Maintenance et évolution
Mise à jour des commandes
Le bot gère automatiquement les mises à jour des commandes :
// Gestionnaire de mise à jour des commandes
export class CommandManager {
static async deployCommands(client: Client) {
try {
const commands = [
disposCommand,
selecsCommand
];
if (process.env.NODE_ENV === 'production') {
// Déployer globalement
await client.application?.commands.set(commands);
console.log('✅ Commandes déployées globalement');
} else {
// Déployer sur le serveur de développement
const guild = client.guilds.cache.get(process.env.DISCORD_GUILD_ID!);
if (guild) {
await guild.commands.set(commands);
console.log('✅ Commandes déployées sur le serveur de développement');
}
}
} catch (error) {
console.error('❌ Erreur lors du déploiement des commandes:', error);
}
}
}Backup et restauration
// Script de backup des données
export async function backupBotData() {
try {
const backup = {
timestamp: new Date().toISOString(),
guilds: Array.from(client.guilds.cache.values()).map(guild => ({
id: guild.id,
name: guild.name,
memberCount: guild.memberCount
})),
commands: Array.from(client.application?.commands.cache.values() || []).map(cmd => ({
id: cmd.id,
name: cmd.name,
description: cmd.description
}))
};
// Sauvegarder dans un fichier
await fs.writeFile(
`backup-bot-${Date.now()}.json`,
JSON.stringify(backup, null, 2)
);
console.log('✅ Backup des données du bot créé');
} catch (error) {
console.error('❌ Erreur lors de la création du backup:', error);
}
}🎯 Bonnes pratiques
Do's
- ✅ Valider les entrées - Toujours valider les données utilisateur
- ✅ Gérer les erreurs - Logs détaillés et réponses appropriées
- ✅ Utiliser les permissions - Vérifier les droits des utilisateurs
- ✅ Tester les commandes - Tests unitaires et d'intégration
Don'ts
- ❌ Exposer les tokens - Garder les secrets en sécurité
- ❌ Ignorer les rate limits - Respecter les limites Discord
- ❌ Oublier la validation - Valider toutes les entrées
- ❌ Négliger le monitoring - Health checks et logs
🚀 Prochaines étapes
- Frontend - React et Next.js
- Backend - PayloadCMS et architecture
- Déploiement - Railway et workflows
- Architecture - Structure technique détaillée
Le bot Discord LIPAIX évolue constamment. N'hésitez pas à proposer des améliorations ! 🚀
