1667 lines
83 KiB
JavaScript
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}
|