Skip to content

🤖 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 :

typescript
// 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 :

typescript
// 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 :

typescript
// 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 :

typescript
// 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 :

typescript
// 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 :

typescript
// 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 :

typescript
// 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 :

typescript
// 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 :

typescript
// 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 :

typescript
// 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 :

typescript
// 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

bash
# 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=production

Configuration de production

typescript
// 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

json
// 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

typescript
// 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

typescript
// 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 :

typescript
// 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

typescript
// 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


Le bot Discord LIPAIX évolue constamment. N'hésitez pas à proposer des améliorations ! 🚀

Released under the MIT License.