Les points clés de ce retour d'expérience :
- Problématique de dualité : Permettre aux membres d'un serveur Discord de savoir qui est connecté en vocal sur un serveur TeamSpeak 6 sans devoir installer ou ouvrir TS.
- Pattern Bot Pool : Allocation dynamique d'un pool de bots clones Discord (Client discord.js) pour matérialiser chaque utilisateur TeamSpeak par une présence vocale distincte.
- Interrogation Server Query HTTP : Consommation des endpoints HTTP nativement exposés par TeamSpeak 6 (/clientlist et /channellist) via des requêtes authentifiées par x-api-key.
- Gestion d'identité & Statuts AFK : Traitement des pseudos, tronquage sous la limite de 32 caractères de Discord et détection des canaux AFK avec mise à jour automatique des suffixes [Vocal] / [AFK].
- Boucle de réconciliation réentrante : Alignement périodique (toutes les 30 secondes) entre l'état physique du serveur TeamSpeak et les connexions vocales réelles des bots Discord.
- Conteneurisation & Déploiement : Packaging sous forme de conteneur Docker léger (node:20-alpine) pilotable via Docker Compose et un script d'automatisation.
1. Contexte et problématique de dualité
Dans de nombreuses communautés de joueurs ou d'équipes techniques, l'infrastructure vocale est parfois séparée entre deux univers : un serveur TeamSpeak pour la qualité audio, la faible latence ou les habitudes d'administration, et un serveur Discord pour les salons textuels, les annonces et le partage de médias.
Cette séparation crée une contrainte d'usage : les membres présents sur Discord ne voient pas qui est actif en vocal sur TeamSpeak. Pour savoir si des amis ou des collègues sont en train de discuter, il faut soit ouvrir le client TeamSpeak, soit poser la question par écrit.
Pour répondre à ce besoin, j'ai développé DiscoTS, une application Node.js auto-hébergée qui interroge régulièrement l'API Server Query HTTP de TeamSpeak 6. Pour chaque utilisateur connecté sur TeamSpeak, l'application emprunte un bot disponible dans un pool de bots Discord, le fait rejoindre un salon vocal dédié (par exemple 🗣️ On TS6 🔵) et adapte son pseudo en temps réel.
2. Architecture globale et le pattern "Bot Pool"
Sur Discord, une instance unique de bot ne peut pas être présente plusieurs fois simultanément dans le même canal vocal sous des identités distinctes. Chaque membre affiché dans la liste des participants d'un salon vocal correspond à une connexion WebSocket et un jeton d'authentification (BOT_TOKEN) propre.
Pour représenter N utilisateurs TeamSpeak dans Discord, DiscoTS s'appuie sur le pattern d'allocation dynamique Bot Pool. Au démarrage de l'application, un gestionnaire instancie un ensemble de bots clones Discord à partir d'une liste de tokens configurés dans l'environnement.
3. Interrogation de l'API Server Query HTTP de TeamSpeak 6
TeamSpeak 6 intègre une interface HTTP Server Query permettant d'exécuter des commandes de gestion via des requêtes REST simples authentifiées par l'en-tête x-api-key.
La classe UserFetcher prend en charge la récupération des données en interrogeant deux endpoints : /clientlist?-groups pour obtenir la liste des clients et /channellist pour connaître le nom des canaux sur lesquels ils se trouvent.
import http from 'http';
import dotenv from 'dotenv';
dotenv.config();
export default class UserFetcher {
constructor() {
this.hostname = process.env.TS_HOST_NAME;
this.port = process.env.TS_HOST_PORT;
this.apiKey = process.env.TS_API_KEY;
}
async fetchClientList() {
return new Promise((resolve, reject) => {
const req = http.request({
hostname: this.hostname,
port: this.port,
path: '/clientlist?-groups',
method: 'GET',
headers: {
'x-api-key': this.apiKey
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try { resolve(JSON.parse(data)); }
catch (e) { reject(e); }
} else {
reject(new Error(`HTTP status code ${res.statusCode}`));
}
});
});
req.on('error', reject);
req.end();
});
}
async fetchChannelList() {
return new Promise((resolve, reject) => {
const req = http.request({
hostname: this.hostname,
port: this.port,
path: '/channellist',
method: 'GET',
headers: {
'x-api-key': this.apiKey
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try { resolve(JSON.parse(data)); }
catch (e) { reject(e); }
} else {
reject(new Error(`HTTP status code ${res.statusCode}`));
}
});
});
req.on('error', reject);
req.end();
});
}
}
4. Gestion du pool de bots Discord et connexions vocales
Le composant BotPoolManager initialise chaque instance de Client Discord avec les intentions nécessaires (GatewayIntentBits.Guilds et GatewayIntentBits.GuildVoiceStates). Il conserve l'état d'occupation de chaque bot (isOccupied) et fournit des méthodes pour trouver une instance libre, la connecter ou la déconnecter.
import { Client, GatewayIntentBits } from 'discord.js';
import { joinVoiceChannel, getVoiceConnection } from '@discordjs/voice';
export default class BotPoolManager {
#botPool = [];
constructor(tokens) {
tokens.forEach((token, index) => {
if (!token) return;
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates
]
});
client.once('ready', () => {
console.log(`Le Clone #${index + 1} (${client.user.tag}) est prêt !`);
client.isOccupied = false;
client.currentNickname = null;
client.currentGuildId = null;
});
client.login(token).catch(err => {
console.error(`Impossible de connecter le clone #${index + 1} :`, err.message);
});
this.#botPool.push(client);
});
}
areBotsReady() {
if (this.#botPool.length === 0) return false;
return this.#botPool.every(bot => bot.isReady());
}
getPool() {
return this.#botPool;
}
findAvailableBot() {
return this.#botPool.find(bot => !bot.isOccupied);
}
async connectBotToVoiceChannel(bot, voiceChannel) {
bot.isOccupied = true;
bot.currentGuildId = voiceChannel.guild.id;
try {
joinVoiceChannel({
channelId: voiceChannel.id,
guildId: voiceChannel.guild.id,
adapterCreator: voiceChannel.guild.voiceAdapterCreator,
group: bot.user.id
});
console.log(`[Vocal] ${bot.user.tag} a REJOINT le canal : "${voiceChannel.name}"`);
} catch (error) {
console.error(`[Vocal] Erreur lors de la connexion de ${bot.user.tag} au canal vocal :`, error);
bot.isOccupied = false;
bot.currentGuildId = null;
}
}
async disconnectBotFromVoiceChannel(bot) {
const guildId = bot.currentGuildId;
bot.isOccupied = false;
bot.currentGuildId = null;
if (guildId) {
try {
const connection = getVoiceConnection(guildId, bot.user.id);
if (connection) {
connection.destroy();
}
console.log(`[Vocal] ${bot.user.tag} a QUITTÉ le canal vocal.`);
} catch (error) {
console.error(`[Vocal] Erreur lors de la déconnexion de ${bot.user.tag} :`, error);
}
}
}
}
5. Normalisation des identités et détection des canaux AFK
Sur TeamSpeak, des utilisateurs normaux côtoient des connexions système ou des requêtes serveur (client_type === 1). De plus, l'API retourne des identifiants de canaux qu'il faut rapprocher d'une liste de canaux considérés comme AFK (configurés via la variable TS_AFK_CHANELS).
La classe BotIdentityManager réalise plusieurs opérations de nettoyage :
- Exclusion des clients serveur (client_type !== 1).
- Vérification du canal courant : si le nom du canal correspond à l'un des canaux AFK, le suffixe [AFK] est appliqué ; sinon le suffixe [Vocal] est utilisé.
- Tronquage automatique de la base du pseudo si sa longueur totale dépasse la contrainte stricte de 32 caractères imposée par l'API Discord (32 - suffix.length).
- Mise à jour du pseudo sur le serveur Discord via me.setNickname() ou réinitialisation lors du départ.
export default class BotIdentityManager {
extractActiveNicknames(tsData, tsChannels = null) {
if (!tsData || !tsData.body) return [];
const channelMap = new Map();
if (tsChannels && tsChannels.body) {
for (const chan of tsChannels.body) {
if (chan.cid && chan.channel_name) {
channelMap.set(chan.cid.toString(), chan.channel_name);
}
}
}
const afkChannels = this.parseAfkChannels();
const activeClients = Array.from(tsData.body).filter(
c => c.client_type !== 1 && c.client_type !== '1'
);
const formattedNicknames = activeClients.map(c => {
const nickname = c.client_nickname;
if (!nickname) return null;
const cid = c.cid ? c.cid.toString() : '';
const channelName = channelMap.get(cid) || '';
const isAFK = channelName !== '' && afkChannels.includes(channelName);
const suffix = isAFK ? '[AFK]' : '[Vocal]';
const maxBaseLength = 32 - suffix.length;
const truncatedBase = nickname.slice(0, maxBaseLength);
return truncatedBase + suffix;
}).filter(Boolean);
return [...new Set(formattedNicknames)];
}
parseAfkChannels() {
const raw = process.env.TS_AFK_CHANELS;
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed.map(String);
} catch {
return raw.split(',').map(s => s.trim());
}
return [];
}
async assignNickname(bot, guild, nickname) {
const me = guild.members.me;
if (me) {
try {
await me.setNickname(nickname);
console.log(`[Pseudo] ${bot.user.tag} renommé en "${nickname}"`);
} catch (error) {
console.warn(`[Pseudo] Impossible de changer le pseudo de ${bot.user.tag} dans ${guild.name} :`, error.message);
}
}
}
async clearNickname(bot, guild) {
const me = guild?.members.me;
if (me) {
try {
await me.setNickname(null);
console.log(`[Pseudo] ${bot.user.tag} pseudo réinitialisé dans ${guild.name}.`);
} catch (error) {
console.warn(`[Pseudo] Impossible de réinitialiser le pseudo de ${bot.user.tag} dans ${guild.name} :`, error.message);
}
}
}
}
6. Provisionnement dynamique du salon vocal Discord
La classe VocalCanalManager évite toute configuration manuelle complexe sur le serveur Discord. Lorsqu'un bot cherche à se connecter, le gestionnaire parcourt les salons de la guilde à la recherche du nom configuré (par exemple 🗣️ On TS6 🔵). Si le salon n'existe pas encore, il le crée automatiquement avec le type ChannelType.GuildVoice.
import { ChannelType } from 'discord.js';
export default class VocalCanalManager {
#channelName;
constructor(channelName) {
this.#channelName = channelName;
}
async findOrCreateChannel(client) {
for (const guild of client.guilds.cache.values()) {
const channel = guild.channels.cache.find(c => c.name === this.#channelName && c.isVoiceBased());
if (channel) return channel;
}
const firstGuild = client.guilds.cache.first();
if (firstGuild) {
try {
const channel = await firstGuild.channels.create({
name: this.#channelName,
type: ChannelType.GuildVoice,
});
console.log(`[Vocal] Canal vocal créé : "${this.#channelName}" dans le serveur "${firstGuild.name}"`);
return channel;
} catch (error) {
console.error(`[Vocal] Impossible de créer le canal vocal "${this.#channelName}":`, error);
}
}
return null;
}
getBotCurrentChannel(bot) {
for (const guild of bot.guilds.cache.values()) {
const me = guild.members.me;
if (me?.voice?.channel) {
return me.voice.channel;
}
}
return null;
}
}
7. La boucle de synchronisation réentrante
Le cœur de l'application réside dans la fonction syncBotsWithTeamSpeak() située dans src/index.js. Exécutée toutes les 30 secondes, elle applique l'algorithme de réconciliation suivant :
- Vérifier que l'ensemble des bots de la réserve est prêt (poolManager.areBotsReady()).
- Récupérer la liste des utilisateurs et salons TeamSpeak.
- Auditer l'état réel des connexions vocales des bots Discord pour resynchroniser les structures internes (isOccupied, currentNickname).
- Pour les bots actuellement connectés dont le pseudo n'est plus présent dans la liste TeamSpeak : réinitialiser leur pseudo et les déconnecter du canal vocal.
- Pour chaque nouvel utilisateur TeamSpeak sans bot attribué : réserver un bot disponible dans le pool, le connecter au salon vocal et lui assigner le pseudo nettoyé.
async function syncBotsWithTeamSpeak() {
if (!poolManager.areBotsReady()) {
console.log("Attente de l'initialisation complète de tous les bots...");
return;
}
try {
const tsClients = await userFetcher.fetchClientList();
let tsChannels = { body: [] };
try {
tsChannels = await userFetcher.fetchChannelList();
} catch (error) {
console.warn(`[UserFetcher] Impossible de récupérer la liste des canaux TS :`, error.message);
}
const activeNicknames = identityManager.extractActiveNicknames(tsClients, tsChannels);
console.log(`\n--- Synchronisation (${new Date().toLocaleTimeString()}) ---`);
console.log(`Utilisateurs TS actifs :`, activeNicknames);
const bots = poolManager.getPool();
// 1. Synchroniser l'état interne en fonction des connexions réelles
for (const bot of bots) {
const currentChannel = vocalManager.getBotCurrentChannel(bot);
if (currentChannel && currentChannel.name === VOICE_CHANNEL_NAME) {
bot.isOccupied = true;
bot.currentGuildId = currentChannel.guild.id;
if (!bot.currentNickname) {
const me = currentChannel.guild.members.me;
bot.currentNickname = me?.nickname || null;
}
} else {
bot.isOccupied = false;
bot.currentNickname = null;
bot.currentGuildId = null;
}
}
// 2. Déconnecter les bots dont l'utilisateur TS est parti
const nicknamesToConnect = [...activeNicknames];
for (const bot of bots) {
if (bot.isOccupied && bot.currentNickname) {
if (!nicknamesToConnect.includes(bot.currentNickname)) {
console.log(`L'utilisateur "${bot.currentNickname}" n'est plus en ligne.`);
const guild = bot.guilds.cache.get(bot.currentGuildId);
if (guild) {
await identityManager.clearNickname(bot, guild);
}
await poolManager.disconnectBotFromVoiceChannel(bot);
bot.currentNickname = null;
} else {
const index = nicknamesToConnect.indexOf(bot.currentNickname);
if (index > -1) {
nicknamesToConnect.splice(index, 1);
}
}
}
}
// 3. Connecter un bot libre pour chaque nouveau nickname actif
for (const nickname of nicknamesToConnect) {
const availableBot = poolManager.findAvailableBot();
if (!availableBot) {
console.log(`Plus de bot disponible pour connecter à "${nickname}"`);
break;
}
const voiceChannel = await vocalManager.findOrCreateChannel(availableBot);
if (voiceChannel) {
await poolManager.connectBotToVoiceChannel(availableBot, voiceChannel);
await identityManager.assignNickname(availableBot, voiceChannel.guild, nickname);
availableBot.currentNickname = nickname;
}
}
} catch (error) {
console.error("Erreur lors de la synchronisation :", error);
}
}
8. Configuration du serveur TeamSpeak 6 et conteneurisation Docker
Pour mettre en œuvre le projet dans mon environnement d'administration, le serveur TeamSpeak 6 tournait dans un conteneur Docker officiel (teamspeaksystems/teamspeak6-server).
Afin de permettre l'accès aux endpoints HTTP Server Query et de générer une clé d'API, il a fallu configurer les deux variables d'environnement suivantes sur le conteneur TeamSpeak :
- TSSERVER_QUERY_SSH_ENABLED=true : permet de se connecter au serveur Query en SSH pour l'administration initiale.
- TSSERVER_QUERY_HTTP_ENABLED=true : active l'interface web HTTP consommée par DiscoTS.
Une fois le service SSH démarré, la clé d'API s'obtient en se connectant en SSH au serveur (exemple : ssh serveradmin@127.0.0.1 -p 10022) puis en exécutant la séquence de commandes suivantes :
use 1
apikeyadd scope=read lifetime=0
Explication des commandes Server Query : La commande use 1 sélectionne le serveur virtuel numéro 1 (le serveur principal). La commande apikeyadd scope=read lifetime=0 génère une clé d'API permanente en lecture seule, garantissant que le bot ne dispose d'aucun droit de modification ou d'exclusion sur le serveur TeamSpeak.
Du côté de DiscoTS, l'application est conteneurisée via le fichier compose.yaml suivant :
services:
discots:
build: .
container_name: discots
restart: always
env_file:
- .env
Et les variables de configuration sont définies dans le fichier .env à partir de la matrice d'options :
| Variable d'environnement | Description | Exemple de valeur |
|---|---|---|
DISCOBOTS |
Tableau JSON ou liste séparée par des virgules de tokens Discord. | ["token_bot_1", "token_bot_2"] |
TS_HOST_NAME |
Nom d'hôte ou adresse IP du serveur TeamSpeak 6. | 127.0.0.1 |
TS_HOST_PORT |
Port HTTP du Server Query TeamSpeak. | 10080 |
TS_API_KEY |
Clé d'API Server Query générée. | w9e8...XyZ |
TS_AFK_CHANELS |
Liste des noms de canaux TS considérés comme AFK. | ["AFK", "Absence"] |
VOICE_CHANNEL_NAME |
Nom du salon vocal Discord cible. | 🗣️ On TS6 🔵 |
9. Tableau comparatif Avant / Après
| Axe d'analyse | Avant (Sans bridge) | Après (Avec DiscoTS) |
|---|---|---|
| Visibilité de la présence | Masquée pour les membres présents uniquement sur Discord. | Visibilité instantanée dans le salon vocal Discord dédié. |
| Statut de disponibilité | Impossible de savoir si un utilisateur est actif ou en AFK. | Distinction explicite via les suffixes [Vocal] et [AFK]. |
| Supervision serveur | Nécessite la connexion SSH ou le client TS3/TS6. | Consultation directe depuis l'application mobile ou dekstop Discord. |
| Administration & Droits | Création manuelle de webhooks ou scripts lourds. | Déploiement léger via Docker et clé d'API en lecture seule. |
10. Compromis techniques et limites du système
Le développement de DiscoTS m'a amené à faire plusieurs choix de conception guidés par la simplicité et la légèreté :
- Capacité du pool de bots : La capacité de représentation simultanée est directement bornée par le nombre de jetons Discord configurés dans la variable DISCOBOTS. Si 15 personnes se connectent sur TeamSpeak mais que 10 tokens sont fournis, les 5 derniers utilisateurs ne seront pas affichés.
- Gestion des Rate Limits Discord : L'API Discord applique des règles strictes sur la fréquence de changement de pseudo (setNickname). L'intervalle de synchronisation fixé à 30 secondes évite d'atteindre les limites de débit lors des mouvements réguliers d'utilisateurs.
- Pas de retransmission audio : DiscoTS est un bridge de présence visuelle et non un relais vocal birectionnel. Les bots ne véhiculent pas le flux sonore entre TeamSpeak et Discord, ce qui préserve les ressources CPU et la bande passante du serveur.
L'ensemble du code source de l'application est disponible sur le dépôt GitHub Thaishery/DiscoTs.