Aller au contenu
WorkflowPro
Code custom

TypeScript pour bots : structure de projet propre en 2026

Architecture, dossiers, conventions et patterns pour coder des bots (Discord, Slack, Telegram, scrapers) en TypeScript proprement. Sans dette technique.

EA

Etienne Aubry

Développeur & Expert Automatisation IA

· · 12 min de lecture · 2237 mots
Code TypeScript propre affiché sur un écran de développeur
Code TypeScript propre affiché sur un écran de développeur

J’ai vu plus de bots TypeScript mal foutus que de bots propres. Le pattern classique : un seul fichier index.ts de 2000 lignes, secrets en dur, logique métier mélangée avec les appels API, zéro tests, et une logique de retry qui se résume à try/catch { console.log }. Six mois plus tard, le mec qui doit reprendre le projet pleure dans son clavier.

Cet article, c’est la structure que j’utilise sur tous mes projets de bot depuis 2024 : Discord bots, scrapers, automations Telegram, listeners Stripe, agents IA. Une base solide, modulaire, testable, qui ne te coince pas dans 6 mois.

Le contexte : c’est quoi un “bot” ?

J’appelle “bot” tout programme qui :

  • Tourne en continu (process long-running) ou se déclenche sur événement
  • Interagit avec une ou plusieurs APIs externes
  • A une logique métier (pas juste un endpoint REST stateless)
  • Doit être maintenable par toi (ou ton successeur) dans 18 mois

Exemples concrets que je vais utiliser dans cet article :

  • Bot Discord qui modère des messages avec une IA
  • Scraper qui surveille des sites e-commerce et notifie sur Slack
  • Worker qui consomme une queue Redis et envoie des emails
  • Agent IA qui appelle des outils (tool use) pour répondre à des questions

Pour les scrapers spécifiquement, j’ai un article dédié qui compare les techos : Scraper en 2026 : Playwright, Puppeteer ou Phantombuster.

La structure de dossiers

Voici la structure que je recommande, déclinée à partir de 50+ projets réels :

mon-bot/
├── src/
│   ├── config/
│   │   ├── env.ts          # validation des variables d'env (zod)
│   │   └── constants.ts    # constantes métier
│   ├── core/
│   │   ├── logger.ts       # logger configuré (pino)
│   │   ├── errors.ts       # classes d'erreurs custom
│   │   └── container.ts    # injection de dépendances (optionnel)
│   ├── services/
│   │   ├── discord.ts      # client Discord wrappé
│   │   ├── openai.ts       # client OpenAI/Anthropic wrappé
│   │   ├── database.ts     # accès Postgres/Supabase
│   │   └── cache.ts        # Redis ou cache local
│   ├── handlers/
│   │   ├── on-message.ts   # handler pour un événement
│   │   └── on-reaction.ts
│   ├── commands/           # si bot avec commandes (slash, prefixe)
│   │   ├── help.ts
│   │   └── moderate.ts
│   ├── jobs/               # tâches récurrentes / queue
│   │   ├── send-digest.ts
│   │   └── cleanup-cache.ts
│   ├── domain/             # logique métier pure (testable)
│   │   ├── moderation.ts
│   │   └── scoring.ts
│   ├── lib/                # utilitaires génériques
│   │   ├── retry.ts
│   │   ├── sleep.ts
│   │   └── chunk.ts
│   └── index.ts            # point d'entrée minimal
├── tests/
│   ├── domain/
│   └── handlers/
├── .env.example
├── .gitignore
├── package.json
├── tsconfig.json
├── vitest.config.ts
└── README.md

Quelques règles tacites :

  • src/index.ts fait moins de 50 lignes. Il assemble les services et démarre. Pas de logique métier dedans.
  • src/domain/ ne dépend de RIEN d’extérieur. Fonctions pures, testables sans mocks.
  • src/services/ wrappe les APIs externes. Si demain tu changes de provider IA, tu ne touches qu’à un fichier.
  • src/handlers/ orchestre. Reçoit un événement, appelle les services et le domain, retourne une réponse.

Je vais détailler chaque partie clé.

Configuration et validation d’environnement

Première chose à coder. Toujours. Ne procrastine jamais ça. Voici mon src/config/env.ts type :

import { z } from "zod";
import { config } from "dotenv";

config();

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().default(3000),

  DISCORD_TOKEN: z.string().min(50),
  DISCORD_GUILD_ID: z.string().regex(/^\d+$/),

  ANTHROPIC_API_KEY: z.string().startsWith("sk-ant-"),

  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url().optional(),

  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

const result = envSchema.safeParse(process.env);

if (!result.success) {
  console.error("❌ Configuration invalide :");
  console.error(result.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = result.data;

Avantages :

  1. Si une variable manque, ton bot crashe AU DÉMARRAGE avec un message clair (pas en plein milieu de la nuit après 4h de tourner)
  2. Tu as l’autocomplétion TypeScript partout (env.DISCORD_TOKEN est typé string)
  3. Tu documentes implicitement ce dont ton bot a besoin

Dans tout le reste du code, tu fais import { env } from "@/config/env". Plus jamais process.env.X en direct.

Logger : pas de console.log en prod

Le console.log c’est sympa en dev. En prod, c’est l’enfer. Pas de niveau, pas de timestamp structuré, pas de filtrage. Solution : pino.

// src/core/logger.ts
import pino from "pino";
import { env } from "@/config/env";

export const logger = pino({
  level: env.LOG_LEVEL,
  transport: env.NODE_ENV === "development"
    ? { target: "pino-pretty", options: { colorize: true } }
    : undefined,
  base: { service: "mon-bot" },
});

export function createChildLogger(module: string) {
  return logger.child({ module });
}

Utilisation :

import { createChildLogger } from "@/core/logger";
const log = createChildLogger("moderation");

log.info({ userId: "123", action: "warn" }, "Utilisateur averti");
log.error({ err, retryCount: 3 }, "Échec après retries");

En dev tu as du joli colorisé. En prod tu as du JSON structuré qu’un Datadog ou Better Stack ingère parfaitement. Tu peux filtrer par module, par niveau, par champ. La vie change.

Erreurs custom et propagation

// src/core/errors.ts
export class AppError extends Error {
  constructor(message: string, public code: string, public context?: object) {
    super(message);
    this.name = "AppError";
  }
}

export class ExternalApiError extends AppError {
  constructor(message: string, public statusCode?: number, context?: object) {
    super(message, "EXTERNAL_API_ERROR", context);
    this.name = "ExternalApiError";
  }
}

export class ValidationError extends AppError {
  constructor(message: string, context?: object) {
    super(message, "VALIDATION_ERROR", context);
    this.name = "ValidationError";
  }
}

Et tu les utilises :

if (response.status !== 200) {
  throw new ExternalApiError(
    "Slack API a retourné un code inattendu",
    response.status,
    { endpoint: "/chat.postMessage", channel }
  );
}

Dans tes handlers, tu peux faire des catch typés et réagir différemment selon la classe d’erreur. C’est 100x plus propre qu’un if (err.message.includes("rate limit")).

Services : wrapper les APIs externes

Règle numéro 1 : ne jamais appeler fetch ou axios directement dans la logique métier. Toujours passer par un service.

Exemple, un service Slack :

// src/services/slack.ts
import { env } from "@/config/env";
import { createChildLogger } from "@/core/logger";
import { ExternalApiError } from "@/core/errors";
import { retry } from "@/lib/retry";

const log = createChildLogger("slack");

export interface SlackMessage {
  channel: string;
  text: string;
  thread_ts?: string;
}

export const slack = {
  async postMessage(message: SlackMessage): Promise<string> {
    return retry(async () => {
      const res = await fetch("https://slack.com/api/chat.postMessage", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${env.SLACK_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(message),
      });

      const data = await res.json();

      if (!data.ok) {
        throw new ExternalApiError(`Slack error: ${data.error}`, res.status, message);
      }

      log.info({ channel: message.channel, ts: data.ts }, "Message envoyé");
      return data.ts;
    }, { retries: 3, delay: 1000 });
  },
};

Pourquoi c’est bien :

  • Tu changes l’implémentation sans toucher au reste. Si tu migres de Slack vers Discord, tu remplaces le contenu sans rien casser dans handlers/
  • Tu mockes facilement. Pour tester un handler, tu fais juste vi.mock("@/services/slack", () => ({ slack: { postMessage: vi.fn() } }))
  • Les retries sont au bon endroit. Pas dispersés dans 15 fichiers.
  • Les logs sont contextualisés. Tu sais quel service a foiré.

Le pattern de retry

Crucial sur les bots. Voici une implémentation simple et solide :

// src/lib/retry.ts
export interface RetryOptions {
  retries: number;
  delay: number;       // ms
  backoff?: number;    // multiplier (default 2)
  shouldRetry?: (err: unknown) => boolean;
}

export async function retry<T>(
  fn: () => Promise<T>,
  options: RetryOptions
): Promise<T> {
  const { retries, delay, backoff = 2, shouldRetry = () => true } = options;
  let lastErr: unknown;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      if (attempt === retries || !shouldRetry(err)) {
        throw err;
      }
      const waitTime = delay * Math.pow(backoff, attempt);
      await new Promise((resolve) => setTimeout(resolve, waitTime));
    }
  }

  throw lastErr;
}

Utilisation :

await retry(
  () => slack.postMessage({ channel: "C123", text: "Hello" }),
  {
    retries: 3,
    delay: 1000,
    shouldRetry: (err) =>
      err instanceof ExternalApiError && err.statusCode !== 401,
  }
);

On ne retry pas sur du 401 (token invalide, autant crasher tout de suite), mais on retry sur du 500 ou du rate limit (avec backoff exponentiel : 1s, 2s, 4s).

Domaine : la logique métier pure

C’est ici que se trouve la “vraie” logique de ton bot. Et c’est ici que tu vas écrire 80% de tes tests unitaires.

Exemple, une fonction de scoring de message pour un bot de modération :

// src/domain/moderation.ts
export interface MessageScore {
  toxicityScore: number;   // 0-1
  reasons: string[];
  shouldFlag: boolean;
}

export function scoreMessage(
  text: string,
  rules: ModerationRules
): MessageScore {
  const reasons: string[] = [];
  let score = 0;

  if (rules.bannedWords.some((w) => text.toLowerCase().includes(w))) {
    score += 0.5;
    reasons.push("banned_word");
  }

  if (text.length > rules.maxLength) {
    score += 0.2;
    reasons.push("too_long");
  }

  const capsRatio = (text.match(/[A-Z]/g) ?? []).length / text.length;
  if (capsRatio > 0.7 && text.length > 10) {
    score += 0.3;
    reasons.push("excessive_caps");
  }

  return {
    toxicityScore: Math.min(score, 1),
    reasons,
    shouldFlag: score >= rules.flagThreshold,
  };
}

Cette fonction :

  • Ne dépend de RIEN externe
  • Est 100% déterministe
  • Se teste en 2 lignes : expect(scoreMessage("...", rules)).toEqual({...})
  • Sera réutilisée dans 3-4 endroits (handler temps réel, batch nocturne, API admin)

Sépare la logique pure des effets de bord. Ton futur toi te remerciera.

Handlers : orchestration

Un handler fait 3 choses, dans l’ordre :

  1. Récupérer des données via les services
  2. Appliquer la logique métier via le domaine
  3. Effectuer les actions via les services

Exemple :

// src/handlers/on-message.ts
import { discord } from "@/services/discord";
import { ai } from "@/services/ai";
import { slack } from "@/services/slack";
import { scoreMessage } from "@/domain/moderation";
import { moderationRules } from "@/config/constants";
import { createChildLogger } from "@/core/logger";

const log = createChildLogger("handlers.on-message");

export async function onMessage(message: Discord.Message): Promise<void> {
  log.debug({ messageId: message.id }, "Message reçu");

  // 1. Score rapide via la logique pure
  const score = scoreMessage(message.content, moderationRules);

  if (!score.shouldFlag) return;

  // 2. Double-check via IA pour les cas ambigus
  const aiVerdict = await ai.classify(message.content, ["toxic", "ok"]);

  if (aiVerdict !== "toxic") {
    log.info({ messageId: message.id, score }, "Faux positif, ignoré");
    return;
  }

  // 3. Action
  await discord.deleteMessage(message.id);
  await slack.postMessage({
    channel: "#mod-alerts",
    text: `Message supprimé de ${message.author.tag} : ${score.reasons.join(", ")}`,
  });

  log.info({ messageId: message.id, reasons: score.reasons }, "Action de modération appliquée");
}

Lisible, testable (tu mockes discord, ai, slack), et chaque responsabilité est claire.

Tests : ce que je teste, ce que je ne teste pas

Je teste systématiquement :

  • Tout le code dans src/domain/ (couverture > 90%)
  • Les utilitaires dans src/lib/
  • Les handlers complexes (avec mocks des services)

Je teste sélectivement :

  • Les services, juste les helpers de parsing/transformation
  • Les commandes critiques (paiement, suppression de données)

Je ne teste pas :

  • Les wrappers d’API qui font juste un fetch (testé via integration tests)
  • Le code de bootstrap (index.ts, init des clients)

Stack que j’utilise : vitest (rapide, ESM-friendly, API proche de jest), msw pour mocker les API HTTP en integration tests.

Démarrage et arrêt propres

Le piège classique : ton bot crashe en prod, PM2 le restart, mais une connexion DB reste ouverte. Au bout de 50 crashes, ton Postgres explose en “too many connections”.

Solution : gérer SIGTERM proprement.

// src/index.ts
import { logger } from "@/core/logger";
import { startBot, stopBot } from "@/bot";

async function main() {
  await startBot();
  logger.info("Bot démarré");
}

async function shutdown(signal: string) {
  logger.info({ signal }, "Signal d'arrêt reçu, fermeture propre...");
  await stopBot();  // ferme les connexions, flush les queues
  logger.info("Bot arrêté proprement");
  process.exit(0);
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("unhandledRejection", (err) => {
  logger.fatal({ err }, "Unhandled rejection");
  process.exit(1);
});

main().catch((err) => {
  logger.fatal({ err }, "Erreur au démarrage");
  process.exit(1);
});

Déploiement : Docker en 2 minutes

Pas de magie ici. Un Dockerfile minimal :

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist ./dist
USER node
CMD ["node", "dist/index.js"]

Et un .dockerignore strict (n’oublie pas node_modules, .env, tests). Tu builds en local avec npm run build, tu pushes l’image sur ton registry, tu déploies sur Railway / Fly.io / ton VPS.

Si tu veux comprendre comment intégrer ce bot dans une architecture plus large avec des queues, lis mon article sur webhook + queue pour 10K events/jour.

Erreurs récurrentes

1. Pas de tsconfig.json strict. Mets "strict": true dès le jour 1. Et "noUncheckedIndexedAccess": true. Tu écriras du code plus défensif sans même y penser.

2. Trop d’abstraction trop tôt. N’invente pas une “Repository Pattern + UoW + CQRS” pour un bot de 800 lignes. Garde la structure simple tant que tu as moins de 5000 lignes. Refactore au besoin.

3. Pas de monitoring. Heartbeat (Healthchecks.io), erreurs (Sentry), logs (Better Stack). Trois services gratuits qui prennent 1h à brancher et te sauveront des nuits blanches.

4. Hot-reload pas configuré. Utilise tsx watch src/index.ts en dev. Tu modifies un fichier, ton bot redémarre en 200ms. Game-changer.

Conclusion

Cette structure n’a rien de révolutionnaire. C’est juste ce qui marche après des années de bots TypeScript codés en mode “j’avais 2h” puis repris en mode “il faut refactor toute la merde”. Si tu suis ces conventions, ton bot vivra 3 ans sans devenir un cauchemar à maintenir.

Si tu as un projet de bot complexe à coder (Discord, scraper, agent IA, intégration custom), je peux le faire pour toi clé en main. C’est exactement ce que je propose en workflow IA ou workflow avancé, avec doc, tests et passation. Réserve un échange pour qu’on en parle.

Partager cet article

Décrivez votre besoin en 2 min, je vous réponds sous 4 h

Audit gratuit · Pas de relance commerciale · Vous repartez avec un plan d'action utilisable.