Tous les articles
6 min·

De l'idée au déploiement : construire un bot Discord en production avec Bun et GitHub Actions

Retour complet sur la création d'EchoSwift, un bot Discord open-source qui poste automatiquement les articles Swift.org, avec Bun, Docker et CI/CD.

Par Carolane Lefebvre
TypeScriptCI/CDGit
De l'idée au déploiement : construire un bot Discord en production avec Bun et GitHub Actions

L'origine du projet

Suivre les actualités Swift.org demandait de consulter régulièrement leur site. J'ai vu une opportunité : créer un bot Discord qui notifie automatiquement ma communauté des nouveaux articles.

Ce qui devait être un simple script s'est transformé en un véritable projet production-ready, avec CI/CD, Docker, monitoring et bonnes pratiques DevOps. Voici le parcours de développement et les décisions techniques associées.

En résumé : EchoSwift est un bot Discord open-source qui poste automatiquement les articles Swift.org, déployé avec GitHub Actions et Docker.

Le besoin

La communauté Swift évolue vite : annonces importantes, améliorations du langage, retours d'expérience sur le blog officiel. Comment rester à jour sans consulter le site plusieurs fois par jour ?

La réponse : un bot Discord qui vérifie automatiquement le feed Swift.org (midi et minuit), détecte les nouveaux articles, les poste dans un format soigné avec notification de rôle, et évite les doublons via un système de tracking.

Choix de la stack technique

Bun comme runtime

Au lieu de Node.js, j'ai opté pour Bun pour plusieurs raisons.

Performance : démarrage quasi instantané (contre ~500ms pour Node.js), consommation mémoire réduite (~30 MB contre ~50 MB), bundler et test runner intégrés.

bun install   # 10-20x plus rapide que npm
bun run dev   # Hot reload natif
bun test      # Test runner ultra-rapide

TypeScript natif : pas besoin de ts-node ou de compilation, Bun exécute directement le TypeScript.

Architecture applicative

src/
├── index.ts          # Point d'entrée, initialisation
├── config.ts         # Configuration et validation env
├── bot.ts            # Client Discord et event handlers
├── scheduler.ts      # Cron jobs et orchestration
├── feed/
│    ├── fetcher.ts   # Client HTTP avec retry
│    ├── parser.ts    # Parsing Atom/RSS
│    └── storage.ts   # Persistence JSON avec écritures atomiques
└── discord/
     ├── embeds.ts    # Création des Discord embeds
     └── poster.ts    # Posting avec rate limiting

Chaque module a une responsabilité unique, ce qui facilite les tests et la maintenance.

Défis techniques et solutions

Scheduler intelligent avec node-cron

Le défi : exécuter le bot à heures fixes dans le bon fuseau horaire.

import cron from 'node-cron';
 
const cronPattern = '0 12,0 * * *'; // Midi et minuit
const task = cron.schedule(
  cronPattern,
  () => checkFeedAndPost(client, config),
  { timezone: config.timezone } // 'Europe/Paris'
);

En développement, exécution immédiate pour faciliter les tests. En production, attente du cron pour éviter les posts intempestifs.

Déduplication et stockage atomique

Le défi : ne pas reposter les mêmes articles après un redémarrage.

async function savePostedArticles(articleIds: Set<string>): Promise<void> {
  const tempPath = STORAGE_PATH + '.tmp';
  const backupPath = STORAGE_PATH + '.backup';
 
  // 1. Backup de l'existant
  if (existsSync(STORAGE_PATH)) {
    await copyFile(STORAGE_PATH, backupPath);
  }
 
  // 2. Écriture dans un fichier temporaire
  await writeFile(tempPath, JSON.stringify(data, null, 2));
 
  // 3. Renommage atomique (opération système atomique)
  await rename(tempPath, STORAGE_PATH);
}

Un nettoyage automatique supprime les entrées de plus de 30 jours pour éviter une croissance sans fin.

Support des salons d'annonce Discord

Le bot crashait initialement avec les salons d'annonce (GuildAnnouncement). La solution : élargir la vérification des types de canaux.

// Avant (restrictif)
if (channel.type !== ChannelType.GuildText) {
  throw new Error('Not a text channel');
}
 
// Après (flexible)
if (channel.type !== ChannelType.GuildText &&
    channel.type !== ChannelType.GuildAnnouncement) {
  throw new Error('Not a text or announcement channel');
}

Rate limiting et posting robuste

Discord limite à environ 5 messages par 5 secondes. La solution : une attente de 2 secondes entre chaque post et une gestion d'erreur qui continue avec les articles suivants en cas de problème.

for (let i = 0; i < articles.length; i++) {
  try {
    await postArticle(client, channelId, roleId, articles[i]);
    if (i < articles.length - 1) {
      await new Promise(resolve => setTimeout(resolve, 2000));
    }
  } catch (error) {
    console.error(`Error posting article ${i + 1}:`, error);
  }
}

Embeds Discord soignés

Les articles sont affichés avec des embeds propres : description courte (200 caractères max), titre cliquable, couleur Swift orange, et timestamp de publication.

return new EmbedBuilder()
  .setTitle(article.title)
  .setURL(article.link)
  .setDescription(description)
  .setColor(0xF05138)
  .setFooter({ text: `swift.org - Published ${dateStr}` })
  .setTimestamp(article.publishedAt);

DevOps : du code au déploiement

Docker multi-stage build

L'objectif : une image légère et sécurisée.

# Stage 1: Build
FROM oven/bun:1-alpine AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY src ./src
COPY tsconfig.json ./
 
# Stage 2: Production
FROM oven/bun:1-alpine
WORKDIR /app
RUN addgroup -g 1001 -S botuser && \
    adduser -S -D -H -u 1001 -G botuser botuser
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/src ./src
COPY --from=builder /app/tsconfig.json ./
RUN mkdir -p /app/data && chown -R botuser:botuser /app/data
VOLUME ["/app/data"]
USER botuser
CMD ["bun", "run", "src/index.ts"]

L'image finale pèse environ 100 MB (contre 300+ MB avec Node.js), avec sécurité renforcée (utilisateur non-root) et build reproductible.

CI/CD avec GitHub Actions

Le workflow complet : tests automatiques avec Bun, build et push de l'image Docker vers GHCR, déploiement SSH automatique sur VPS, et redémarrage du container.

name: Build & Deploy
on:
  push:
    branches: [main]
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Bun
        uses: oven-sh/setup-bun@v1
      - name: Run tests
        run: bun test
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
 
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to VPS
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /opt/echoswift
            sudo docker compose pull
            sudo docker compose up -d --force-recreate

Benchmarks

  • Mémoire : Bun ~30 MB vs Node.js ~50 MB (40% d'économie)
  • Démarrage : Bun ~50ms vs Node.js ~500ms (10x plus rapide)
  • Cold start Docker : Bun ~800ms vs Node.js ~2s (2.5x plus rapide)

Retours d'expérience

Ce qui a bien fonctionné : Bun (excellent pour un projet léger et performant), les écritures atomiques (prévention de la corruption de données), le CI/CD dès le début (itérations rapides), les multi-stage builds (images légères et sécurisées).

Ce que je ferais différemment : ajouter des tests d'intégration avec un mock Discord, intégrer Sentry ou Datadog pour le monitoring, mettre en place des health checks HTTP, collecter des métriques (articles postés, temps de réponse), et utiliser PostgreSQL au lieu de JSON pour la scalabilité.

EchoSwift est bien plus qu'un simple bot Discord : c'est une démonstration complète de comment construire, tester et déployer une application production-ready avec des technologies modernes.

Commentaires

Connecte-toi pour laisser un commentaire.

Aucun commentaire pour le moment. Sois le premier !

Voir d'autres articles

.task vs .onAppear vs .refreshable en SwiftUI

iOS 26 a livré 5 améliorations discrètes en SwiftUI

safeAreaInset vs safeAreaBar en SwiftUI