TicketTool free
Bot Discord de gestion de tickets et son tableau de bord d’administration ; une double attribution d’un même ticket reste impossible même sous clics simultanés.

En bref
- Problème
- Dans une file de tickets, un clic automatisé gagne la course à la prise en charge, deux clics simultanés risquent d’attribuer deux fois le même ticket et un crash peut rejouer une tâche différée.
- Ce que j’ai réalisé
- Un bot TypeScript où chaque prise en charge passe six gardes et une stratégie de répartition, puis une seule écriture conditionnelle en base ; un dashboard Fastify avec connexion OAuth2 Discord et configuration versionnée.
- Résultat
- Une seule ligne modifiée en base désigne le gagnant d’un ticket ; les tâches différées reprennent après un arrêt et deux onglets du dashboard ne s’écrasent plus la configuration.
- Rôle
- Conception, architecture, orchestration et vérification.
- Équipe
- Projet individuel
- Statut
- Terminé
- Confidentialité
- Certains détails sont volontairement anonymisés.
Le produit, écran par écran
Vrai serveur Fastify et vrai front React, alimentés par une base de démonstration aux données inventées : 3 serveurs, 9 panels, 210 tickets, 42 transcripts, 8 opérateurs. Seuls l’annuaire Discord et le client OAuth2 sont simulés.
Accès
, ouvrir la capture complèteConnexion L’accès au dashboard passe par OAuth2 Discord.
, ouvrir la capture complèteChoix du serveur Seuls les serveurs où l’utilisateur est Owner, Administrator ou porte le rôle Dashboard apparaissent ; un serveur sans le bot propose de l’ajouter.
Panels
, ouvrir la capture complèteCoquille de l’application Barre latérale configuration / activité et sélecteur de panel, avant tout choix.
, ouvrir la capture complèteHub d’un panel Les 14 sections d’un panel et un raccourci vers les réglages les plus utilisés.
, ouvrir la capture complèteRéglages généraux Nom du panel, catégories d’ouverture et d’archive, rôles du support ; le compteur de tickets est en lecture seule.
Messages et boutons
, ouvrir la capture complèteMessages du bot Chaque message envoyé par le bot, avec un badge default ou override.
, ouvrir la capture complèteÉditeur d’embed Édition complète d’un embed, avec un aperçu rendu à la façon de Discord.
, ouvrir la capture complèteBoutons Libellé, emoji et couleur de chaque bouton, modifiables un par un.
Permissions
, ouvrir la capture complèteGroupes de permissions Quatre groupes, chacun réglé pour le ticket ouvert et pour le ticket fermé.
, ouvrir la capture complèteMatrice des permissions Les 21 permissions Discord en refus, neutre ou autorisation, et le diagnostic des droits réels du bot.
Ouverture d’un ticket
, ouvrir la capture complèteFormulaire d’ouverture Questions posées à l’ouverture : format de réponse, champ obligatoire, bornes de longueur.
, ouvrir la capture complèteHoraires Grille de 7 jours sur 24 heures qui fixe quand un ticket peut être ouvert.
Claiming anti-abus
, ouvrir la capture complèteClaiming en file d’attente Salon de file, gardes anti-abus (C1, C3 et A1 activées ici) et déclencheur qui libère le claim.
Automatisation
, ouvrir la capture complèteAutomatisations Règles [Trigger] et [Watcher] ; ici, un ticket sans message de son auteur pendant deux jours est fermé.
, ouvrir la capture complèteCommandes personnalisées Réponses déclenchées par un texte exact (Strict), un joker (Wildcard) ou une expression régulière (RegEx).
Serveur
, ouvrir la capture complèteConfiguration du serveur Préfixe, rôles du dashboard, journalisation, transcripts et fuseau horaire, communs à tous les panels.
Mesure
, ouvrir la capture complèteStatistiques Tickets par panel, répartition en anneau et courbe des créations sur 36 jours.
, ouvrir la capture complèteStatistiques des opérateurs Claims par heure, temps de réaction et de résolution, avec une pastille ANOMALY sur un opérateur.
Archives
, ouvrir la capture complèteTranscripts Transcripts archivés, paginés, chacun consultable en ligne.
, ouvrir la capture complèteLecteur de transcript Un ticket complet rendu par le moteur de production : embeds, boutons, mentions, image jointe, message vocal, PDF et réactions.
Traçabilité
, ouvrir la capture complèteJournal d’audit Qui a changé quoi, et quand, dans le dashboard.
, ouvrir la capture complèteDétail d’une modification Une entrée dépliée : l’ancienne et la nouvelle valeur, champ par champ.
Qualité
, ouvrir la capture complèteDesign system La galerie interne des composants du dashboard, état par état.
Sommaire7 sections
Contexte
Projet personnel mené en 2026 : un bot Discord de gestion de tickets (panels, claim, transcripts, automatisations) et son dashboard web d’administration.
J’utilisais l’offre payante d’un bot de tickets sur mes serveurs Discord. Je ne voulais plus payer son abonnement. Une faille de ce bot m’a été signalée sur mon serveur : une personne malveillante pouvait récupérer les transcripts de tickets d’un serveur. Je l’ai signalée à l’éditeur. Je ne sais pas si elle a été corrigée. J’ai donc refait l’outil entièrement, pour l’héberger sur un VPS à la place de l’abonnement.
J’ai conçu et spécifié le système, puis orchestré sa réalisation. La pile est discord.js 14, TypeScript strict, SQLite avec Drizzle, Fastify 5 et zod, en versions épinglées. Le serveur web tourne dans le même process que le bot.
Le problème
Dans une file de tickets, plusieurs opérateurs se disputent la prise en charge d’un même ticket (le claim), ce qui ouvre cinq risques. La règle « premier qui clique gagne » avantage un clic automatisé (auto-clicker). Deux clics simultanés ne doivent pas attribuer le même ticket deux fois. Une tâche différée (un job) peut être rejouée après un crash. Discord limite le renommage d’un canal sans l’annoncer clairement. Deux onglets du dashboard peuvent s’écraser la configuration.
Architecture et décisions
Un clic de claim traverse une chaîne ordonnée de six gardes : plafond de tickets actifs, cooldown, plafond glissant, détection de comportement, défi et fenêtre de randomisation. La chaîne s’arrête au premier verdict qui n’est pas « allow ». Les gardes reçoivent la base, une horloge et des coordinateurs injectés, sans accès à Discord, ce qui les rend testables seules.
Une fabrique choisit ensuite la stratégie de répartition parmi quatre : aucune, round-robin, moins chargé, push. Elle se replie sur « aucune » quand une dépendance manque, et le flux de claim reste intact.
La décision centrale porte sur l’attribution : un UPDATE conditionnel unique (état « en file » et aucun claimant). Une ligne modifiée désigne le gagnant, zéro ligne désigne le perdant. Une double attribution reste donc impossible, même si toutes les gardes laissent passer les deux clics.
Dans la Fig. 1, deux opérateurs cliquent au même instant sur le même ticket, et un seul l’obtient.
Deux opérateurs cliquent au même instant sur le même ticket. Qui l’obtient ?
Opérateur A clique sur le ticket #12
Ticket attribué à A
Opérateur B clique sur le ticket #12
Refusé : le ticket était déjà pris
L’UPDATE conditionnel ne modifie qu’une ligne : le premier passe, le second trouve le ticket déjà pris.
UPDATE tickets SET claimant = ? WHERE id = ? AND state = 'queued' AND claimant IS NULL
Et si un opérateur spamme ? A, sans ticket en cours, clique trois fois d’affilée.
- 1er clic#13 accepté
- 2e clic#14 refusé : cooldown (2 min)
- 3e clic#15 refusé : cooldown (2 min)
1 accepté, 2 refusés par la garde cooldown (2 min) : après un ticket accepté, l’opérateur attend deux minutes, et ses clics suivants s’arrêtent avant la base.
Résultat pré-rendu en exemple : le module se joue avec JavaScript.
Les 6 gardes, dans l’ordre du bot
C3Plafond de tickets actifs : au plus 2 tickets actifs par opérateurC1Cooldown : 2 min entre deux tickets acceptés d’un même opérateurC2Plafond glissant : au plus 3 tickets acceptés par opérateur sur 10 minA3Détection de comportement : quatre clics à intervalles trop réguliers signalent l’opérateur 5 minA2Défi à jeton : un clic sur trois doit valider un jeton aléatoire dans la minuteA1Fenêtre de randomisation : les clics sur un ticket sont mis en attente 1 min, puis tirés au sort
La chaîne s’arrête au premier refus ; l’attribution, elle, se joue toujours à l’UPDATE.
J’ai écrit l’OAuth2 Discord à la main avec le fetch natif de Node, et un limiteur de débit sans dépendance, pour garder une chaîne de dépendances courte. J’ai fixé une seule instance par fichier de base, parce que deux instances double-exécuteraient jobs et renommages.
Reprise après arrêt
Le planificateur de jobs est persistant et garantit une exécution au moins une fois, avec des handlers idempotents. Il réserve un job par un UPDATE atomique avant d’attendre le handler, et il reprend au démarrage les jobs restés en cours. Un échec est re-planifié à 1, 5 puis 15 minutes, puis le job passe en statut terminal à la cinquième tentative.
L’arrêt suit un ordre fixe : arrêt des entrées, drain borné, destruction du client, fermeture de la base, avec un minuteur de sortie forcée. Les renommages de canal passent par une file persistée où le dernier nom demandé gagne, avec 5 minutes de marge entre deux renommages effectifs.
Sécurité du dashboard
Le dashboard écrit la configuration d’un serveur seulement si le hash SHA-256 lu avant l’édition est toujours le hash courant ; sinon il renvoie un conflit sans rien écrire. Un utilisateur autorisé par un simple rôle dashboard ne peut pas modifier la liste de ces rôles.
La connexion utilise un état anti-CSRF de 16 octets à usage unique, une redirection post-login validée et une session de 32 octets dans un cookie signé de 7 jours. Le transcript HTML est autonome : les données sont en base64, le texte est échappé, le DOM est construit sans innerHTML, et l’accès est limité à 60 requêtes par minute.
Résultat
J’ai vérifié chaque livraison par trois contrôles bloquants : les tests, le lint et le typage. Les services métier sont testés sans simuler discord.js. Pour la version 1.0, le README du dépôt déclare 866 tests unitaires et de handlers verts. Il déclare aussi 20 tests de bout en bout verts. Je n’ai pas rejoué ces tests pour cette fiche.
Ce que j’ai appris, ce que je referais
J’ai placé l’exclusivité du claim dans l’UPDATE conditionnel, sous la chaîne de gardes. Je n’ai donc pas à prouver chaque garde pour la garantir. Une garde peut se tromper sans qu’un ticket soit attribué deux fois.
Quatre limites restent dans la version actuelle :
- Le pool du tirage de randomisation est en mémoire : un redémarrage le perd. Je le persisterais.
- Le tirage utilise Math.random par défaut.
- Les seuils de détection (coefficient de variation 0,05, flag de 5 minutes) sont des constantes que je n’ai pas calibrées. Je les calibrerais avec le journal des claims.
- Les jetons OAuth sont stockés en clair dans la base locale. Je les chiffrerais au repos. J’étudierais PKCE.


