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.

Année
2026
Type
Projet personnel
Domaine
Développement
Période
juin – juillet 2026
Stack
TypeScript, discord.js, Fastify, SQLite, Drizzle ORM, zod
Page Statistiques du dashboard customTT : barre latérale de configuration et d’activité, tableau des tickets créés par panel avec son anneau de répartition, et courbe des créations sur 36 jours.
Statistiques, données de démonstration23 écransOuvrir la visite

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.

01 sur 05

Hub d’un panel

Les 14 sections d’un panel et un raccourci vers les réglages les plus utilisés.

Accès

Panels

  • Application encore vide : barre du haut avec le logo customTT et l’utilisateur Alex Moreau, barre latérale du serveur Nova Gaming (Server Configs, Panel Configs, Custom Commands, Statistics, Transcripts, Dashboard History) et un sélecteur « Select a panel… » au-dessus du message « No panel selected »., ouvrir la capture complète
    Coquille de l’application Barre latérale configuration / activité et sélecteur de panel, avant tout choix.
  • Menu du panel « 2 | Support » : barre d’actions Clone, Rename, Send, Set Count et Delete, grille de cartes de réglages (General, Buttons, Messages, Permissions, Claiming, Automation…) et encart « Frequently Used Configs » avec les rôles du support et la catégorie TICKETS., ouvrir la capture complète
    Hub d’un panel Les 14 sections d’un panel et un raccourci vers les réglages les plus utilisés.
  • Page General du panel Support : champ Panel Name « Support », compteur de tickets à 46, catégories TICKETS et ARCHIVES, et rôle @Support dans les rôles de l’équipe., ouvrir la capture complète
    Ré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

Permissions

Ouverture d’un ticket

Claiming anti-abus

  • Page Claiming du panel « 8 | Commande » : mode Queue sélectionné avec le salon #file-attente, stratégie de distribution « None (manual) », interrupteurs des gardes C3, C1 et A1 activés avec leurs valeurs, et règle de déclenchement « commande livrée »., ouvrir la capture complète
    Claiming 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

  • Automation Editor : une règle [Watcher] activée, détaillée ligne par ligne, qui vérifie l’absence de message du Ticket Owner pendant 2 Days puis lance l’action Close Ticket., ouvrir la capture complète
    Automatisations Règles [Trigger] et [Watcher] ; ici, un ticket sans message de son auteur pendant deux jours est fermé.
  • Tableau Custom commands : trois commandes (Règles du support, Fiche joueur, Rappel preuve) avec leur déclencheur, leur type Strict ou Wildcard, leur état et des boutons Edit et Delete., ouvrir la capture complète
    Commandes personnalisées Réponses déclenchées par un texte exact (Strict), un joker (Wildcard) ou une expression régulière (RegEx).

Serveur

  • Page Server Configs en cartes : préfixe $, limite globale de 500 tickets, rôles @Administrateur et @Modérateur, salon #logs-tickets pour les journaux et les transcripts, options de sauvegarde automatique et fuseau Europe/Paris., ouvrir la capture complète
    Configuration du serveur Préfixe, rôles du dashboard, journalisation, transcripts et fuseau horaire, communs à tous les panels.

Mesure

Archives

Traçabilité

Qualité

01 sur 01

Hub d’un panel

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 ?

Démonstration sur données fictives : ticket #12, opérateurs A et B.

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.

  1. 1er clic#13 accepté
  2. 2e clic#14 refusé : cooldown (2 min)
  3. 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
  1. C3Plafond de tickets actifs : au plus 2 tickets actifs par opérateur
  2. C1Cooldown : 2 min entre deux tickets acceptés d’un même opérateur
  3. C2Plafond glissant : au plus 3 tickets acceptés par opérateur sur 10 min
  4. A3Détection de comportement : quatre clics à intervalles trop réguliers signalent l’opérateur 5 min
  5. A2Défi à jeton : un clic sur trois doit valider un jeton aléatoire dans la minute
  6. A1Fenê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.

Fig. 1 — Deux clics au même instant sur le ticket #12, puis trois clics d’affilée d’un même opérateur (données et réglages fictifs). Le ticket n’est attribué qu’une fois parce que l’UPDATE conditionnel ne modifie qu’une ligne, et le spam est arrêté par la garde cooldown avant d’atteindre la base.

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.

Cycle de vie d’un job différé échec : re-planifié à 1, 5 puis 15 min UPDATE atomique réserve le job succès cinquième tentative reprend les jobs restés en cours Planifiéen attente En coursréservé Exécutéhandler idempotent Démarrageaprès un arrêt Statut terminalplus de reprise Ordre d’arrêt minuteur de sortie forcée Entréesarrêt Drainborné Clientdestruction Basefermeture
Fig. 2 — Reprise après arrêt. En haut, le cycle d’un job différé : réservé par un UPDATE atomique, exécuté, re-planifié à 1, 5 puis 15 minutes en cas d’échec, terminal à la cinquième tentative ; au démarrage, les jobs restés en cours reprennent. En bas, l’ordre d’arrêt : entrées, drain borné, client, base, sous un minuteur de sortie forcée.

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.

oui non Lireconfiguration ethash SHA-256 Éditerd’autres ongletspeuvent écrire Hash courant= hash lu ?au moment d’écrire Écriremise à jour Conflitrien n’est écrit Configuration modifiée depuis la lecture : conflit, et rien n’est écrit.
Fig. 3 — Concurrence optimiste sur la configuration. Le hash SHA-256 lu avant l’édition est comparé au hash courant au moment d’écrire : s’ils diffèrent, le dashboard renvoie un conflit sans rien écrire.

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.