Créer un bot de veille multi-agents Discord avec Mastra (Guide Complet 2025)
Un bot de veille discord
Avec la popularisation des LLMs, agents, MCP et autres technologies, nous pouvons bricoler des applications de plus en plus facilement. Aujourd’hui je vais vous présenter un framework Agentic en TypeScript que j’apprécie énormément, Mastra. https://mastra.ai/
Mastra
Le but ici n’est pas de présenter toutes les fonctionnalités de Mastra, ni de rentrer en détail dans la configuration, mais de vous montrer son potentiel avec les workflows et sa facilité de prise en main.
Normalement il y a un playground qui facilite les essais, mais ici nous les ferons directement sur Discord.
Discord
En premier, nous allons aller sur : https://discord.com/developers/applications
Et créer une nouvelle application
Dans la partie ‘General Information’, récupérez l’application ID pour plus tard.
Ensuite dans la partie bot, faites un reset du token pour en générer un nouveau.
Enfin, récupérez dans installation, l’URL d’installation pour l’ajouter à un serveur pour les tests.
CODE !
Maintenant passons au code et au setup du projet.
Setup
Pour mettre en place le projet en TypeScript je vais suivre ce guide :
Il y a juste ma partie script qui est différente :
"scripts": { "dev": "tsx watch src/index.ts", "start": "tsx src/index.ts", }
Pour cela il faudra aussi ajouter tsx :
pnpm add tsx
Maintenant nous allons faire notre setup discord, un simple message d’echo pour l’instant.
Il faudra installer la librairie Discord.js
pnpm add discord.js dotenv
Voici mon index.ts :
import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from 'discord.js';import dotenv from 'dotenv';
// Charger les variables d'environnementdotenv.config();
// Créer une nouvelle instance du client Discordconst client = new Client({ intents: [ GatewayIntentBits.Guilds, ],});
// Définir la commande /pingconst commands = [ new SlashCommandBuilder() .setName('ping') .setDescription('Répond avec pong!') .toJSON(),];
// Fonction pour enregistrer les commandes slashasync function deployCommands() { const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN!);
try { console.log('Déploiement des commandes slash...');
await rest.put( Routes.applicationCommands(process.env.CLIENT_ID!), { body: commands }, );
console.log('Commandes slash déployées avec succès!'); } catch (error) { console.error('Erreur lors du déploiement des commandes:', error); }}
// Event: Bot prêtclient.once('ready', async () => { console.log(`Bot connecté en tant que ${client.user?.tag}!`); await deployCommands();});
// Event: Interaction avec les commandes slashclient.on('interactionCreate', async (interaction) => { if (!interaction.isChatInputCommand()) return;
const { commandName } = interaction;
if (commandName === 'ping') { await interaction.reply('Pong!'); }});
// Gestion des erreursclient.on('error', (error) => { console.error('Erreur du client Discord:', error);});
process.on('unhandledRejection', (error) => { console.error('Promesse rejetée non gérée:', error);});
// Connexion du botclient.login(process.env.DISCORD_TOKEN);
Faites un pnpm run dev
et ajoutez-le à votre serveur, il devrait vous répondre pong !
Mastra
Pour ce tutoriel je vais utiliser les clés de Google, et le modèle flash preview, car il est gratuit !
Vous pourriez vous aussi les utiliser pour avoir un bot 100% free : https://ai.google.dev/gemini-api/docs/api-key?hl=fr
En premier, nous allons installer toutes les dépendances :
pnpm add @mastra/core @mastra/memory @mastra/libsql @ai-sdk/google zod rss-parser
Maintenant nous allons créer un dossier agents et faire un agentRss.ts :
import 'dotenv/config';import { Agent } from "@mastra/core/agent";import { Memory } from '@mastra/memory';import { LibSQLStore } from '@mastra/libsql';import { createTool } from '@mastra/core';import { z } from 'zod';import Parser from 'rss-parser';import { google } from '@ai-sdk/google';// Initialiser le parser RSSconst parser = new Parser();
const memory = new Memory({ storage: new LibSQLStore({ url: "file:./memory.db", }),});
const today = new Date().toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long'});
// Outil pour lire les flux RSSconst rssFeedTool = createTool({ id: 'rss-feed-reader', description: 'Lit et analyse un flux RSS depuis une URL', inputSchema: z.object({ url: z.string().url().describe('L\'URL du flux RSS à lire'), limit: z.number().optional().default(10).describe('Nombre maximum d\'articles à récupérer (défaut: 10)') }), outputSchema: z.object({ title: z.string().describe('Titre du flux RSS'), description: z.string().optional().describe('Description du flux'), link: z.string().optional().describe('Lien vers le site web'), articles: z.array(z.object({ title: z.string().describe('Titre de l\'article'), link: z.string().optional().describe('Lien vers l\'article'), pubDate: z.string().optional().describe('Date de publication'), contentSnippet: z.string().optional().describe('Extrait du contenu'), creator: z.string().optional().describe('Auteur de l\'article') })).describe('Liste des articles du flux') }), execute: async ({ context }) => { try { const { url, limit } = context;
// Parser le flux RSS const feed = await parser.parseURL(url);
// Limiter le nombre d'articles const articles = feed.items.slice(0, limit).map(item => ({ title: item.title || 'Sans titre', link: item.link || undefined, pubDate: item.pubDate || undefined, contentSnippet: item.contentSnippet || item.content || undefined, creator: item.creator || item.author || undefined }));
return { title: feed.title || 'Flux RSS', description: feed.description || undefined, link: feed.link || undefined, articles }; } catch (error) { throw new Error(`Erreur lors de la lecture du flux RSS: ${error instanceof Error ? error.message : 'Erreur inconnue'}`); } }});
export const rssAgent = new Agent({ name: 'RSS Feed Agent', instructions: `You are a specialized RSS feed reader assistant. ` + `Today is ${today}. ` +
`Your capabilities include: ` + `- Reading and parsing RSS feeds from URLs ` + `- Summarizing RSS feed content and articles ` + `- Providing information about the latest articles from various sources ` + `- Helping users stay updated with news and content from their favorite websites ` +
`When users provide RSS feed URLs or ask about specific feeds: ` + `- Use the rssFeedTool to fetch and parse the RSS content ` + `- Provide clear summaries of the articles ` + `- Highlight the most important or recent articles ` + `- Format the information in an easy-to-read way ` +
`You can help with popular RSS feeds from tech blogs, news sites, ` + `podcasts, and other content sources. Always be helpful and provide ` + `organized information about the RSS content.`, model: google('gemini-2.5-flash-preview-04-17'), memory, tools: { rssFeedTool }});
ici nous avons créé un agent avec la date du jour et il a un outil qui lui permet de récupérer avec une URL les flux RSS. En .env vous devez aussi ajouter GOOGLE_GENERATIVE_AI_API_KEY=your-key, pour que cela fonctionne.
Maintenant nous allons faire un agent qui va permettre de réécrire les news reçues.
agentWriter.ts
import 'dotenv/config';import { Agent } from "@mastra/core/agent";import { Memory } from '@mastra/memory';import { google } from '@ai-sdk/google';import { LibSQLStore } from '@mastra/libsql';
const memory = new Memory({ storage: new LibSQLStore({ url: "file:./memory.db", }),});
export const writerAgent = new Agent({ name: 'Content Writer', instructions: `You are an expert content rewriter specialized in tech news and research aggregation. Your goal is to: 1. Take multiple pieces of content (RSS feeds, web research, etc.) and create a comprehensive digest 2. Identify common trends and important topics across sources 3. Organize the information with clear sections 4. Maintain accuracy while improving readability and flow 5. Respond in plain text with no Markdown formatting When rewriting: - Prioritize the most important and current information - Group related topics together - Highlight major trends and innovations - Use a professional yet accessible tone - Include dates and sources when available - Do NOT use Markdown; answer as if sending a simple text message `, memory, model: google('gemini-2.5-flash-preview-04-17')});
Et maintenant passons au workflow ! Là où nous allons indiquer quelles URLs checker. Nous pourrions aller plus loin, et faire une commande à l’utilisateur pour ajouter des sources et pendant le workflow nous irions voir en DB les sources.
mais pour l’instant faisons simple.
Créez un dossier workflows et dedans créez un index.ts
import 'dotenv/config';import { createWorkflow, createStep } from "@mastra/core/workflows";import { z } from "zod";import { rssAgent } from "../agents/agentRss";import { writerAgent } from "../agents/agentWriter";
// Step 1: Lire Blog du Modérateur Techconst blogModeratorStep = createStep({ id: "blog-moderator-step", description: "Lit le flux RSS du Blog du Modérateur Tech", inputSchema: z.object({}), outputSchema: z.object({ blogModeratorContent: z.string() }),
execute: async () => { const message1 = "📡 Step 1: Lecture du flux RSS Blog du Modérateur Tech..."; console.log(message1);
const response = await rssAgent.generate( "Lis le flux RSS de https://www.blogdumoderateur.com/tech/feed/ et donne-moi un résumé CONCIS des 3 articles les plus récents seulement. Limite-toi à 2-3 phrases par article.", { resourceId: "rss_workflow", threadId: "rss_workflow_blog_moderator" } );
const message2 = "✅ Step 1: Blog du Modérateur Tech terminé"; console.log(message2);
return { blogModeratorContent: response.text }; }});
// Step 2: Lire Le Monde Informatique Cloudconst mondeInformatiqueStep = createStep({ id: "monde-informatique-step", description: "Lit le flux RSS du Monde Informatique Cloud", inputSchema: z.object({}), outputSchema: z.object({ mondeInformatiqueContent: z.string() }),
execute: async () => { const message1 = "📡 Step 2: Lecture du flux RSS Le Monde Informatique Cloud..."; console.log(message1);
const response = await rssAgent.generate( "Lis le flux RSS de https://www.lemondeinformatique.fr/flux-rss/thematique/le-monde-du-cloud-computing/rss.xml et donne-moi un résumé CONCIS des 3 articles les plus récents sur le cloud computing. Limite-toi à 2-3 phrases par article.", { resourceId: "rss_workflow", threadId: "rss_workflow_monde_informatique" } );
const message2 = "✅ Step 2: Le Monde Informatique Cloud terminé"; console.log(message2);
return { mondeInformatiqueContent: response.text }; }});
// Step 3: Lire Forbes Innovationconst forbesStep = createStep({ id: "forbes-step", description: "Lit le flux RSS de Forbes Innovation", inputSchema: z.object({}), outputSchema: z.object({ forbesContent: z.string() }),
execute: async () => { const message1 = "📡 Step 3: Lecture du flux RSS Forbes Innovation..."; console.log(message1);
const response = await rssAgent.generate( "Lis le flux RSS de https://www.forbes.com/innovation/feed et donne-moi un résumé CONCIS des 3 articles les plus récents sur l'innovation. Limite-toi à 2-3 phrases par article.", { resourceId: "rss_workflow", threadId: "rss_workflow_forbes" } );
const message2 = "✅ Step 3: Forbes Innovation terminé"; console.log(message2);
return { forbesContent: response.text }; }});
// Step 4: Réécriture et synthèse finaleconst rewriteStep = createStep({ id: "rewrite-synthesis-step", description: "Réécrit et synthétise tous les contenus RSS", inputSchema: z.object({ "blog-moderator-step": z.object({ blogModeratorContent: z.string() }), "monde-informatique-step": z.object({ mondeInformatiqueContent: z.string() }), "forbes-step": z.object({ forbesContent: z.string() }) }), outputSchema: z.object({ finalDigest: z.string() }),
execute: async ({ inputData }) => { const message1 = "🔄 Step 4: Synthèse et réécriture..."; console.log(message1);
const blogModeratorContent = inputData["blog-moderator-step"].blogModeratorContent; const mondeInformatiqueContent = inputData["monde-informatique-step"].mondeInformatiqueContent; const forbesContent = inputData["forbes-step"].forbesContent;
const synthesisPrompt = ` Crée un digest tech quotidien professionnel à partir des contenus RSS suivants:
Source 1 - Blog du Modérateur Tech: ${blogModeratorContent}
Source 2 - Le Monde Informatique (Cloud): ${mondeInformatiqueContent}
Source 3 - Forbes Innovation: ${forbesContent}
Instructions: 1. Crée une synthèse globale organisée par thèmes technologiques 2. Identifie les tendances communes et les sujets importants 3. Hiérarchise l'information par ordre d'importance 4. Utilise uniquement du texte brut sans Markdown, comme un message WhatsApp 5. Indique toujours la source et la date pour chaque article 6. Garde un ton informatif et engageant avec des sections claires `;
const response = await writerAgent.generate([ { role: "user", content: synthesisPrompt } ]);
const message2 = "✅ Step 4: Synthèse finale terminée, ça arrive soon !"; console.log(message2);
return { finalDigest: response.text }; }});
// Création du workflow completexport const rssWorkflow = createWorkflow({ id: "rss-aggregator-workflow", description: "Agrège et réécrit le contenu de plusieurs sources RSS tech", inputSchema: z.object({}), outputSchema: z.object({ finalDigest: z.string() })}) .parallel([ blogModeratorStep, mondeInformatiqueStep, forbesStep ]) .then(rewriteStep) .commit();
Très bien nous avons un workflow maintenant ! Qui a trois steps qui se font en parallèle (fetch des RSS et synthétisation par LLM) et ensuite l’agent ‘writer’ qui va résumer les actualités.
Vous allez me dire très bien mais je veux converser avec moi, pas de problème !
Chef agent
Nous allons bientôt tester notre code, mais avant il nous reste quelques étapes.
Créez un agentMaster.ts dans le dossier agents :
import 'dotenv/config';import { Agent } from "@mastra/core/agent";import { Memory } from '@mastra/memory';import { rssWorkflow } from '../workflows';import { google } from '@ai-sdk/google';import { LibSQLStore } from '@mastra/libsql';
const memory = new Memory({ storage: new LibSQLStore({ url: "file:./memory.db", }),});
const today = new Date().toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long'});
export const chefAgent = new Agent({ name: 'News Assistant', instructions: `You are a specialized news assistant focused on providing tech news and current events. ` + `Today is ${today}. ` + `Always reply in plain text without using Markdown formatting. ` + `Do not use asterisks or any other symbols for bold or italic text. ` +
`MANDATORY RULE: For ANY request related to news, actualités, current events, or tech updates, ` + `you MUST use the rssWorkflow immediately. This includes: ` + `- "actualités" ` + `- "news" ` + `- "dernières nouvelles" ` + `- "infos du jour" ` + `- "tech news" ` + `- "what's happening" ` + `- Any request for current information ` +
`The rssWorkflow provides real-time tech news from Blog du Modérateur, Le Monde Informatique, and Forbes Innovation. ` + `Always use rssWorkflow first for news requests - do not attempt to provide news from your knowledge base. `, model: google('gemini-2.5-flash-preview-04-17'), memory, workflows: { rssWorkflow },});
Et dans cet agent, nous lui avons donné comme outil le workflow, donc il pourra exécuter le workflow pour récupérer les actualités. Génial non ?!
Test Discord
Modifions le index.ts pour ajouter la commande /chat et générer une conversation avec l’agent master :
import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from 'discord.js';import dotenv from 'dotenv';import { chefAgent } from './agents/agentMaster';
// Charger les variables d'environnementdotenv.config();
// Créer une nouvelle instance du client Discordconst client = new Client({ intents: [ GatewayIntentBits.Guilds, ],});
// Définir les commandes /ping et /chatconst commands = [ new SlashCommandBuilder() .setName('ping') .setDescription('Répond avec pong!') .toJSON(), new SlashCommandBuilder() .setName('chat') .setDescription('Converser avec l\'assistant IA') .addStringOption(option => option.setName('message') .setDescription('Votre message à l\'assistant') .setRequired(true)) .toJSON(),];
// Fonction pour enregistrer les commandes slashasync function deployCommands() { const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN!);
try { console.log('Déploiement des commandes slash...');
await rest.put( Routes.applicationCommands(process.env.CLIENT_ID!), { body: commands }, );
console.log('Commandes slash déployées avec succès!'); } catch (error) { console.error('Erreur lors du déploiement des commandes:', error); }}
// Event: Bot prêtclient.once('ready', async () => { console.log(`Bot connecté en tant que ${client.user?.tag}!`); await deployCommands();});
// Event: Interaction avec les commandes slashclient.on('interactionCreate', async (interaction) => { if (!interaction.isChatInputCommand()) return;
const { commandName } = interaction;
if (commandName === 'ping') { await interaction.reply('Pong!'); } else if (commandName === 'chat') { const message = interaction.options.getString('message', true);
try { // Répondre immédiatement pour éviter le timeout await interaction.reply('🤖 Recherche d\'informations...');
// Créer un threadId unique basé sur l'utilisateur et la date const uniqueThreadId = `discord_chat_${interaction.user.id}_${new Date().toDateString().replace(/\s/g, '_')}`;
// Envoyer le message à l'agent const response = await chefAgent.generate(message, { resourceId: `discord_user_${interaction.user.id}`, threadId: uniqueThreadId, });
// Fonction pour diviser le texte en chunks de 2000 caractères max const splitMessage = (text: string, maxLength: number = 2000): string[] => { if (text.length <= maxLength) { return [text]; }
const chunks: string[] = []; let currentChunk = '';
// Diviser par paragraphes d'abord const paragraphs = text.split('\n\n');
for (const paragraph of paragraphs) { // Si le paragraphe seul dépasse la limite, le diviser par phrases if (paragraph.length > maxLength) { const sentences = paragraph.split('. '); for (const sentence of sentences) { const potentialChunk = currentChunk + (currentChunk ? '\n\n' : '') + sentence + (sentence.endsWith('.') ? '' : '.');
if (potentialChunk.length > maxLength) { if (currentChunk) { chunks.push(currentChunk.trim()); currentChunk = sentence + (sentence.endsWith('.') ? '' : '.'); } else { // Si même une phrase dépasse, la couper brutalement chunks.push(sentence.substring(0, maxLength - 3) + '...'); } } else { currentChunk = potentialChunk; } } } else { const potentialChunk = currentChunk + (currentChunk ? '\n\n' : '') + paragraph;
if (potentialChunk.length > maxLength) { chunks.push(currentChunk.trim()); currentChunk = paragraph; } else { currentChunk = potentialChunk; } } }
if (currentChunk) { chunks.push(currentChunk.trim()); }
return chunks; };
// Diviser la réponse si nécessaire const messageChunks = splitMessage(response.text);
// Envoyer le premier chunk en modifiant la réponse initiale await interaction.editReply(messageChunks[0]);
// Envoyer les chunks suivants en tant que nouveaux messages for (let i = 1; i < messageChunks.length; i++) { await interaction.followUp(messageChunks[i]); }
} catch (error) { console.error('Erreur lors de la génération de la réponse:', error); await interaction.editReply('Désolé, une erreur s\'est produite lors du traitement de votre demande. 😔'); } }});
// Gestion des erreursclient.on('error', (error) => { console.error('Erreur du client Discord:', error);});
process.on('unhandledRejection', (error) => { console.error('Promesse rejetée non gérée:', error);});
// Connexion du botclient.login(process.env.DISCORD_TOKEN);
Faites maintenant /chat et demandez “Quelles sont les actualités du jour ? ”
C’est top non ?
Ajoutons un workflow web pour demander plus d’informations sur une actualité !
Web
Pour éviter la re-création d’un agent, vous pouvez utiliser https://tsai-registry.dev.
Je vais donc en profiter pour ajouter l’agent web, avant cela, allez-vous créer un compte firecrawl.dev et récupérer votre clé d’API free.
Exécutez la commande suivante pour ajouter votre agent web :
npx tsai-registry add firecrawl-agent
L’utilitaire vous demandera votre clé API firecrawl pour alimenter le .env directement.
Une fois installé, vous avez normalement un dossier mastra/registry/agent/firecrawl… qui s’est créé, je vais prendre le dossier firecrawl et le mettre dans mon src/agents.
Je ne vais pas rentrer en détail dans la configuration de l’agent, tout ce qu’il faut faire c’est de remplacer openai par google comme les autres agents.
Ensuite, vous pouvez créer dans le dossier workflows un web.ts
import 'dotenv/config';import { createWorkflow, createStep } from "@mastra/core/workflows";import { z } from "zod";import { firecrawlAgent } from "../agents/firecrawl-agent/index";import { writerAgent } from "../agents/agentWriter";
// Étape 1: Recherche webconst webSearchStep = createStep({ id: "web-search-step", description: "Effectue une recherche web approfondie sur le sujet donné", inputSchema: z.object({ query: z.string().describe("Le sujet ou la requête de recherche") }), outputSchema: z.object({ searchResults: z.string().describe("Les résultats de recherche compilés") }),
execute: async ({ inputData }) => { const { query } = inputData; const message1 = "🔎 Étape 1: Recherche web en cours..."; console.log(message1);
const prompt = `Recherche des informations détaillées sur: ${query}`; const { text } = await firecrawlAgent.generate([ { role: "user", content: prompt } ]);
const message2 = "✅ Étape 1: Recherche web terminée."; console.log(message2);
return { searchResults: text }; }});
// Étape 2: Réécriture du contenuconst rewriteStep = createStep({ id: "rewrite-step", description: "Réécrit et améliore le contenu de recherche", inputSchema: z.object({ searchResults: z.string().describe("Les résultats de recherche à réécrire") }), outputSchema: z.object({ rewrittenContent: z.string().describe("Le contenu réécrit et amélioré") }),
execute: async ({ inputData }) => { const { searchResults } = inputData; const message1 = "✍️ Étape 2: Réécriture du contenu en cours..."; console.log(message1);
const prompt = ` Réécris et améliore le contenu suivant de manière claire et engageante:
${searchResults}
Instructions: - Organise le contenu avec des titres appropriés - Améliore la lisibilité et la structure - Garde toutes les informations importantes - Rends le ton professionnel mais accessible `;
const { text } = await writerAgent.generate([ { role: "user", content: prompt } ]);
const message2 = "✅ Étape 2: Réécriture terminée."; console.log(message2); return { rewrittenContent: text }; }});
// Création du workflow completexport const webResearchWorkflow = createWorkflow({ id: "web-research-workflow", description: "Workflow de recherche web et réécriture de contenu", inputSchema: z.object({ query: z.string().describe("Le sujet de recherche") }), outputSchema: z.object({ rewrittenContent: z.string().describe("Le contenu final réécrit et amélioré") })}) .then(webSearchStep) .then(rewriteStep) .commit();
Voici maintenant mon nouveau chef agent avec le workflow et des indications en plus en prompt :
export const chefAgent = new Agent({ name: 'Chef Assistant', instructions: `You are a helpful global assistant that can help with various tasks and questions. ` + `Today is ${today}. ` + `Always reply in plain text without using Markdown formatting. ` + `Do not use asterisks or any other symbols for bold or italic text. ` +
`You have access to different workflows that can help you provide better answers: ` + `- rssWorkflow: **MANDATORY** - Use this IMMEDIATELY when users ask for news, actualités, current events, or tech updates. This provides a comprehensive tech digest from multiple sources. ` + `- webResearchWorkflow: Use this when you need other current information, research, or web search ` +
`IMPORTANT RULES: ` + `- When users ask for "actualités", "news", "dernières nouvelles", "infos du jour", or any news-related request: YOU MUST use rssWorkflow first ` + `- Do not attempt to provide news from your knowledge - always use rssWorkflow for current news ` + `- The rssWorkflow provides real-time tech news aggregated from Blog du Modérateur, Le Monde Informatique, and Forbes Innovation ` + `- Keep conversations focused and concise to avoid token overflow ` +
`You can assist with: ` + `- General questions and information ` + `- News and current events (use rssWorkflow MANDATORY) ` + `- Research and web search (use webResearchWorkflow) ` + `- Technical help and explanations ` + `- Creative tasks and brainstorming ` +
`Always try to be helpful, accurate, and use the available workflows when they can ` + `improve your response quality.`, model: google('gemini-2.5-flash-preview-04-17'), memory, workflows: { rssWorkflow, webResearchWorkflow },});
Et maintenant demandons plus d’informations sur une actualité :
Conclusion
J’espère que ce tutoriel vous donnera envie d’utiliser Mastra, c’est un framework TypeScript agréable à utiliser avec une communauté active et un support rapide de la part des fondateurs.
Le repo de ce tutoriel : https://github.com/Killian-Aidalinfo/discord-bot-actu
De plus, si vous souhaitez participer à la construction communautaire d’agents et workflows vous pouvez participer ici : https://github.com/aidalinfo/tsai-registry
C’est tout pour moi !
n’hésitez pas à me suivre sur les réseaux sociaux :
-
Youtube ➡️ https://www.youtube.com/@civilisationit
-
X (Ancien Twitter) ➡️ https://x.com/Ninapepite_
-
LinkedIn ➡️ https://www.linkedin.com/in/killian-stein-4465b81a2/