const {LogType} = require('loguix') const wlog = new LogType("Server") const SafeMetric = require("../utils/SafeMetric") const fs = require("fs") const path = require("path") const {Server} = require('socket.io') const {createServer} = require('http') const session = require("../server/auth/Session") const users = require("../server/auth/User") const players = require("../player/Player") const {Player} = require("../player/Player") const discordBot = require("../discord/Bot") const discordAuth = require("../server/auth/DiscordAuth") const {Report} = require("../discord/ReportSender") const Finder = require("../player/Finder") const {__glob} = require("../utils/GlobalVars") const playlists = require("../playlists/PlaylistManager") const guildPlaylists = require("../playlists/GuildPlaylists") const playlistUtils = require("../playlists/PlaylistUtils") const history = require("../playlists/History") const lyrics = require("../lyrics/Lyrics") const serverSettings = require("../discord/ServerSettings") const mediaBase = require("../discord/MediaBase") const googleApis = require("../playlists/Google/OAuth2") const configuration = require("../utils/Database/Configuration") const { List } = require('../player/List') const Admin = require("./Admin") const { Song } = require('../player/Song') const { getMediaInformationFromUrl } = require('../media/MediaInformation') // Suivi des connexions : la source de vérité est io.sockets.sockets (socket.data.userId posé à la connexion) // et les rooms Socket.IO ("user:", ""). Les listes de présence en sont dérivées à la demande : // un utilisateur connecté sur plusieurs appareils reste présent tant qu'il lui reste un socket dans la room. var io = null // Délai de regroupement des mises à jour de présence (rafales de connexions / changements de guilde) const USERS_UPDATE_DELAY = 200 // Dernier état de connexion vocale connu par guilde (pour ne notifier /GUILD/UPDATE qu'aux changements) const lastPlayerConnected = new Map() /** * Identifiants distincts des utilisateurs ayant au moins un socket dans la room * @param {string} room * @returns {Set} */ function roomUserIds(room) { const ids = new Set() if(!io) return ids const socketIds = io.sockets.adapter.rooms.get(room) if(!socketIds) return ids for(const socketId of socketIds) { const userId = io.sockets.sockets.get(socketId)?.data?.userId if(userId) ids.add(userId) } return ids } function roomSize(room) { return io?.sockets.adapter.rooms.get(room)?.size || 0 } /** * Identités publiques des utilisateurs connectés sur une guilde (une entrée par utilisateur, quel que soit le nombre d'appareils) * @param {string} guildId * @returns {Array<{id: string, username: string, global_name: string, avatar: string}>} */ function getGuildConnectedUsers(guildId) { const list = [] if(!guildId) return list for(const userId of roomUserIds(guildId)) { const user = users.getUserById(userId) if(!user || user.isDeleted()) continue list.push({ id: user.identity.id, username: user.identity.username, global_name: user.identity.global_name, avatar: user.identity.avatar, }) } return list } // Notifie les membres d'une guilde (et les sockets présents dans sa room) que la liste des serveurs a changé function notifyGuildUpdate(guildId) { if(!io) return const guild = discordBot.getGuilds().get(guildId) const rooms = [guildId] for(const memberId of guild?.allMembers || []) rooms.push("user:" + memberId) io.to(rooms).emit("/GUILD/UPDATE") } // Playlists partagées : messages par défaut des refus (enveloppe {ok:false, error, message}) const GUILD_PLAYLIST_ERRORS = { NO_GUILD: "Aucun serveur actif ou accès refusé à ce serveur", DISABLED: "Les playlists partagées sont désactivées sur ce serveur", FORBIDDEN: "Vous n'avez pas les droits nécessaires sur les playlists de ce serveur", NOT_FOUND: "Playlist introuvable", INVALID_PARAMS: "Paramètres invalides", LIMIT: "Limite atteinte", FAILED: "L'opération a échoué", } function refusal(error, message) { return { ok: false, error, message: message || GUILD_PLAYLIST_ERRORS[error] || GUILD_PLAYLIST_ERRORS.FAILED } } function payloadObject(data) { return data !== null && typeof data === "object" && !Array.isArray(data) ? data : {} } /** * Identifiant de playlist : valeur brute (ancien client) ou {id} / {playlistId} * @returns {string|null} */ function playlistIdFrom(data) { if(typeof data === "string" || typeof data === "number") return playlistUtils.parsePlaylistId(data) const payload = payloadObject(data) return playlistUtils.parsePlaylistId(payload.id ?? payload.playlistId) } /** * Création de playlist {name, url?} : nom de 1 à 100 caractères sans lien ; avec un lien (2000 caractères max), * un nom non vide remplace le titre importé (un nom identique au lien, envoyé par l'ancien client, est ignoré) * @returns {{name: string|null, url: string|null}|null} */ function parseCreateInput(data) { const payload = payloadObject(data) const hasUrl = payload.url !== undefined && payload.url !== null && payload.url !== "" if(hasUrl) { const url = playlistUtils.parseUrl(payload.url) if(!url) return null let name = null if(typeof payload.name === "string" && payload.name.trim() !== "" && payload.name.trim() !== url) { name = playlistUtils.parseName(payload.name) if(!name) return null } else if(payload.name !== undefined && payload.name !== null && typeof payload.name !== "string") { return null } return { name, url } } const name = playlistUtils.parseName(payload.name) return name ? { name, url: null } : null } /** * Identifiant de musique (chaîne ou nombre non vide) */ function songIdFrom(value) { if(typeof value === "number" && Number.isFinite(value)) return String(value) if(typeof value === "string" && value !== "" && value.length <= 200) return value return null } /** * Statistiques publiques d'un serveur (accueil) : nombres finis, positifs, arrondis */ function guildStatsOf(guildId) { const values = new Map() for(const metric of SafeMetric.getGuildMetrics(guildId) || []) { if(metric && typeof metric.name === "string") values.set(metric.name, metric.value) } const read = (name) => { const n = Number(SafeMetric.toSafeNumber(values.get(name + "_" + guildId), 0)) return Number.isFinite(n) && n > 0 ? Math.round(n) : 0 } return { musicPlayed: read("numberOfMusicPlayed"), secondsPlayed: read("numberOfSecondsPlayed"), commands: read("numberOfCommands"), } } /** * Appel protégé du module de paroles : toute absence, exception ou réponse mal formée devient UNAVAILABLE */ async function callLyrics(functionName, ...args) { const fn = lyrics && typeof lyrics[functionName] === "function" ? lyrics[functionName] : null if(!fn) return { ok: false, error: "UNAVAILABLE" } try { const result = await fn(...args) if(result && typeof result === "object" && typeof result.ok === "boolean") return result wlog.warn("Réponse inattendue du module de paroles (" + functionName + ")") return { ok: false, error: "UNAVAILABLE" } } catch(e) { wlog.error("Erreur du module de paroles (" + functionName + ") : " + (e?.stack || e)) return { ok: false, error: "UNAVAILABLE" } } } function init() { wlog.step.init("server_init", "Initialisation du serveur Socket.IO") // Le handler HTTP n'est appelé par socket.io que pour les requêtes hors /socket.io/ const httpServer = createServer((req, res) => { // Route de santé (healthcheck Docker / supervision) if(req.method === "GET" && req.url.split("?")[0] === "/health") { res.writeHead(200, {"Content-Type": "application/json"}) return res.end(JSON.stringify({ok: true, uptime: process.uptime()})) } res.writeHead(404, {"Content-Type": "application/json"}) res.end(JSON.stringify({ok: false, error: "NOT_FOUND"})) }) io = new Server(httpServer, { cors: { origin: "*" }, maxHttpBufferSize: 300 * 1024 * 1024 }) // Interface d'administration : poussées globales vers les rooms ADMIN / ADMIN_LOGS (une seule fois) Admin.init(io) // État d'un player (regroupé par Player.markDirty) : envoyé uniquement aux sockets de sa guilde. // On ne crée JAMAIS de player ici (lecture de AllPlayers seulement). process.on("PLAYER_STATE", (guildId) => { if(!io || !guildId) return try { const player = players.AllPlayers.get(guildId) if(roomSize(guildId) > 0) { io.to(guildId).emit("/PLAYER/UPDATE", player ? player.getState() : players.offlineState(guildId)) } // La liste des serveurs (pastille "connecté") ne change qu'à la connexion / déconnexion du bot const connected = !!(player && player.isConnected()) if(lastPlayerConnected.get(guildId) !== connected) { lastPlayerConnected.set(guildId, connected) notifyGuildUpdate(guildId) } } catch(e) { wlog.error("Erreur lors de l'envoi de l'état du player de la guilde " + guildId + " : " + (e?.stack || e)) } }) var usersUpdateTimer = null process.on("USERS_UPDATE", () => { if(!io || usersUpdateTimer) return usersUpdateTimer = setTimeout(() => { usersUpdateTimer = null try { // Listes recalculées à partir des sockets réellement connectés for(const guildId of discordBot.getGuilds().keys()) { if(roomSize(guildId) === 0) continue io.to(guildId).emit("/USERS/UPDATE", {"id": guildId, "members": getGuildConnectedUsers(guildId)}) } io.sockets.emit("/USER/READY") } catch(e) { wlog.error("Erreur lors de la mise à jour des utilisateurs connectés : " + (e?.stack || e)) } }, USERS_UPDATE_DELAY) if(typeof usersUpdateTimer.unref === "function") usersUpdateTimer.unref() }) process.on("VOCAL_UPDATE", async () => { if(io) { io.sockets.emit("/CHANNEL/UPDATE") } }) // Compte Google : statut poussé à toutes les sessions de l'utilisateur (liaison, synchronisation, révocation) process.on("GOOGLE_UPDATE", (userId) => { if(!io || !userId) return try { io.to("user:" + userId).emit("/GOOGLE/STATUS/UPDATE", googleApis.getStatus(userId)) } catch(e) { wlog.error("Erreur lors de l'envoi du statut Google : " + (e?.stack || e)) } }) // Playlists modifiées hors d'une requête de l'utilisateur (synchronisation YouTube, playlist reçue) process.on("PLAYLISTS_UPDATE", (userId) => { if(!io || !userId) return io.to("user:" + userId).emit("/PLAYLISTS/UPDATE") }) // Playlists partagées d'un serveur (contenu ou réglage modifié) : sockets présents sur ce serveur process.on("GUILD_PLAYLISTS_UPDATE", (guildId) => { if(!io || typeof guildId !== "string" || guildId === "") return try { io.to(guildId).emit("/GUILD/PLAYLISTS/UPDATE", { guildId }) } catch(e) { wlog.error("Erreur lors de l'envoi de la mise à jour des playlists partagées : " + (e?.stack || e)) } }) io.on("connection", async (socket) => { var socketUser; var disconnectHandled = false socket.on("disconnect", (info) => { handleDisconnect() wlog.log("Déconnexion du client : " + socket.id + " - Raison : " + info) }) socket.on("error", (error) => { wlog.error("Erreur sur le socket : " + socket.id + " - " + error) }) // Make sure Discord Bot is loaded and make an interruption until it is loaded while(!await discordBot.isReady()) { wlog.warn("Attente de traitement : "+ socket.id + " : Le bot Discord n'est pas encore chargé, attente de 0.5 seconde... (Avoid Rate Limit)") await new Promise(resolve => setTimeout(resolve, 500)) if(socket.disconnected) return } wlog.log(`Connexion d'un client : ${socket.id}`) if(!socket.handshake.auth || typeof socket.handshake.auth !== "object") { wlog.warn("Authentification manquant pour le client :" + socket.id) sendSession() return } var token = socket.handshake.auth.token var sessionId = socket.handshake.auth.sessionId var auth_code = socket.handshake.auth.auth_code var inLogin = false if(sessionId) { if(!session.checkSession(sessionId)) { wlog.warn("Session invalide pour le client : " + socket.id) sendSession() return; } else { if(auth_code) { const discordUser = await discordAuth.getDiscordUser(sessionId, auth_code) session.removeSession(sessionId) // Toute réponse sous forme de chaîne (ou sans identité) est une erreur if(typeof discordUser === "string" || !discordUser?.identity?.id) { wlog.warn("Erreur lors de la récupération des informations de l'utilisateur Discord associé à la session : " + sessionId) socket.emit("AUTH_ERROR", typeof discordUser === "string" ? discordUser : "USER_INFO_ERROR") socket.disconnect() return } else { const loggedUser = await users.addUser(discordUser.auth, discordUser.identity) if(!loggedUser) { socket.emit("AUTH_ERROR", "USER_INFO_ERROR") socket.disconnect() return } const newToken = await loggedUser.createToken() socket.emit("NEW_TOKEN", newToken) token = newToken inLogin = true wlog.log("Utilisateur Discord associé à la session : " + sessionId + " récupéré avec succès") } } else { wlog.warn("Code d'authentification manquant pour le client :" + socket.id) socket.emit("AUTH_ERROR", "Code manquant invalide") socket.disconnect() return } } } if(!token) { wlog.warn("Token manquant pour le client :" + socket.id) socket.emit("AUTH_ERROR", "Token invalide") sendSession() return } socketUser = users.getUserByToken(token) if(!socketUser) { wlog.warn("Token invalide pour le client :" + socket.id) socket.emit("AUTH_ERROR", "Token invalide") sendSession() return } else { if(!socketUser.auth) { wlog.warn("L'utilisateur '" + socketUser.identity.username + "' n'a pas d'authentification Discord Valide") socketUser.clearToken() socket.emit("AUTH_ERROR", "L'authentification Discord de l'utilisateur n'est pas valide") socket.disconnect() return } if(!inLogin) { if(socketUser.needUpdate()) { if (!(await users.updateIdentity(socketUser.identity.id))) { wlog.error("Erreur lors de la mise à jour des informations de l'utilisateur : " + socketUser.identity.id); socket.emit("AUTH_ERROR", "Mise à jour des informations de l'utilisateur impossible"); wlog.log("Déconnexion de l'utilisateur : " + socketUser.identity.username + " (" + socketUser.identity.id + ") - Socket : " + socket.id) socket.disconnect(); return; } socketUser.justUpdated() } else { wlog.log("Pas de mise à jour des informations de l'utilisateur : " + socketUser.identity.id + " car l'utilisateur vient de se connecter") } } else { wlog.log("L'utilisateur '" + socketUser.identity.username + "' s'est connecté via la session : " + sessionId) } } // Client parti pendant les traitements asynchrones : rien à enregistrer if(socket.disconnected) return socketUser = users.getUserByToken(token) if(socketUser) { var actualGuildId = null // Bannissement global : on coupe AVANT d'enregistrer le moindre handler if(socketUser.isFullBanned()) { wlog.warn("Utilisateur banni : " + socketUser.identity.username + " (" + socketUser.identity.id + ") - Socket : " + socket.id) socket.emit("AUTH_ERROR", "Vous êtes banni du serveur") socket.disconnect(true) return } // Plusieurs appareils / onglets par utilisateur : chaque socket est rattaché à l'id de l'utilisateur // et à la room "user:" (déconnexion, ban et suppression agissent sur toutes ses sessions) socket.data.userId = socketUser.identity.id socket.join("user:" + socketUser.identity.id) wlog.log("Utilisateur connecté : " + socketUser.identity.username + " (" + socketUser.identity.id + ") - Socket : " + socket.id) if(socketUser.isAdmin()) { socket.join(Admin.ADMIN_ROOM) wlog.log("Utilisateur admin identifié : " + socketUser.identity.username + " (" + socketUser.identity.id + ")") } process.emit("USERS_UPDATE") // USERS // CHECKED : 24/04/2025 IORequest("/USER/INFO", () => { // socketUser est relu par IORequest ; copie de l'identité pour ne pas écrire isAdmin dans users.json var guildPresents = getUserGuilds(); IOAnswer("/USER/INFO", { identity: {...socketUser.identity, isAdmin: socketUser.isAdmin()}, guilds: guildPresents, labels: socketUser.labels, history: history.getPersonalHistory(socketUser.identity.id), }, true) wlog.log("[USUAL] - Envoi des informations Discord de '" + socketUser.identity.id + "' à '" + socket.id + "'" ) }, true) IORequest("/USER/HISTORY", () => { IOAnswer("/USER/HISTORY", history.getPersonalHistory(socketUser.identity.id), true) }, true) //CHECKED : 24/04/2025 IORequest("/USER/SIGNOUT", () => { socketUser.removeToken(token) IOAnswer("/USER/SIGNOUT", true) socket.disconnect() }) IORequest("/USER/DELETE", async () => { const userId = socketUser.identity.id socketUser.removeToken(token) // deleteAccount supprime aussi les jetons Google (révoqués chez Google) await users.deleteAccount(userId) await playlists.deleteUserPlaylists(userId) await history.clearPersonalHistory(userId) IOAnswer("/USER/DELETE", true) // Toutes les sessions de l'utilisateur (autres appareils compris) io.in("user:" + userId).disconnectSockets(true) process.emit("USERS_UPDATE") }) // CHECKED : 24/04/2025 IORequest("/USERS/LIST", () => { if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/USERS/LIST", false) IOAnswer("/USERS/LIST", getGuildConnectedUsers(actualGuildId)) }) IORequest("/VERSION", () => { IOAnswer("/VERSION", __glob.VERSION) }) IORequest("/CHANGELOG", () => { const changelogPath = __glob.CHANGELOG_PATH if(!fs.existsSync(changelogPath)) { wlog.warn("Aucun changelog trouvé à l'emplacement : " + changelogPath) return IOAnswer("/CHANGELOG", false) } const changelogContent = fs.readFileSync(changelogPath, "utf-8") IOAnswer("/CHANGELOG", changelogContent, true) }, true) // PLAYERS // PAROLES (LRCLIB, repli lyrics.ovh) : réponses {ok:true, ...} ou {ok:false, error} // NO_MUSIC | NOT_FOUND | UNAVAILABLE | INVALID_PARAMS, par acquittement ou par l'événement du même nom // Paroles de la musique en cours sur le serveur actif (jamais de création de player) IORequest("/PLAYER/LYRICS", async (_, ack) => { const reply = (value) => answer("/PLAYER/LYRICS", value, ack) if(!checkUserGuild(socketUser, actualGuildId)) return reply({ ok: false, error: "NO_MUSIC" }) const song = players.AllPlayers.get(actualGuildId)?.queue?.current if(!song) return reply({ ok: false, error: "NO_MUSIC" }) reply(await callLyrics("getLyricsForSong", song)) }) // Recherche manuelle : {q} ou texte (200 caractères max) IORequest("/LYRICS/SEARCH", async (data, ack) => { const reply = (value) => answer("/LYRICS/SEARCH", value, ack) const raw = typeof data === "string" ? data : payloadObject(data).q const query = typeof raw === "string" ? raw.trim() : "" if(query === "" || query.length > 200) return reply({ ok: false, error: "INVALID_PARAMS" }) reply(await callLyrics("searchCandidates", query)) }) // Paroles d'un résultat de recherche : {id} (identifiant LRCLIB, entier positif) IORequest("/LYRICS/GET", async (data, ack) => { const reply = (value) => answer("/LYRICS/GET", value, ack) const raw = typeof data === "number" || typeof data === "string" ? data : payloadObject(data).id const id = typeof raw === "string" && /^\d{1,15}$/.test(raw.trim()) ? Number(raw.trim()) : raw if(!Number.isSafeInteger(id) || id <= 0) return reply({ ok: false, error: "INVALID_PARAMS" }) reply(await callLyrics("getById", id)) }) //CHECKED : 03/05/2025 IORequest("/PLAYER/PREVIOUS/LIST", () => { if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/PLAYER/PREVIOUS/LIST", false) const list = new List(actualGuildId) IOAnswer("/PLAYER/PREVIOUS/LIST", list.getPrevious()) }) // ChECKED : 03/05/2025 IORequest("/GUILD/JOIN", async (guildId) => { if(typeof guildId !== "string" || !checkUserGuild(socketUser, guildId)) return IOAnswer("/GUILD/JOIN", false) if(socket.rooms.has(guildId)) { wlog.warn("[USUAL] - L'utilisateur '" + socketUser.identity.username + "' est déjà dans la room de la guilde : " + guildId) actualGuildId = guildId IOAnswer("/GUILD/JOIN", true, true) return } // Quitte uniquement les rooms des autres guildes : les rooms personnelles ("user:"), // d'administration (ADMIN, ADMIN_LOGS) et la room du socket sont conservées for(const room of [...socket.rooms]) { if(isPersistentRoom(room) || room === guildId) continue socket.leave(room) wlog.log("L'utilisateur '" + socketUser.identity.username + "' quitte la room de la guilde: " + room) } socket.join(guildId) wlog.log("L'utilisateur '" + socketUser.identity.username + "' rejoint la room de la guilde : " + guildId) actualGuildId = guildId IOAnswer("/GUILD/JOIN", true, true) // État actuel du player pour ce seul socket (aucune diffusion globale) const player = players.AllPlayers.get(guildId) socket.emit("/PLAYER/UPDATE", player ? player.getState() : players.offlineState(guildId)) process.emit("USERS_UPDATE") }, true) IORequest("/GUILD/INFO", () => { if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/GUILD/INFO", false) const guild = discordBot.getGuilds().get(actualGuildId) if(!guild) { wlog.warn("Aucune guilde trouvée pour l'id : " + actualGuildId) return IOAnswer("/GUILD/INFO", false) } const player = players.AllPlayers.get(actualGuildId) const guildData = { id: guild.id, name: guild.name, icon: guild.icon, owner: guild.owner, members: new Array(), serverMember: guild.allMembers.length, connected: !!(player && player.isConnected()), labels: socketUser.labels, isOwner: socketUser.identity.id === guild.owner, isMod: socketUser.isMod(actualGuildId), isAdmin: socketUser.isAdmin(), } guildData.members = connectedMembers(actualGuildId, guild.owner) // Option et droits des playlists partagées (le client adapte l'interface sans requête de plus) const access = guildPlaylistsAccess() guildData.sharedPlaylists = access ? sharedPlaylistsInfo(access) : { enabled: false, editors: "members", canEdit: false, canManage: false } IOAnswer("/GUILD/INFO", guildData, true) }, true) IORequest("/GUILD/LIST", () => { const guildData = getUserGuilds() IOAnswer("/GUILD/LIST", guildData, true) }, true) // Statistiques publiques du serveur actif (accueil) : tout membre ayant accès IORequest("/GUILD/STATS", (_, ack) => { if(!checkUserGuild(socketUser, actualGuildId)) return answer("/GUILD/STATS", { ok: false, error: "NO_GUILD" }, ack, true) answer("/GUILD/STATS", { ok: true, ...guildStatsOf(actualGuildId) }, ack, true) }, true) // CHECKED : 03/05/2025 IORequest("/PLAYER/STATE", async () => { const player = await verifyPlayerAction(actualGuildId) if(!player) return IOAnswer("/PLAYER/STATE", false) IOAnswer("/PLAYER/STATE", await player.getState(), true) }, true) // CHECKED : 03/05/2025 IORequest("/PLAYER/PAUSE", () => { return handlePlayerAction(actualGuildId, (player) => player.pause(), "/PLAYER/PAUSE"); }); // CHECKED : 03/05/2025 IORequest("/PLAYER/BACKWARD", () => { return handlePlayerAction(actualGuildId, (player) => player.previous(), "/PLAYER/BACKWARD"); }); // CHECKED : 03/05/2025 IORequest("/PLAYER/FORWARD", () => { return handlePlayerAction(actualGuildId, (player) => player.skip(), "/PLAYER/FORWARD"); }); // CHECKED : 03/05/2025 IORequest("/PLAYER/LOOP", () => { return handlePlayerAction(actualGuildId, (player) => player.setLoop(), "/PLAYER/LOOP"); }); // CHECKED : 03/05/2025 IORequest("/PLAYER/SHUFFLE", () => { return handlePlayerAction(actualGuildId, (player) => player.setShuffle(), "/PLAYER/SHUFFLE"); }); // CHECKED : 03/05/2025 IORequest("/PLAYER/DISCONNECT", () => { return handlePlayerAction(actualGuildId, (player) => player.leave(), "/PLAYER/DISCONNECT"); }); // CHECKED : 03/05/2025 IORequest("/PLAYER/CHANNEL/CHANGE", () => { return handlePlayerAction(actualGuildId, (player) => { const channel = getUserChannel(false, true) if(!channel) return false return player.changeChannel(channel) }, "/PLAYER/CHANNEL/CHANGE"); }); // CHECKED : 03/05/2025 IORequest("/PLAYER/SEEK", (data) => { // Accepte { time } (client actuel) ou un nombre ; 0 est une position valide const raw = data !== null && typeof data === "object" ? data.time : data const time = Number(raw) if(raw === null || raw === undefined || raw === "" || !Number.isFinite(time) || time < 0) return IOAnswer("/PLAYER/SEEK", false) return handlePlayerAction(actualGuildId, (player) => { // Check if current is not null if(player.queue?.current == null) { wlog.warn("Le player de la guilde : " + actualGuildId + " n'a pas de musique en cours") return false } return player.setDuration(time) }, "/PLAYER/SEEK"); }); // CHECKED : 04/05/2025 IORequest("/QUEUE/PLAY", (data) => { if(!data) return IOAnswer("/QUEUE/PLAY", false) const {index, listType, now} = data if(index == null) return IOAnswer("/QUEUE/PLAY", false) if(listType == null) return IOAnswer("/QUEUE/PLAY", false) if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/QUEUE/PLAY", false) const player = new Player(actualGuildId) if(!connectToPlayer(actualGuildId, player)) return IOAnswer("/QUEUE/PLAY", false) var song; if(listType == "previous") { const previous = player.queue.getPrevious() song = previous[index] } else if(listType == "next") { const next = player.queue.getNext() song = next[index] } if(!song) return IOAnswer("/QUEUE/PLAY", false) if(listType == "next") player.queue.removeNextByIndex(index) if(now) { player.playNow(song) } else { player.add(song) } history.addToPersonalHistory(socketUser.identity.id, song) IOAnswer("/QUEUE/PLAY", true) }) // CHECKED : 04/05/2025 IORequest("/QUEUE/NEXT/DELETE", (index) => { if(index == null) return IOAnswer("/QUEUE/NEXT/DELETE", false) return handlePlayerAction(actualGuildId, (player) => { const next = player.queue.getNext() if(!next[index]) return false player.queue.removeNextByIndex(index) }, "/QUEUE/NEXT/DELETE") }) // CHECKED : 04/05/2025 IORequest("/QUEUE/NEXT/DELETEALL", () => { return handlePlayerAction(actualGuildId, (player) => player.queue.clearNext(), "/QUEUE/NEXT/DELETEALL") }) // CHECKED : 04/05/2025 IORequest("/QUEUE/NEXT/MOVE", (data) => { if(!data) return IOAnswer("/QUEUE/NEXT/MOVE", false) const {index, newIndex} = data if(index == null) return IOAnswer("/QUEUE/NEXT/MOVE", false) if(newIndex == null) return IOAnswer("/QUEUE/NEXT/MOVE", false) return handlePlayerAction(actualGuildId, (player) => { const next = player.queue.getNext() if(!next[index]) return false player.queue.moveNext(index, newIndex) }, "/QUEUE/NEXT/MOVE") }) // SEARCH // CHECKED : 24/04/2025 IORequest("/SEARCH", async (query) => { if(typeof query !== "string" || query.trim() === "" || query.length > 1000) return IOAnswer("/SEARCH", false) IOAnswer("/SEARCH", await Finder.search(query, true)) }) // CHECKED : 03/05/2025 IORequest("/SEARCH/PLAY", async (data) => { if(!data) return IOAnswer("/SEARCH/PLAY", false) var {song, now} = data if(!song) return IOAnswer("/SEARCH/PLAY", false) if(typeof song == "string") { song = JSON.parse(song) } song = new Song(song) if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/SEARCH/PLAY", false) const player = new Player(actualGuildId) if(!connectToPlayer(actualGuildId, player)) return IOAnswer("/SEARCH/PLAY", false) if(now) { player.playNow(song) } else { player.add(song) } history.addToPersonalHistory(socketUser.identity.id, song) IOAnswer("/SEARCH/PLAY", true) }) // CHECKED : 05/05/2025 IORequest("/SEARCH/PLAYLIST", async (data) => { if(!data) return IOAnswer("/SEARCH/PLAYLIST", false) const {url, now} = data if(!url || typeof url !== "string") return IOAnswer("/SEARCH/PLAYLIST", false) // Droits vérifiés AVANT de créer un player (et avant la recherche) if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/SEARCH/PLAYLIST", false) const playlist = await Finder.search(url, true, "PLAYLIST") if(!playlist) return IOAnswer("/SEARCH/PLAYLIST", false) const player = new Player(actualGuildId) if(!connectToPlayer(actualGuildId, player)) return IOAnswer("/SEARCH/PLAYLIST", false) player.readPlaylist(playlist, now) IOAnswer("/SEARCH/PLAYLIST", true) }) // Ancienne route : texte libre ou {track, artist?, duration?} → paroles du meilleur résultat IORequest("/SEARCH/LYRICS", async (data, ack) => { const reply = (value) => answer("/SEARCH/LYRICS", value, ack) const invalid = { ok: false, error: "INVALID_PARAMS" } let query if(typeof data === "string") { const text = data.trim() if(text === "" || text.length > 200) return reply(invalid) query = text } else { const payload = payloadObject(data) const title = typeof payload.track === "string" ? payload.track.trim() : "" const author = payload.artist === undefined || payload.artist === null ? "" : payload.artist if(title === "" || title.length > 200 || typeof author !== "string" || author.length > 200) return reply(invalid) let duration = 0 if(payload.duration !== undefined && payload.duration !== null) { duration = Number(payload.duration) if(!Number.isFinite(duration) || duration < 0 || duration > 86400) return reply(invalid) } query = { track: title, artist: author.trim(), duration } } // findLyrics : texte libre (« artiste titre ») ou {track, artist, duration} if(typeof lyrics?.findLyrics === "function") return reply(await callLyrics("findLyrics", query)) const song = typeof query === "string" ? { title: query, author: "", duration: 0 } : { title: query.track, author: query.artist, duration: query.duration } reply(await callLyrics("getLyricsForSong", song)) }) // UPLOAD // Fichiers convertis en Ogg/Opus puis stockés dans le salon Discord multimédia (voir MediaBase) // Réponses par ack (ou même nom d'événement) : {ok:true, ...} | {ok:false, error, message} IORequest("/UPLOAD/INFO", (_, ack) => { answer("/UPLOAD/INFO", { ok: true, ...mediaBase.getInfo() }, ack, true) }, true) IORequest("/UPLOAD/FILE", async (data, ack) => { const reply = (value) => answer("/UPLOAD/FILE", value, ack) if(!data || typeof data !== "object" || typeof data.name !== "string" || !data.name.trim()) { return reply({ ok: false, error: "INVALID_PARAMS", message: "Fichier manquant." }) } // Le contenu est vérifié par ffprobe pendant la conversion (piste audio lisible) reply(await mediaBase.postMedia({ name: data.name, file: data.file }, { id: socketUser.identity.id, username: socketUser.identity.username })) }) IORequest("/UPLOAD/FILES", async (_, ack) => { answer("/UPLOAD/FILES", await mediaBase.getAllMedia(socketUser.identity.id), ack) }) IORequest("/UPLOAD/FILE/DELETE", async (data, ack) => { if(!data) return answer("/UPLOAD/FILE/DELETE", false, ack) answer("/UPLOAD/FILE/DELETE", await mediaBase.deleteMedia(data, socketUser.identity.id), ack) }) // GOOGLE API (compte YouTube lié : import et synchronisation des playlists) // Réponses : statut = {configured, linked, valid, invalidReason, channel, linkedAt, lastSync, syncing} // actions = {ok:true, status, ...} ou {ok:false, error:CODE, message} IORequest("/GOOGLE/STATUS", (_, ack) => { answer("/GOOGLE/STATUS", googleApis.getStatus(socketUser.identity.id), ack, true) }, true) // URL de consentement Google (null si l'intégration n'est pas configurée) IORequest("/GOOGLE/AUTH", (_, ack) => { answer("/GOOGLE/AUTH", googleApis.createAuthUrl(socketUser.identity.id), ack) }) // Retour de Google : {code, state} → liaison du compte puis première synchronisation en arrière-plan const handleGoogleCallback = (requestName) => async (data, ack) => { const userId = socketUser.identity.id const code = data !== null && typeof data === "object" ? data.code : data const state = data !== null && typeof data === "object" ? data.state : null try { const status = await googleApis.linkAccount(userId, code, state) // Pas d'attente : la synchronisation peut durer, son résultat est poussé // par /GOOGLE/STATUS/UPDATE et /PLAYLISTS/UPDATE playlists.syncYoutube(userId, { force: true }).catch((e) => { wlog.error("Erreur lors de la synchronisation YouTube de " + userId + " : " + (e?.stack || e)) }) answer(requestName, { ok: true, status: { ...status, syncing: true } }, ack) } catch(e) { const error = googleApis.describeApiError(e) wlog.warn("Liaison Google refusée pour '" + socketUser.identity.username + "' : " + error.code + " - " + error.message) answer(requestName, { ok: false, error: error.code, message: error.message }, ack) } } IORequest("/GOOGLE/CALLBACK", handleGoogleCallback("/GOOGLE/CALLBACK")) // Ancien nom conservé pour compatibilité (même charge utile {code, state}) IORequest("/GOOGLE/YOUTUBE/ADD_PLAYLIST", handleGoogleCallback("/GOOGLE/YOUTUBE/ADD_PLAYLIST")) IORequest("/GOOGLE/YOUTUBE/SYNC", (_, ack) => { const userId = socketUser.identity.id const wasSyncing = googleApis.isSyncing(userId) const pending = playlists.syncYoutube(userId) // Les refus (non lié, déjà en cours, délai) sont immédiats : une synchronisation lancée marque "syncing" sans attendre if(wasSyncing || !googleApis.isSyncing(userId)) { return pending.then((result) => { answer("/GOOGLE/YOUTUBE/SYNC", { ...result, status: googleApis.getStatus(userId) }, ack) }) } pending.catch((e) => wlog.error("Erreur lors de la synchronisation YouTube de " + userId + " : " + (e?.stack || e))) answer("/GOOGLE/YOUTUBE/SYNC", { ok: true, started: true, status: googleApis.getStatus(userId) }, ack) }) IORequest("/GOOGLE/REVOKE", async (_, ack) => { const userId = socketUser.identity.id const result = await googleApis.unlink(userId, { revoke: true }) answer("/GOOGLE/REVOKE", { ok: true, ...result, status: googleApis.getStatus(userId) }, ack) }) // PLAYLISTS IORequest("/CHANNEL", () => { // Get the channel of the bot, in actualGuildId if he is connected const channel = getUserChannel(true) if(!channel) return IOAnswer("/CHANNEL", false, true) IOAnswer("/CHANNEL", channel, true) }, true) // PLAYLISTS PERSONNELLES // Réponses inchangées (playlist | false | true) mais par acquittement si le client en fournit un // (IORequestAck), sinon par l'événement du même nom. Chaque modification est poussée à toutes les // sessions de l'utilisateur (/PLAYLISTS/UPDATE) pour que ses autres appareils se mettent à jour. // CHECKED : 30/04/2025 IORequest("/PLAYLISTS/CREATE", async (data, ack) => { const reply = (value) => answer("/PLAYLISTS/CREATE", value, ack) const input = parseCreateInput(data) if(!input) return reply(false) // Identité figée avant l'import (socketUser est relu par chaque nouvelle requête) const identity = socketUser.identity const playlist = await playlists.addPlaylist(identity.id, input.name, input.url, identity.username, identity.id, identity.avatar) if(!playlist) return reply(false) personalPlaylistsChanged(identity.id) reply(playlist) }) // CHECKED : 30/04/2025 IORequest("/PLAYLISTS/DELETE", (data, ack) => { const id = playlistIdFrom(data) if(!id) return answer("/PLAYLISTS/DELETE", false, ack) const userId = socketUser.identity.id const removed = playlists.removePlaylist(userId, id) if(removed) personalPlaylistsChanged(userId) answer("/PLAYLISTS/DELETE", removed, ack) }) // CHECKED : 24/04/2025 IORequest("/PLAYLISTS/LIST", (_, ack) => { answer("/PLAYLISTS/LIST", playlists.getPlaylistsOfUser(socketUser.identity.id), ack) }) // Envoie une copie d'une playlist à un utilisateur partageant au moins un serveur // {playlistId (ou name, ancien nom du champ), toId} IORequest("/PLAYLISTS/SEND", (data, ack) => { const reply = (value) => answer("/PLAYLISTS/SEND", value, ack) const payload = payloadObject(data) const toId = payload.toId const playlistId = playlistUtils.parsePlaylistId(payload.playlistId ?? payload.id ?? payload.name) if(!playlistId || !toId || typeof toId !== "string") return reply(false) const fromId = socketUser.identity.id if(toId === fromId) return reply(false) const toUser = users.getUserById(toId) if(!toUser || toUser.isDeleted()) return reply(false) const shareGuild = [...discordBot.getGuilds().values()].some(g => g.allMembers?.includes(fromId) && g.allMembers?.includes(toId)) if(!shareGuild) return reply(false) // copyPlaylist refuse un doublon de titre chez le destinataire const copy = playlists.copyPlaylist(fromId, toId, playlistId) if(copy) process.emit("PLAYLISTS_UPDATE", toId) reply(!!copy) }) // CHECKED : 30/04/2025 IORequest("/PLAYLISTS/RENAME", (data, ack) => { const reply = (value) => answer("/PLAYLISTS/RENAME", value, ack) const payload = payloadObject(data) const id = playlistUtils.parsePlaylistId(payload.id) const newName = playlistUtils.parseName(payload.newName, 200) if(!id || !newName) return reply(false) const userId = socketUser.identity.id const playlist = playlists.renamePlaylist(userId, id, newName) if(!playlist) return reply(false) personalPlaylistsChanged(userId) reply(playlist) }) // CHECKED : 30/04/2025 IORequest("/PLAYLISTS/ADD_SONG", (data, ack) => { const reply = (value) => answer("/PLAYLISTS/ADD_SONG", value, ack) const payload = payloadObject(data) const id = playlistUtils.parsePlaylistId(payload.id) if(!id || !payload.song) return reply(false) const userId = socketUser.identity.id // null : playlist inconnue, musique invalide ou limite de titres atteinte const playlist = playlists.addSong(userId, id, payload.song) if(!playlist) return reply(false) personalPlaylistsChanged(userId) reply(playlist) }) // CHECKED : 30/04/2025 // {id, songId, index?} : index désigne l'occurrence exacte quand la musique est en double IORequest("/PLAYLISTS/REMOVE_SONG", (data, ack) => { const reply = (value) => answer("/PLAYLISTS/REMOVE_SONG", value, ack) const payload = payloadObject(data) const id = playlistUtils.parsePlaylistId(payload.id) const songId = songIdFrom(payload.songId) if(!id || !songId) return reply(false) const index = playlistUtils.parseIndex(payload.index) const userId = socketUser.identity.id const playlist = playlists.removeSong(userId, id, songId, index === null ? undefined : index) if(!playlist) return reply(false) personalPlaylistsChanged(userId) reply(playlist) }) // Déplace une musique dans une playlist : {id, from, to} IORequest("/PLAYLISTS/MOVE_SONG", (data, ack) => { const reply = (value) => answer("/PLAYLISTS/MOVE_SONG", value, ack) const payload = payloadObject(data) const id = playlistUtils.parsePlaylistId(payload.id) const from = playlistUtils.parseIndex(payload.from) const to = playlistUtils.parseIndex(payload.to) if(!id || from === null || to === null) return reply(false) const userId = socketUser.identity.id const playlist = playlists.moveSong(userId, id, from, to) if(!playlist) return reply(false) if(from !== to) personalPlaylistsChanged(userId) reply(playlist) }) // Ordre d'affichage des playlists : {order: [playlistId...]} (identifiants absents conservés à la suite) IORequest("/PLAYLISTS/REORDER", (data, ack) => { const order = payloadObject(data).order if(!Array.isArray(order)) return answer("/PLAYLISTS/REORDER", false, ack) const userId = socketUser.identity.id const done = playlists.reorderPlaylists(userId, order) if(done) personalPlaylistsChanged(userId) answer("/PLAYLISTS/REORDER", done, ack) }) // CHECKED : 05/05/2025 // {id, now} (ou {name, now} : ancien nom du champ, qui contenait déjà l'identifiant) IORequest("/PLAYLISTS/PLAY", async (data, ack) => { const reply = (value) => answer("/PLAYLISTS/PLAY", value, ack) const payload = payloadObject(data) const id = playlistUtils.parsePlaylistId(payload.id ?? payload.name) if(!id) return reply(false) if(!checkUserGuild(socketUser, actualGuildId)) return reply(false) const playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id) if(!playlist) return reply(false) const player = new Player(actualGuildId) if(!connectToPlayer(actualGuildId, player)) return reply(false) player.readPlaylist(playlist, !!payload.now) reply(true) }) // Identifiant brut ou {id} IORequest("/PLAYLISTS/REFRESH", async (data, ack) => { const reply = (value) => answer("/PLAYLISTS/REFRESH", value, ack) const id = playlistIdFrom(data) if(!id) return reply(false) const userId = socketUser.identity.id const playlist = await playlists.refreshPlaylist(userId, id) if(!playlist) return reply(false) personalPlaylistsChanged(userId) reply(playlist) }) // PLAYLISTS PARTAGÉES DU SERVEUR ACTIF // Toujours le serveur actif (actualGuildId, jamais un identifiant du client) et checkUserGuild d'abord. // Réponses {ok:true, ...} ou {ok:false, error, message} ; error : NO_GUILD, DISABLED, FORBIDDEN, // NOT_FOUND, INVALID_PARAMS, LIMIT, FAILED. Chaque modification pousse /GUILD/PLAYLISTS/UPDATE {guildId}. IORequest("/GUILD/PLAYLISTS/LIST", (_, ack) => { const access = guildPlaylistsAccess() if(!access) return answer("/GUILD/PLAYLISTS/LIST", refusal("NO_GUILD"), ack, true) answer("/GUILD/PLAYLISTS/LIST", { ok: true, guildId: access.guildId, settings: { ...access.settings }, canEdit: access.canEdit, canManage: access.canManage, // Désactivées : masquées (jamais supprimées), réapparaissent à la réactivation playlists: access.settings.enabled ? guildPlaylists.getPlaylists(access.guildId) : [], }, ack, true) }, true) GuildPlaylistRequest("/GUILD/PLAYLISTS/CREATE", async (payload, access) => { const input = parseCreateInput(payload) if(!input) return refusal("INVALID_PARAMS", "Nom (1 à 100 caractères) ou lien de playlist invalide") return guildResult(await guildPlaylists.createPlaylist(access.guildId, input, access.user), access.guildId) }, { edit: true }) // Suppression : créateur de la playlist, modérateur, propriétaire du serveur ou administrateur GuildPlaylistRequest("/GUILD/PLAYLISTS/DELETE", async (payload, access) => { const id = playlistUtils.parsePlaylistId(payload.id) if(!id) return refusal("INVALID_PARAMS") const playlist = guildPlaylists.getPlaylist(access.guildId, id) if(!playlist) return refusal("NOT_FOUND") const isCreator = !!playlist.createdBy?.id && playlist.createdBy.id === access.userId if(!isCreator && !access.isModerator) return refusal("FORBIDDEN", "Seuls le créateur de la playlist et les modérateurs peuvent la supprimer") if(!guildPlaylists.deletePlaylist(access.guildId, id)) return refusal("NOT_FOUND") guildPlaylistsChanged(access.guildId) return { ok: true } }) GuildPlaylistRequest("/GUILD/PLAYLISTS/RENAME", async (payload, access) => { const id = playlistUtils.parsePlaylistId(payload.id) const newName = playlistUtils.parseName(payload.newName) if(!id || !newName) return refusal("INVALID_PARAMS", "Nom invalide (1 à 100 caractères)") return guildResult(guildPlaylists.renamePlaylist(access.guildId, id, newName, access.user), access.guildId) }, { edit: true }) GuildPlaylistRequest("/GUILD/PLAYLISTS/ADD_SONG", async (payload, access) => { const id = playlistUtils.parsePlaylistId(payload.id) if(!id || !payload.song) return refusal("INVALID_PARAMS") return guildResult(guildPlaylists.addSong(access.guildId, id, payload.song, access.user), access.guildId) }, { edit: true }) GuildPlaylistRequest("/GUILD/PLAYLISTS/REMOVE_SONG", async (payload, access) => { const id = playlistUtils.parsePlaylistId(payload.id) const songId = songIdFrom(payload.songId) if(!id || !songId) return refusal("INVALID_PARAMS") const index = playlistUtils.parseIndex(payload.index) return guildResult(guildPlaylists.removeSong(access.guildId, id, songId, index === null ? undefined : index, access.user), access.guildId) }, { edit: true }) GuildPlaylistRequest("/GUILD/PLAYLISTS/MOVE_SONG", async (payload, access) => { const id = playlistUtils.parsePlaylistId(payload.id) const from = playlistUtils.parseIndex(payload.from) const to = playlistUtils.parseIndex(payload.to) if(!id || from === null || to === null) return refusal("INVALID_PARAMS") const result = guildPlaylists.moveSong(access.guildId, id, from, to, access.user) // Déplacement sur place : aucune poussée inutile if(result?.ok && from === to) return { ok: true, playlist: result.playlist } return guildResult(result, access.guildId) }, { edit: true }) GuildPlaylistRequest("/GUILD/PLAYLISTS/REORDER", async (payload, access) => { if(!Array.isArray(payload.order)) return refusal("INVALID_PARAMS") if(!guildPlaylists.reorder(access.guildId, payload.order)) return refusal("INVALID_PARAMS") guildPlaylistsChanged(access.guildId) return { ok: true } }, { edit: true }) // Playlists importées depuis un lien uniquement GuildPlaylistRequest("/GUILD/PLAYLISTS/REFRESH", async (payload, access) => { const id = playlistUtils.parsePlaylistId(payload.id) if(!id) return refusal("INVALID_PARAMS") return guildResult(await guildPlaylists.refreshPlaylist(access.guildId, id, access.user), access.guildId) }, { edit: true }) // Lecture : tout membre ayant accès (même logique de connexion que /PLAYLISTS/PLAY) GuildPlaylistRequest("/GUILD/PLAYLISTS/PLAY", async (payload, access) => { const id = playlistUtils.parsePlaylistId(payload.id) if(!id) return refusal("INVALID_PARAMS") const playlist = guildPlaylists.getPlaylist(access.guildId, id) if(!playlist) return refusal("NOT_FOUND") const player = new Player(access.guildId) if(!connectToPlayer(access.guildId, player)) return refusal("FAILED", "Rejoignez d'abord un salon vocal de ce serveur") player.readPlaylist(playlist, !!payload.now) return { ok: true } }) // Partage sur le serveur d'une de MES playlists (copie indépendante) : {playlistId} GuildPlaylistRequest("/GUILD/PLAYLISTS/IMPORT", async (payload, access) => { const id = playlistUtils.parsePlaylistId(payload.playlistId ?? payload.id) if(!id) return refusal("INVALID_PARAMS") const personal = playlists.getPlaylistOfUser(access.userId, id) if(!personal) return refusal("NOT_FOUND") return guildResult(guildPlaylists.importPlaylist(access.guildId, personal, access.user), access.guildId) }, { edit: true }) // Copie d'une playlist du serveur dans MES playlists : {id} GuildPlaylistRequest("/GUILD/PLAYLISTS/COPY", async (payload, access) => { const id = playlistUtils.parsePlaylistId(payload.id) if(!id) return refusal("INVALID_PARAMS") const playlist = guildPlaylists.getPlaylist(access.guildId, id) if(!playlist) return refusal("NOT_FOUND") if(!playlists.hasRoomForPlaylist(access.userId)) return refusal("LIMIT", "Vous avez atteint la limite de " + playlists.MAX_PLAYLISTS + " playlists : supprimez-en une avant d'en copier une autre") const copy = playlists.addPlaylistCopy(access.userId, playlist) if(!copy) return refusal("FAILED", "La copie de la playlist a échoué") personalPlaylistsChanged(access.userId) return { ok: true, playlist: copy } }) // Réglage du serveur actif : {enabled?, editors?} (propriétaire ou administrateur du bot) IORequest("/OWNER/PLAYLISTS/SETTINGS", (data, ack) => { const reply = (value) => answer("/OWNER/PLAYLISTS/SETTINGS", value, ack) if(!checkUserGuild(socketUser, actualGuildId)) return reply(refusal("NO_GUILD")) const guild = discordBot.getGuilds().get(actualGuildId) if(!isGuildOwnerOrAdmin(guild)) return reply(refusal("FORBIDDEN", "Réservé au propriétaire du serveur")) const payload = payloadObject(data) const patch = {} if(payload.enabled !== undefined) patch.enabled = payload.enabled if(payload.editors !== undefined) patch.editors = payload.editors const settings = serverSettings.setSharedPlaylists(actualGuildId, patch) if(!settings) return reply(refusal("INVALID_PARAMS", "Réglage invalide (enabled : booléen, editors : \"members\" ou \"mods\")")) wlog.log("Playlists partagées de la guilde " + actualGuildId + " modifiées par '" + socketUser.identity.username + "'") guildPlaylistsChanged(actualGuildId) // Liste des serveurs de l'administration (option affichée) process.emit("GUILDS_UPDATE") reply({ ok: true, settings }) }) // ADMIN // Toutes les routes /ADMIN/* (Admin.js) sont enregistrées sans condition : // les droits sont revérifiés à chaque appel sur l'utilisateur relu (promotion / rétrogradation en cours de session) Admin.attach({ io, socket, socketUser: () => socketUser }) // CHECKED : 24/04/2025 IORequest("/OWNER/USERS/SWITCH_MOD", async (userId) => { if(!userId || !actualGuildId) return IOAnswer("/OWNER/USERS/SWITCH_MOD", false) if(socketUser.identity.id == userId) return IOAnswer("/OWNER/USERS/SWITCH_MOD", false) const guild = discordBot.getGuilds().get(actualGuildId) // Réservé au propriétaire du serveur (ou à un administrateur du bot) if(!isGuildOwnerOrAdmin(guild)) return IOAnswer("/OWNER/USERS/SWITCH_MOD", false) if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/OWNER/USERS/SWITCH_MOD", false) if(userId == guild.owner) return IOAnswer("/OWNER/USERS/SWITCH_MOD", false) await users.setGuildMod(userId, actualGuildId) process.emit("USERS_UPDATE") IOAnswer("/OWNER/USERS/SWITCH_MOD", true) }) IORequest("/OWNER/ROLES/GET", async () => { if(!actualGuildId) return IOAnswer("/OWNER/ROLES/GET", false) const guild = discordBot.getGuilds().get(actualGuildId) if(!isGuildOwnerOrAdmin(guild)) return IOAnswer("/OWNER/ROLES/GET", false) const rolesSecure = discordBot.getGuildRoles(actualGuildId) const actualRole = await serverSettings.getSecureRole(actualGuildId) // Move the actual role at the start of the array if(actualRole) { const index = rolesSecure.findIndex(r => r.id === actualRole.id) if(index > -1) { rolesSecure.unshift(rolesSecure.splice(index, 1)[0]) } } else { // Find the @everyone role and put it at the start of the array const index = rolesSecure.findIndex(r => r.name === "@everyone") if(index > -1) { rolesSecure.unshift(rolesSecure.splice(index, 1)[0]) } } IOAnswer("/OWNER/ROLES/GET", await rolesSecure) }) IORequest("/OWNER/ROLES/SET", async (data) => { if(!actualGuildId) return IOAnswer("/OWNER/ROLES/SET", false) const guild = discordBot.getGuilds().get(actualGuildId) if(!isGuildOwnerOrAdmin(guild)) return IOAnswer("/OWNER/ROLES/SET", false) // null = retrait de la protection ; sinon le rôle doit exister sur le serveur // et on n'enregistre que les données issues de Discord (jamais l'objet du client) var role = null if(data) { role = discordBot.getGuildRoles(actualGuildId).find(r => r.id === data?.id) || null if(!role) return IOAnswer("/OWNER/ROLES/SET", false) } await serverSettings.setSecureRole(actualGuildId, role) IOAnswer("/OWNER/ROLES/SET", true) }) // Vrai si l'utilisateur du socket est le propriétaire de la guilde ou un administrateur du bot function isGuildOwnerOrAdmin(guild) { if(!guild) return false if(socketUser.identity.id === guild.owner) return true return socketUser.isAdmin() } /** * Droits de l'utilisateur sur les playlists partagées du serveur actif (null : pas d'accès au serveur) * - voir / lire : option activée ; modifier : activée et (tous les membres ou modérateur / propriétaire / admin) * - gérer l'option : propriétaire ou administrateur du bot */ function guildPlaylistsAccess() { if(!checkUserGuild(socketUser, actualGuildId)) return null const guild = discordBot.getGuilds().get(actualGuildId) if(!guild) return null const settings = serverSettings.getSharedPlaylists(actualGuildId) const userId = socketUser.identity.id const isOwner = userId === guild.owner const isAdmin = socketUser.isAdmin() const isModerator = isOwner || isAdmin || socketUser.isMod(actualGuildId) return { guildId: actualGuildId, guild, settings, userId, // Identité figée au moment de la requête (createdBy / updatedBy) user: { ...socketUser.identity }, isModerator, canEdit: settings.enabled && (settings.editors === "members" || isModerator), canManage: isOwner || isAdmin, } } function sharedPlaylistsInfo(access) { return { enabled: access.settings.enabled, editors: access.settings.editors, canEdit: access.canEdit, canManage: access.canManage, } } /** * Route des playlists partagées : accès au serveur, option activée et, si edit, droit de modification * vérifiés avant le handler ; réponse unique (acquittement ou événement), exception → FAILED */ function GuildPlaylistRequest(name, handler, { edit = false } = {}) { IORequest(name, async (data, ack) => { const reply = (value) => answer(name, value, ack) try { const access = guildPlaylistsAccess() if(!access) return reply(refusal("NO_GUILD")) if(!access.settings.enabled) return reply(refusal("DISABLED")) if(edit && !access.canEdit) return reply(refusal("FORBIDDEN", "Seuls les modérateurs peuvent modifier les playlists de ce serveur")) reply(await handler(payloadObject(data), access)) } catch(e) { wlog.error("Erreur lors du traitement de " + name + " pour '" + socketUser?.identity?.username + "' : " + (e?.stack || e)) reply(refusal("FAILED")) } }) } function guildPlaylistsChanged(guildId) { process.emit("GUILD_PLAYLISTS_UPDATE", guildId) } function personalPlaylistsChanged(userId) { process.emit("PLAYLISTS_UPDATE", userId) } // Résultat de GuildPlaylists → enveloppe de réponse (poussée aux membres présents en cas de succès) function guildResult(result, guildId) { if(result?.ok) { guildPlaylistsChanged(guildId) return { ok: true, playlist: result.playlist } } return refusal(result?.error || "FAILED", result?.message) } // CHECKED : 24/04/2025 IORequest("/MOD/USERS/BAN", async (userId) => { if(!userId || typeof userId !== "string" || !actualGuildId) return IOAnswer("/MOD/USERS/BAN", false) const guild = discordBot.getGuilds().get(actualGuildId) if(!guild) return IOAnswer("/MOD/USERS/BAN", false) if(!socketUser.isMod(actualGuildId) && socketUser.identity.id !== guild.owner) return IOAnswer("/MOD/USERS/BAN", false) if(socketUser.identity.id == userId) return IOAnswer("/MOD/USERS/BAN", false) const user = users.getUserById(userId) if(!user || user.isMod(actualGuildId) || user.identity.id == guild.owner) return IOAnswer("/MOD/USERS/BAN", false) // Bascule ban / déban sur la guilde active await users.setGuildBan(userId, actualGuildId) // Toutes les sessions de l'utilisateur quittent la room de la guilde s'il est désormais banni // (syncUserSockets émet aussi USERS_UPDATE) Admin.syncUserSockets(userId) io.to("user:" + userId).emit("/GUILD/UPDATE") IOAnswer("/MOD/USERS/BAN", true) }) IORequest("/MOD/USERS/LIST", () => { if(!actualGuildId) return IOAnswer("/MOD/USERS/LIST", false) const guild = discordBot.getGuilds().get(actualGuildId) if(!guild) return IOAnswer("/MOD/USERS/LIST", false) if(!socketUser.isMod(actualGuildId) && socketUser.identity.id !== guild.owner) return IOAnswer("/MOD/USERS/LIST", false) if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/MOD/USERS/LIST", false) const guildUserList = new Array() for(var user of guild.allMembers) { const userData = users.getUserById(user) if(!userData) continue if(userData.labels.includes("DELETED")) continue // Copie : ces drapeaux ne doivent pas être enregistrés dans users.json guildUserList.push({ identity: { ...userData.identity, isAdmin: userData.isAdmin(), isMod: userData.isMod(actualGuildId), isOwner: userData.identity.id == guild.owner, }, isBanned: userData.isBanned(actualGuildId), }) } IOAnswer("/MOD/USERS/LIST", guildUserList, true) }, true) // UTILS // CHECKED : 24/04/2025 IORequest("/REPORT", async (data) => { if(!data) return IOAnswer("/REPORT", false) const level = data["level"] const desc = data["desc"] if(!level || typeof level !== "string" || !desc || typeof desc !== "string" || desc.trim() === "") return IOAnswer("/REPORT", false) // Réponse = résultat réel de l'envoi dans le salon de rapport const sent = await new Report(socketUser.identity.username, level, desc.slice(0, 4000)).send() if(!sent) wlog.warn("Le rapport de '" + socketUser.identity.username + "' n'a pas pu être envoyé") IOAnswer("/REPORT", sent === true) }) IORequest("/MOD/STATS", () => { // Toutes les réponses partent sur /MOD/STATS (le front attend ce canal) const answer = (value) => IOAnswer("/MOD/STATS", value, true) if(!actualGuildId) return answer(false) const guild = discordBot.getGuilds().get(actualGuildId) if(!guild) return answer(false) const isAdmin = socketUser.isAdmin() if(!isAdmin && !socketUser.isMod(actualGuildId) && socketUser.identity.id !== guild.owner) return answer(false) if(!isAdmin && !checkUserGuild(socketUser, actualGuildId)) return answer(false) // Métriques lues depuis le cache mémoire : noms terminant exactement par _, valeurs numériques finies const metricsToSend = SafeMetric.getGuildMetrics(actualGuildId).map(metric => ({ name: metric.name, description: typeof metric.description === "string" ? metric.description : metric.name, value: SafeMetric.toSafeNumber(metric.value, 0) })) answer(metricsToSend) }, true) // Functions // Rooms conservées lors d'un changement de guilde function isPersistentRoom(room) { return room === socket.id || room === Admin.ADMIN_ROOM || room === Admin.LOGS_ROOM || (typeof room === "string" && room.startsWith("user:")) } /** * Membres connectés d'une guilde (hors utilisateur du socket), avec leurs rôles */ function connectedMembers(guildId, ownerId) { const members = [] for(const user of getGuildConnectedUsers(guildId)) { if(user.id === socketUser.identity.id) continue const userData = users.getUserById(user.id) if(!userData) continue members.push({ id: userData.identity.id, username: userData.identity.username, global_name: userData.identity.global_name, avatar: userData.identity.avatar, isAdmin: userData.isAdmin(), isOwner: userData.identity.id == ownerId, isMod: userData.isMod(guildId), }) } return members } function getUserChannel(usual, force) { const botChannel = getBotChannel() if(botChannel && !force) { return botChannel } const membersVoices = discordBot.getMembersVoices() const member = membersVoices.get(socketUser.identity.id) if(member) { const channelId = member.channelId const guildId = member.guildId if(guildId != actualGuildId) { if(!usual) wlog.warn("La guilde active : " + actualGuildId + " ne correspond pas à la guilde du channel vocal : " + guildId + " de l'utilisateur '" + socketUser.identity.username + "'") return null } const channel = discordBot.getChannel(guildId, channelId) if(!channel) { wlog.warn("Le channel vocal n'existe pas : " + channelId) return null } return channel } else { if(!usual) wlog.warn("L'utilisateur '" + socketUser.identity.username + "' n'est pas dans un channel vocal") return null } } function getUserGuilds() { var guildPresents = new Array(); var guildsOfBot = discordBot.getGuilds() const isAdmin = socketUser.isAdmin() for(var guild of guildsOfBot) { if(guild[1].allMembers.includes(socketUser.identity.id)) { // Copie : les champs propres à l'utilisateur ne doivent pas être écrits sur l'objet partagé du bot const guildData = {...guild[1]} guildData.serverMember = guild[1].allMembers.length guildData.restricted = false const secureRole = serverSettings.getSecureRole(guild[0]) if(secureRole && socketUser.identity.id !== guild[1].owner && !isAdmin) { const member = discordBot.getGuildMember(guild[0], socketUser.identity.id) guildData.restricted = !member?.roles?.cache?.has(secureRole.id) } guildData.allowed = true guildData.members = connectedMembers(guild[0], guild[1].owner) // Send if the bot is connected to the guild const player = players.AllPlayers.get(guild[0]) guildData.connected = !!(player && player.isConnected()) // Leave the room if the user is not in the guild if(socket.rooms.has(guild[0]) && !checkUserGuild(socketUser, guild[0])) { socket.leave(guild[0]) if(actualGuildId === guild[0]) actualGuildId = null process.emit("USERS_UPDATE") wlog.warn("L'utilisateur '" + socketUser.identity.username + "' quitte la room de la guilde : " + guild[0] + " car il n'est pas dans la guilde) /!\\") } guildPresents.push(guildData) } } return guildPresents } function getBotChannel() { const player = players.AllPlayers.get(actualGuildId) if(player && player.isConnected()) { const channel = discordBot.getChannel(actualGuildId, player.channelId) if(channel) { return channel } } return null } /** * @param {Player} player */ function connectToPlayer(guildId, player) { if(!checkUserGuild(socketUser, guildId)) return false // Déjà connecté sur cette guilde : on ne déplace pas le bot if(player.isConnected()) return true const channel = getUserChannel() if(!channel) return false player.join(channel) return true } // Player existant uniquement (jamais de création ici) async function verifyPlayerAction(guildId) { if (!checkUserGuild(socketUser, guildId)) return null; const player = players.AllPlayers.get(guildId); if (player) { return player; } else { wlog.warn(`Le player de la guilde : ${guildId} n'existe pas`); return null; } } function checkUserGuild(socketUser, guildId) { if(!guildId || typeof guildId !== "string") { wlog.warn("Aucun guildId n'est actif pour l'utilisateur : " + socketUser.identity.username) return false } // Check if the guildId is referenced in the bot guilds const guild = discordBot.getGuilds().get(guildId) if(!guild) { wlog.warn("La guilde : " + guildId + " n'est pas référencée dans le bot") return false } if(socketUser.isBanned(guildId)) { wlog.warn("L'utilisateur '" + socketUser.identity.username + "' est banni de la guilde : " + guildId) return false } const isAdmin = socketUser.isAdmin() // Check role if secure role is set (propriétaire et administrateurs du bot exemptés, comme dans getUserGuilds) const secureRole = serverSettings.getSecureRole(guildId) if(secureRole && socketUser.identity.id !== guild.owner && !isAdmin) { const member = discordBot.getGuildMember(guildId, socketUser.identity.id) if(!member?.roles?.cache?.has(secureRole.id)) { wlog.warn("L'utilisateur '" + socketUser.identity.username + "' n'a pas le rôle requis pour accéder à la guilde : " + guildId) return false } } if(!guild.allMembers.includes(socketUser.identity.id)) { wlog.warn("L'utilisateur '" + socketUser.identity.username + "' n'est pas membre de la guilde : " + guildId) // Si user admin, override if(!isAdmin) { return false } wlog.log("L'utilisateur '" + socketUser.identity.username + "' est admin donc à le droit d'accéder à la guilde : " + guildId) } return true } // Réponse refusée par le player (false, "no_music" ou {ok:false, reason}) : transmise telle quelle au client function isRefusal(result) { return result === false || result === "no_music" || (result !== null && typeof result === "object" && result.ok === false) } /** * Exécute une action sur le player EXISTANT de la guilde et répond une seule fois * @param {function(Player)} action - renvoie false / "no_music" / {ok:false} en cas de refus */ async function handlePlayerAction(guildId, action, actionName) { if (!checkUserGuild(socketUser, guildId)) return IOAnswer(actionName, false); const player = players.AllPlayers.get(guildId); if (!player) { wlog.warn(`Le player de la guilde : ${guildId} n'existe pas`); return IOAnswer(actionName, false); } const result = await action(player); wlog.log(`L'utilisateur '${socketUser.identity.username}' effectue l'action '${actionName}' sur le player de la guilde : ${guildId}`); if (isRefusal(result)) return IOAnswer(actionName, result); IOAnswer(actionName, true); } } function handleDisconnect() { // Les rooms sont quittées automatiquement par Socket.IO : la présence est recalculée depuis les sockets restants if(disconnectHandled || !socketUser) return disconnectHandled = true wlog.log("Déconnexion de l'utilisateur : " + socketUser.identity.username + " (" + socketUser.identity.id + ") - Socket : " + socket.id) process.emit("USERS_UPDATE") } function sendSession() { const newSession = session.addSession(socket.id) socket.emit("NEW_SESSION", newSession) wlog.log("Envoi d'une nouvelle session : '" + newSession + "' au client : " + socket.id) socket.disconnect() } /** * Enregistre une route. À chaque requête : * - l'utilisateur est relu (bannissement, droits, suppression appliqués immédiatement à tous ses sockets) ; * - toute exception est journalisée et le client reçoit false (jamais de rejet non géré). * Le callback reçoit (valeur, ack) : ack est la fonction d'acquittement socket.io si le client en fournit une. */ function IORequest(RequestName, RequestCallback, usual) { socket.on(RequestName, async (value, ack) => { if(typeof value === "function" && ack === undefined) { ack = value value = undefined } const fresh = users.getUserById(socket.data.userId) if(!fresh || fresh.isDeleted() || fresh.isFullBanned()) { wlog.warn("Requête refusée (" + RequestName + ") : compte supprimé ou banni - Socket : " + socket.id) socket.emit("AUTH_ERROR", fresh?.isFullBanned() ? "Vous êtes banni du serveur" : "Votre compte n'est plus valide") socket.disconnect(true) return } socketUser = fresh if(!usual) { wlog.log(socketUser.identity.username + " - Socket : " + socket.id + " - " + RequestName + " - [RECIEVED]") } try { await RequestCallback(value, typeof ack === "function" ? ack : undefined) } catch(e) { wlog.error("Erreur lors du traitement de " + RequestName + " pour '" + socketUser?.identity?.username + "' : " + (e?.stack || e)) try { if(typeof ack === "function") ack(false) else IOAnswer(RequestName, false, true) } catch(err) { // Socket fermé entre-temps : rien à répondre } } }) } function IOAnswer(AnswerName, AnswerValue, usual) { if(!usual) { wlog.log(socketUser.identity.username + " - Socket : " + socket.id + " - " + AnswerName + " - [ANSWERED]") } socket.emit(AnswerName, AnswerValue) } // Réponse par acquittement si le client en a fourni un, sinon par l'événement du même nom function answer(AnswerName, AnswerValue, ack, usual) { if(typeof ack === "function") { if(!usual) wlog.log(socketUser.identity.username + " - Socket : " + socket.id + " - " + AnswerName + " - [ACK]") return ack(AnswerValue) } IOAnswer(AnswerName, AnswerValue, usual) } }) httpServer.listen(configuration.getPort(), () => { wlog.log(`Le serveur écoute sur le port ${configuration.getPort()}`) wlog.step.end("server_init") }) } module.exports = {init}