Files
chopin/src/server/Server.js
T

1667 lines
83 KiB
JavaScript

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:<id>", "<guildId>"). 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<string>}
*/
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:<id>" (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:<id>"),
// 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 _<guildId>, 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}