2179 lines
89 KiB
JavaScript
2179 lines
89 KiB
JavaScript
/**
|
|
* [Subsonics Chopin - Backend] - Interface d'administration générale
|
|
* Toutes les routes Socket.IO "/ADMIN/*" : vue d'ensemble, logs, comptes, serveurs Discord, players, WARP, YouTube,
|
|
* métriques et maintenance. Contrat : réponse {ok:true, data} ou {ok:false, error, message} via l'ack
|
|
* (ou socket.emit(name, env) sans ack).
|
|
*
|
|
* - attach({ io, socket, socketUser }) : enregistre les routes pour un socket authentifié, SANS condition :
|
|
* les droits sont revérifiés à chaque appel sur l'utilisateur relu dans users.json (jamais un objet périmé).
|
|
* - init(io) : poussées globales vers les rooms "ADMIN" et "ADMIN_LOGS" (appelé une seule fois).
|
|
*/
|
|
const os = require('os')
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const { LogType } = require('loguix')
|
|
|
|
const users = require('./auth/User')
|
|
const session = require('./auth/Session')
|
|
const players = require('../player/Player')
|
|
const discordBot = require('../discord/Bot')
|
|
const serverSettings = require('../discord/ServerSettings')
|
|
const playlists = require('../playlists/PlaylistManager')
|
|
const guildPlaylists = require('../playlists/GuildPlaylists')
|
|
const warp = require('../utils/WarpProxy')
|
|
const SafeMetric = require('../utils/SafeMetric')
|
|
const LogReader = require('../utils/LogReader')
|
|
const maintenance = require('../utils/Maintenance')
|
|
const configuration = require('../utils/Database/Configuration')
|
|
const { getDatabase } = require('../utils/Database/Database')
|
|
const { __glob } = require('../utils/GlobalVars')
|
|
const youtube = require('../youtube')
|
|
|
|
const alog = new LogType("Admin")
|
|
|
|
const ADMIN_ROOM = "ADMIN"
|
|
const LOGS_ROOM = "ADMIN_LOGS"
|
|
const OVERVIEW_INTERVAL = 10 * 1000
|
|
const WARP_CHECK_TIMEOUT = 10 * 1000
|
|
const SNOWFLAKE = /^\d{5,25}$/
|
|
// YouTube : attente max de la version de yt-dlp avant de répondre (le sondage continue en arrière-plan)
|
|
const YOUTUBE_VERSION_WAIT = 3000
|
|
// Import depuis un navigateur : sous le délai de 60 s du client
|
|
const YOUTUBE_BROWSER_TIMEOUT = 50 * 1000
|
|
const YOUTUBE_RECENT = 50
|
|
const COOKIES_MAX_LENGTH = 1000000
|
|
const COOKIE_IMPORT_SOURCES = ["paste", "file"]
|
|
const YOUTUBE_BROWSERS = ["firefox", "chrome", "chromium", "edge", "brave", "opera", "vivaldi", "safari"]
|
|
// Navigateurs Chromium : base de cookies verrouillée / chiffrée sous Windows
|
|
const CHROMIUM_BROWSERS = ["chrome", "chromium", "edge", "brave", "opera", "vivaldi"]
|
|
const YOUTUBE_TEST_ROUTES = ["auto", "warp", "direct"]
|
|
const YOUTUBE_TEST_IDENTITIES = ["auto", "anon", "cookies"]
|
|
const YOUTUBE_SETTINGS_MAX_KEYS = 20
|
|
// Test des identifiants Spotify (jeton client credentials)
|
|
const SPOTIFY_TEST_TIMEOUT = 15 * 1000
|
|
// Changement des processus yt-dlp actifs (Youtube.js ne prévient pas le player) : sondage léger
|
|
const YTDLP_PROCESS_POLL = 2000
|
|
// Jamais "config" : contient le token du bot et les secrets
|
|
const DATABASES = ["Users", "Playlists", "GuildPlaylists", "history", "media", "previous", "server_settings"]
|
|
// Playlists partagées : qui peut les modifier (tous les membres / modérateurs)
|
|
const SHARED_PLAYLISTS_EDITORS = ["members", "mods"]
|
|
const PLAYER_ACTIONS = ["pause", "resume", "skip", "previous", "stop", "leave", "loop", "shuffle", "clear"]
|
|
const LOG_LEVELS = ["INFO", "WARN", "ERROR", "FATAL"]
|
|
const USER_FILTERS = ["all", "online", "admin", "banned", "deleted"]
|
|
|
|
let io = null
|
|
let initialized = false
|
|
let browserImportPending = false
|
|
|
|
class AdminError extends Error {
|
|
/**
|
|
* @param {"FORBIDDEN"|"INVALID_PARAMS"|"NOT_FOUND"|"SELF_ACTION"|"TARGET_PROTECTED"|"UNAVAILABLE"|"INTERNAL"} code
|
|
* @param {string} [message]
|
|
*/
|
|
constructor(code, message) {
|
|
super(message || code)
|
|
this.code = code
|
|
}
|
|
}
|
|
|
|
// UTILITAIRES
|
|
|
|
function safeNum(value) {
|
|
const n = Number(value)
|
|
return Number.isFinite(n) ? n : 0
|
|
}
|
|
|
|
function toIso(value) {
|
|
if (value === null || value === undefined || value === "") return null
|
|
const date = value instanceof Date ? value : new Date(value)
|
|
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
|
}
|
|
|
|
function asObject(payload) {
|
|
return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {}
|
|
}
|
|
|
|
function requireId(value, name = "userId") {
|
|
if (typeof value !== "string" || !SNOWFLAKE.test(value)) {
|
|
throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function requireBool(value, name = "value") {
|
|
if (typeof value !== "boolean") {
|
|
throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (booléen attendu)`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function requireString(value, name, min, max) {
|
|
if (typeof value !== "string") throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (texte attendu)`)
|
|
const text = value.trim()
|
|
if (text.length < min || text.length > max) {
|
|
throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (${min} à ${max} caractères)`)
|
|
}
|
|
return text
|
|
}
|
|
|
|
function optionalString(value, name, max) {
|
|
if (value === undefined || value === null) return null
|
|
if (typeof value !== "string") throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (texte attendu)`)
|
|
const text = value.trim()
|
|
if (text.length > max) throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" trop long (${max} caractères max)`)
|
|
return text === "" ? null : text
|
|
}
|
|
|
|
function optionalInt(value, name, min, max, fallback) {
|
|
if (value === undefined || value === null) return fallback
|
|
const n = Number(value)
|
|
if (!Number.isInteger(n) || n < min || n > max) {
|
|
throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (entier entre ${min} et ${max})`)
|
|
}
|
|
return n
|
|
}
|
|
|
|
/**
|
|
* Les anciens endpoints acceptent un userId en chaîne, les nouveaux un objet {userId}
|
|
*/
|
|
function userIdFrom(payload) {
|
|
if (typeof payload === "string") return requireId(payload)
|
|
return requireId(asObject(payload).userId)
|
|
}
|
|
|
|
function describeUser(user) {
|
|
return `${user?.identity?.username ?? "inconnu"} (${user?.identity?.id ?? "?"})`
|
|
}
|
|
|
|
/**
|
|
* Journalise chaque action mutante
|
|
*/
|
|
function audit(me, action, target = "") {
|
|
alog.warn(`[ADMIN] ${me.identity.username} -> ${action}${target ? " " + target : ""}`)
|
|
}
|
|
|
|
function roomSize(room) {
|
|
return io?.sockets.adapter.rooms.get(room)?.size || 0
|
|
}
|
|
|
|
function getClient() {
|
|
return discordBot.getClient()
|
|
}
|
|
|
|
function getYoutubeProcesses() {
|
|
try {
|
|
const map = require('../player/Method/Youtube').activeProcesses
|
|
return map instanceof Map ? map : null
|
|
} catch (e) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getMediaBase() {
|
|
try {
|
|
return require('../discord/MediaBase')
|
|
} catch (e) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function dbData(name, fallback) {
|
|
const data = getDatabase(name)?.data
|
|
return data === undefined || data === null ? fallback : data
|
|
}
|
|
|
|
/**
|
|
* Coalesce les appels : fn est exécutée au plus une fois par délai
|
|
*/
|
|
function debounce(fn, delay) {
|
|
let timer = null
|
|
return () => {
|
|
if (timer) return
|
|
timer = setTimeout(async () => {
|
|
timer = null
|
|
try {
|
|
await fn()
|
|
} catch (e) {
|
|
alog.error(`Poussée admin impossible : ${e?.stack || e}`)
|
|
}
|
|
}, delay)
|
|
timer.unref?.()
|
|
}
|
|
}
|
|
|
|
// TYPES SÉRIALISÉS
|
|
|
|
function userLite(identity) {
|
|
return {
|
|
id: identity?.id ?? null,
|
|
username: identity?.username ?? null,
|
|
global_name: identity?.global_name ?? null,
|
|
avatar: identity?.avatar ?? null
|
|
}
|
|
}
|
|
|
|
function songLite(song) {
|
|
if (!song || typeof song !== "object") return null
|
|
const duration = Number(song.duration)
|
|
return {
|
|
id: song.id ?? null,
|
|
title: song.title ?? null,
|
|
author: song.author ?? null,
|
|
authorId: song.authorId ?? null,
|
|
url: song.url ?? null,
|
|
thumbnail: song.thumbnail ?? null,
|
|
duration: Number.isFinite(duration) && duration > 0 ? duration : null,
|
|
readduration: song.readduration ?? null,
|
|
type: song.type ?? null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Index des sockets authentifiés par utilisateur (socket.data.userId posé par Server.js à la connexion)
|
|
* @returns {Map<string, import('socket.io').Socket[]>}
|
|
*/
|
|
function socketIndex() {
|
|
const byUser = new Map()
|
|
if (!io) return byUser
|
|
for (const socket of io.sockets.sockets.values()) {
|
|
const userId = socket.data?.userId
|
|
if (!userId) continue
|
|
if (!byUser.has(userId)) byUser.set(userId, [])
|
|
byUser.get(userId).push(socket)
|
|
}
|
|
return byUser
|
|
}
|
|
|
|
function socketGuildId(socket) {
|
|
const guilds = discordBot.getGuilds()
|
|
for (const room of socket.rooms) {
|
|
if (guilds.has(room)) return room
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Données partagées pour construire plusieurs résumés sans tout recalculer
|
|
*/
|
|
function buildContext() {
|
|
const guildsByUser = new Map()
|
|
for (const [guildId, guild] of discordBot.getGuilds()) {
|
|
for (const memberId of guild.allMembers || []) {
|
|
if (!guildsByUser.has(memberId)) guildsByUser.set(memberId, [])
|
|
guildsByUser.get(memberId).push(guildId)
|
|
}
|
|
}
|
|
const mediaCounts = new Map()
|
|
const media = dbData("media", [])
|
|
if (Array.isArray(media)) {
|
|
for (const m of media) mediaCounts.set(m?.userId, (mediaCounts.get(m?.userId) || 0) + 1)
|
|
}
|
|
const registered = new Set(users.getUsers().filter(u => !u.labels?.includes("DELETED")).map(u => u.identity.id))
|
|
return {
|
|
sockets: socketIndex(),
|
|
guildsByUser,
|
|
playlists: dbData("Playlists", {}),
|
|
history: dbData("history", {}),
|
|
mediaCounts,
|
|
registered
|
|
}
|
|
}
|
|
|
|
function userSummary(user, ctx = buildContext()) {
|
|
const id = user.identity.id
|
|
const labels = Array.isArray(user.labels) ? [...user.labels] : []
|
|
const sockets = ctx.sockets.get(id) || []
|
|
const updated = labels.find(l => l.startsWith("UPDATED["))
|
|
let currentGuildId = null
|
|
for (const socket of sockets) {
|
|
currentGuildId = socketGuildId(socket)
|
|
if (currentGuildId) break
|
|
}
|
|
const playlistsOfUser = ctx.playlists?.[id]
|
|
const historyOfUser = ctx.history?.[id]
|
|
return {
|
|
...userLite(user.identity),
|
|
labels,
|
|
isAdmin: labels.includes("ADMIN"),
|
|
isFullBanned: labels.includes("BAN"),
|
|
isDeleted: labels.includes("DELETED"),
|
|
modGuilds: labels.filter(l => l.startsWith("MOD_")).map(l => l.slice(4)),
|
|
bannedGuilds: labels.filter(l => l.startsWith("BAN_")).map(l => l.slice(4)),
|
|
banReason: user.banInfo?.reason ?? null,
|
|
bannedAt: toIso(user.banInfo?.at),
|
|
lastIdentityUpdate: updated ? toIso(updated.slice("UPDATED[".length, -1)) : null,
|
|
hasDiscordAuth: !!user.auth,
|
|
tokenCount: Array.isArray(user.tokens) ? user.tokens.length : 0,
|
|
online: sockets.length > 0,
|
|
socketCount: sockets.length,
|
|
currentGuildId,
|
|
guildIds: ctx.guildsByUser.get(id) || [],
|
|
playlistCount: Array.isArray(playlistsOfUser) ? playlistsOfUser.length : 0,
|
|
historyCount: Array.isArray(historyOfUser) ? historyOfUser.length : 0,
|
|
mediaCount: ctx.mediaCounts.get(id) || 0
|
|
}
|
|
}
|
|
|
|
function isConnectedPlayer(player) {
|
|
if (!player) return false
|
|
try {
|
|
if (typeof player.isConnected === "function") return !!player.isConnected()
|
|
} catch (e) {
|
|
return false
|
|
}
|
|
return !!player.connection
|
|
}
|
|
|
|
function connectedPlayers() {
|
|
return players.getAllPlayers().filter(isConnectedPlayer)
|
|
}
|
|
|
|
/**
|
|
* Statut simplifié d'un état de player (v2 : state.status ; ancien format : playerState)
|
|
*/
|
|
function playerStatus(state) {
|
|
if (state?.status) return state.status
|
|
if (state?.playing) return "playing"
|
|
if (state?.paused) return "paused"
|
|
return "idle"
|
|
}
|
|
|
|
/**
|
|
* PlayerSummary = état v2 du player (next tronqué à 10) + informations d'administration
|
|
*/
|
|
function playerSummary(player, { full = false } = {}) {
|
|
let state
|
|
try {
|
|
state = player.getState()
|
|
} catch (e) {
|
|
alog.error(`Lecture de l'état du player ${player?.guildId} impossible : ${e?.stack || e}`)
|
|
return null
|
|
}
|
|
if (!state) return null
|
|
const guildId = state.guildId || player.guildId
|
|
const guild = discordBot.getGuilds().get(guildId)
|
|
const channelId = state.connection?.channelId ?? state.channelId ?? player.channelId ?? null
|
|
|
|
let listeners = 0
|
|
try {
|
|
const channel = channelId ? discordBot.getChannel(guildId, channelId) : null
|
|
listeners = channel?.members ? channel.members.filter(m => !m.user?.bot).size : 0
|
|
} catch (e) {
|
|
listeners = 0
|
|
}
|
|
|
|
const proc = getYoutubeProcesses()?.get(guildId)
|
|
const next = Array.isArray(state.next) ? state.next : []
|
|
const previous = Array.isArray(state.previous) ? state.previous : []
|
|
// Player.lastError n'est jamais effacé : une erreur sur une autre musique que celle en cours de lecture est résolue
|
|
const lastErrorStale = !!(state.lastError && state.status === "playing" && state.current?.id
|
|
&& state.lastError.songId !== state.current.id)
|
|
return {
|
|
...state,
|
|
guildId,
|
|
lastErrorStale,
|
|
next: full ? next : next.slice(0, 10),
|
|
nextCount: Number.isFinite(state.nextCount) ? state.nextCount : next.length,
|
|
// Résumé : 20 derniers titres ; détail complet : tout l'historique renvoyé par getState()
|
|
previous: full ? previous : previous.slice(0, 20),
|
|
guildName: guild?.name ?? null,
|
|
guildIcon: guild?.icon ?? null,
|
|
listeners,
|
|
webListeners: roomSize(guildId),
|
|
ytdlp: proc ? { pid: proc.pid ?? null, route: proc.route ?? null, startedAt: toIso(proc.startedAt) } : null
|
|
}
|
|
}
|
|
|
|
function playersList() {
|
|
return connectedPlayers().map(p => playerSummary(p)).filter(Boolean)
|
|
}
|
|
|
|
function guildStats(guildId) {
|
|
return {
|
|
commands: safeNum(SafeMetric.get("numberOfCommands_" + guildId)),
|
|
musicPlayed: safeNum(SafeMetric.get("numberOfMusicPlayed_" + guildId)),
|
|
secondsPlayed: safeNum(SafeMetric.get("numberOfSecondsPlayed_" + guildId))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copie sérialisable d'une guilde (jamais l'objet partagé de Bot.js, jamais allMembers)
|
|
*/
|
|
function guildSummary(guildData, ctx = buildContext()) {
|
|
const id = guildData.id
|
|
const discordGuild = getClient().guilds.cache.get(id)
|
|
|
|
let missingPermissions = []
|
|
try {
|
|
if (discordGuild?.members?.me && typeof discordBot.checkRequiredPermission === "function") {
|
|
missingPermissions = discordBot.checkRequiredPermission(discordGuild.members.me)
|
|
}
|
|
} catch (e) {
|
|
missingPermissions = []
|
|
}
|
|
|
|
let owner = { id: guildData.owner ?? null }
|
|
const ownerMember = discordGuild?.members?.cache.get(guildData.owner)
|
|
if (ownerMember?.user) {
|
|
owner = {
|
|
id: ownerMember.user.id,
|
|
username: ownerMember.user.username ?? null,
|
|
global_name: ownerMember.user.globalName ?? null,
|
|
avatar: ownerMember.user.avatar ?? null
|
|
}
|
|
}
|
|
|
|
// Utilisateurs web distincts dans la room de la guilde
|
|
const webUsers = new Set()
|
|
for (const socketId of io?.sockets.adapter.rooms.get(id) || []) {
|
|
const userId = io.sockets.sockets.get(socketId)?.data?.userId
|
|
if (userId) webUsers.add(userId)
|
|
}
|
|
|
|
const members = guildData.allMembers || []
|
|
const secureRole = serverSettings.getSecureRole(id)
|
|
const player = players.AllPlayers.get(id)
|
|
return {
|
|
id,
|
|
name: guildData.name ?? null,
|
|
icon: guildData.icon ?? null,
|
|
banner: guildData.banner ?? null,
|
|
description: guildData.description ?? null,
|
|
owner,
|
|
memberCount: members.length,
|
|
registeredMembers: members.filter(m => ctx.registered.has(m)).length,
|
|
webUsersOnline: webUsers.size,
|
|
joinedAt: toIso(guildData.joinedAt),
|
|
createdAt: toIso(guildData.createdAt),
|
|
features: Array.isArray(guildData.features) ? [...guildData.features] : [],
|
|
secureRole: secureRole ? { id: secureRole.id ?? null, name: secureRole.name ?? null } : null,
|
|
missingPermissions,
|
|
isMediaGuild: !!id && id === configuration.getMediaGuildId(),
|
|
player: isConnectedPlayer(player) ? playerSummary(player) : null,
|
|
stats: guildStats(id),
|
|
sharedPlaylists: sharedPlaylistsSummary(id)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Option des playlists partagées d'un serveur + nombre de playlists enregistrées (même masquées)
|
|
*/
|
|
function sharedPlaylistsSummary(guildId) {
|
|
const settings = typeof serverSettings.getSharedPlaylists === "function"
|
|
? serverSettings.getSharedPlaylists(guildId)
|
|
: { enabled: false, editors: "members" }
|
|
return {
|
|
enabled: !!settings?.enabled,
|
|
editors: SHARED_PLAYLISTS_EDITORS.includes(settings?.editors) ? settings.editors : "members",
|
|
count: guildPlaylists.countPlaylists(guildId)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Résumé d'une playlist pour les listes d'administration (jamais la liste des titres)
|
|
*/
|
|
function playlistLite(p) {
|
|
return {
|
|
playlistId: p?.playlistId != null ? String(p.playlistId) : null,
|
|
title: p?.title ?? null,
|
|
type: p?.type ?? null,
|
|
// "google" : synchronisée depuis le compte Google du propriétaire
|
|
source: p?.source ?? null,
|
|
url: typeof p?.url === "string" ? p.url : null,
|
|
thumbnail: typeof p?.thumbnail === "string" ? p.thumbnail : null,
|
|
duration: safeNum(p?.duration),
|
|
syncedAt: toIso(p?.syncedAt),
|
|
createdAt: toIso(p?.createdAt),
|
|
updatedAt: toIso(p?.updatedAt),
|
|
songCount: Array.isArray(p?.songs) ? p.songs.length : 0
|
|
}
|
|
}
|
|
|
|
function guildsList(search) {
|
|
const ctx = buildContext()
|
|
let list = [...discordBot.getGuilds().values()]
|
|
if (search) {
|
|
const s = search.toLowerCase()
|
|
list = list.filter(g => (g.name || "").toLowerCase().includes(s) || g.id.includes(s))
|
|
}
|
|
return list.map(g => guildSummary(g, ctx)).sort((a, b) => (a.name || "").localeCompare(b.name || ""))
|
|
}
|
|
|
|
function onlineUsersPayload() {
|
|
const sockets = socketIndex()
|
|
const online = []
|
|
for (const [userId, list] of sockets) {
|
|
const user = users.getUserById(userId)
|
|
if (!user) continue
|
|
let guildId = null
|
|
for (const socket of list) {
|
|
guildId = socketGuildId(socket)
|
|
if (guildId) break
|
|
}
|
|
online.push({ ...userLite(user.identity), socketCount: list.length, guildId })
|
|
}
|
|
const registered = users.getUsers().filter(u => !u.labels?.includes("DELETED")).length
|
|
return { online, counts: { registered, online: online.length } }
|
|
}
|
|
|
|
// MÉTRIQUES
|
|
|
|
function metricsData() {
|
|
const all = SafeMetric.getAll()
|
|
const raw = all.map(m => ({
|
|
name: m.name,
|
|
description: m.description ?? m.name,
|
|
value: safeNum(m.value),
|
|
invalid: !Number.isFinite(Number(m.value))
|
|
}))
|
|
const byGuildMap = new Map()
|
|
const ensureGuild = (guildId) => {
|
|
if (!byGuildMap.has(guildId)) {
|
|
byGuildMap.set(guildId, {
|
|
guildId,
|
|
guildName: discordBot.getGuilds().get(guildId)?.name ?? null,
|
|
commands: 0,
|
|
musicPlayed: 0,
|
|
secondsPlayed: 0
|
|
})
|
|
}
|
|
return byGuildMap.get(guildId)
|
|
}
|
|
const byUser = []
|
|
let commands = 0
|
|
for (const m of raw) {
|
|
if (m.name === "numberOfCommands") commands = m.value
|
|
else if (m.name.startsWith("numberOfCommands_")) ensureGuild(m.name.slice("numberOfCommands_".length)).commands = m.value
|
|
else if (m.name.startsWith("numberOfMusicPlayed_")) ensureGuild(m.name.slice("numberOfMusicPlayed_".length)).musicPlayed = m.value
|
|
else if (m.name.startsWith("numberOfSecondsPlayed_")) ensureGuild(m.name.slice("numberOfSecondsPlayed_".length)).secondsPlayed = m.value
|
|
else if (m.name.startsWith("userCommand_")) byUser.push({ key: m.name.slice("userCommand_".length), commands: m.value })
|
|
}
|
|
const byGuild = [...byGuildMap.values()]
|
|
return {
|
|
global: {
|
|
commands,
|
|
musicPlayed: byGuild.reduce((acc, g) => acc + g.musicPlayed, 0),
|
|
secondsPlayed: byGuild.reduce((acc, g) => acc + g.secondsPlayed, 0)
|
|
},
|
|
byGuild: byGuild.sort((a, b) => b.commands - a.commands),
|
|
byUser: byUser.sort((a, b) => b.commands - a.commands),
|
|
raw
|
|
}
|
|
}
|
|
|
|
// YOUTUBE
|
|
|
|
/**
|
|
* Erreurs typées du module youtube -> AdminError (leurs messages sont déjà en français)
|
|
*/
|
|
function youtubeError(e) {
|
|
if (e instanceof AdminError) return e
|
|
switch (e?.code) {
|
|
case "INVALID_COOKIES":
|
|
case "INVALID_PARAMS":
|
|
return new AdminError("INVALID_PARAMS", e.message)
|
|
case "BUSY":
|
|
return new AdminError("UNAVAILABLE", e.message)
|
|
default:
|
|
return e
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Relance le sondage de la version de yt-dlp si son cache a expiré, sans bloquer plus de quelques secondes
|
|
* (getVersion() répond directement depuis son cache : 6 h si connue, 1 min en cas d'échec)
|
|
*/
|
|
async function refreshYtdlpVersion() {
|
|
let timer
|
|
try {
|
|
await Promise.race([
|
|
youtube.ytdlp.getVersion().catch(() => null),
|
|
new Promise((resolve) => {
|
|
timer = setTimeout(resolve, YOUTUBE_VERSION_WAIT)
|
|
timer.unref?.()
|
|
})
|
|
])
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* État complet de la section YouTube : youtube.getStatus() + tentatives récentes (avec le nom du serveur)
|
|
*/
|
|
function youtubeStatus() {
|
|
const guilds = discordBot.getGuilds()
|
|
const recent = youtube.diagnostics.getRecent(YOUTUBE_RECENT).map(entry => ({
|
|
...entry,
|
|
guildName: entry.guildId ? (guilds.get(entry.guildId)?.name ?? null) : null
|
|
}))
|
|
return { ...youtube.getStatus(), recent }
|
|
}
|
|
|
|
/**
|
|
* Résumé yt-dlp de la vue d'ensemble (clés present / updatedAt conservées pour les anciens clients)
|
|
*/
|
|
async function youtubeOverview() {
|
|
const activeProcesses = getYoutubeProcesses()?.size ?? 0
|
|
try {
|
|
await refreshYtdlpVersion()
|
|
const status = youtube.getStatus()
|
|
const cookies = status.cookies || {}
|
|
const summary = status.diagnostics || {}
|
|
return {
|
|
version: status.ytdlp?.version ?? null,
|
|
source: status.ytdlp?.source ?? null,
|
|
updating: !!status.ytdlp?.updating,
|
|
cookies: {
|
|
present: !!cookies.present,
|
|
loggedIn: !!cookies.loggedIn,
|
|
valid: !!cookies.valid,
|
|
invalidatedAt: cookies.invalidatedAt ?? null,
|
|
invalidReason: cookies.invalidReason ?? null,
|
|
importedAt: cookies.importedAt ?? null,
|
|
updatedAt: cookies.importedAt ?? null,
|
|
earliestAuthExpiry: cookies.earliestAuthExpiry ?? null,
|
|
lastCheck: cookies.lastCheck ? { at: cookies.lastCheck.at ?? null, ok: !!cookies.lastCheck.ok } : null
|
|
},
|
|
cookiesMode: status.settings?.cookiesMode?.value ?? null,
|
|
activeProcesses,
|
|
lastFailure: summary.lastFailure ? { at: summary.lastFailure.at ?? null, cls: summary.lastFailure.cls ?? null } : null,
|
|
recentFailureRate: summary.recentFailureRate ?? null,
|
|
recentCount: safeNum(summary.recentCount)
|
|
}
|
|
} catch (e) {
|
|
alog.error(`État YouTube illisible pour la vue d'ensemble : ${e?.stack || e}`)
|
|
return { version: null, cookies: { present: false, updatedAt: null }, activeProcesses }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lance la vérification en direct des cookies sans l'attendre (le résultat arrive par "/ADMIN/YOUTUBE/UPDATE")
|
|
* @returns {boolean} true si une vérification a été lancée
|
|
*/
|
|
function startCookieCheck() {
|
|
if (!youtube.cookies.getStatus().loggedIn) return false
|
|
youtube.check.checkCookies().catch((e) => alog.warn(`Vérification des cookies YouTube impossible : ${e?.message || e}`))
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Description d'une modification de réglages pour l'audit (URL du fournisseur de PO token masquée)
|
|
*/
|
|
function describeSettingsChange(before, after) {
|
|
const format = (key, value) => key === "potProviderUrl" ? (youtube.settings.maskUrl(value) || '""') : JSON.stringify(value)
|
|
return Object.keys(after)
|
|
.filter(key => before[key] !== after[key])
|
|
.map(key => `${key}: ${format(key, before[key])} -> ${format(key, after[key])}`)
|
|
.join(", ")
|
|
}
|
|
|
|
function isContainer() {
|
|
try {
|
|
return fs.existsSync("/.dockerenv") || fs.existsSync("/run/.containerenv")
|
|
} catch (e) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Message d'échec de --cookies-from-browser (dernière ligne ERROR nettoyée + explication)
|
|
*/
|
|
function browserFailureMessage(browser, result) {
|
|
const line = String(result?.stderr || "").split(/\r?\n/).map(l => l.trim()).reverse().find(l => /^ERROR:/i.test(l))
|
|
let detail
|
|
if (result?.timedOut) detail = "délai dépassé"
|
|
else if (line) detail = youtube.errors.sanitize(line.replace(/^ERROR:\s*/i, "")).slice(0, 300)
|
|
else detail = `aucun cookie exporté (code ${result?.code ?? "?"})`
|
|
let hint = "Cette méthode ne fonctionne que si le bot tourne sur la même machine et la même session que le navigateur."
|
|
if (CHROMIUM_BROWSERS.includes(browser) && process.platform === "win32") {
|
|
hint += " Sous Windows, Chrome et ses dérivés verrouillent et chiffrent leurs cookies : fermez complètement le navigateur, ou préférez Firefox."
|
|
}
|
|
return `Lecture des cookies de ${browser} impossible : ${detail}. ${hint}`
|
|
}
|
|
|
|
/**
|
|
* Importe les cookies d'un navigateur installé sur la machine du bot (dev / local uniquement) :
|
|
* yt-dlp --cookies-from-browser les exporte dans un fichier temporaire, aussitôt importé puis supprimé.
|
|
*/
|
|
async function importFromBrowser(browser, profile, by) {
|
|
if (isContainer()) {
|
|
throw new AdminError("UNAVAILABLE", "Le bot tourne dans un conteneur (Docker) : il n'a accès à aucun navigateur. Utilisez l'assistant de connexion (export d'un fichier cookies.txt).")
|
|
}
|
|
if (browserImportPending) throw new AdminError("UNAVAILABLE", "Un import depuis un navigateur est déjà en cours")
|
|
browserImportPending = true
|
|
// Préfixe des copies de cookies du module youtube : nettoyé au démarrage en cas de crash
|
|
let dir = null
|
|
try {
|
|
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chopin-ytc-"))
|
|
const file = path.join(dir, "cookies.txt")
|
|
const spec = profile ? `${browser}:${profile}` : browser
|
|
const args = [
|
|
...youtube.ytdlp.baseArgs({ proxy: null }),
|
|
"--cookies-from-browser", spec,
|
|
"--cookies", file,
|
|
"--skip-download",
|
|
"--", youtube.settings.get().testUrl
|
|
]
|
|
// yt-dlp écrit le fichier de cookies en se fermant, même si l'extraction de la vidéo échoue
|
|
const result = await youtube.ytdlp.run(args, { timeoutMs: YOUTUBE_BROWSER_TIMEOUT })
|
|
let text = null
|
|
try {
|
|
text = await fs.promises.readFile(file, "utf-8")
|
|
} catch (e) {
|
|
text = null
|
|
}
|
|
if (!text || !text.trim()) throw new AdminError("UNAVAILABLE", browserFailureMessage(browser, result))
|
|
try {
|
|
return youtube.cookies.importCookies(text, { source: "browser:" + browser, by })
|
|
} catch (e) {
|
|
if (e?.code === "INVALID_COOKIES") {
|
|
throw new AdminError("INVALID_PARAMS", `${e.message} (êtes-vous connecté à YouTube dans ${browser}${profile ? `, profil « ${profile} »` : ""} ?)`)
|
|
}
|
|
throw e
|
|
}
|
|
} finally {
|
|
browserImportPending = false
|
|
// Le fichier temporaire contient TOUS les cookies du navigateur : supprimé dans tous les cas
|
|
if (dir) await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
}
|
|
}
|
|
|
|
function adminIdentity(me) {
|
|
return { id: me.identity.id, username: me.identity.username }
|
|
}
|
|
|
|
// CONFIGURATION ET INTÉGRATIONS
|
|
|
|
// Dernier test des identifiants Spotify (oublié dès qu'ils changent)
|
|
let spotifyLastTest = null
|
|
|
|
function websiteBase() {
|
|
return String(configuration.getWebsiteLink() || "").replace(/\/+$/, "")
|
|
}
|
|
|
|
/**
|
|
* Comptes Google liés, lus dans data/google.json (lecture seule : jamais de jeton renvoyé)
|
|
*/
|
|
function googleAccountsInfo() {
|
|
const data = dbData("google", null)
|
|
if (!data || typeof data !== "object" || Array.isArray(data)) return { available: false, linked: 0, invalid: 0 }
|
|
let linked = 0
|
|
let invalid = 0
|
|
for (const record of Object.values(data)) {
|
|
if (!record || typeof record !== "object" || !record.tokens) continue
|
|
linked++
|
|
if (record.invalid) invalid++
|
|
}
|
|
return { available: true, linked, invalid }
|
|
}
|
|
|
|
function youtubeCookiesSummary() {
|
|
try {
|
|
const cookies = youtube.cookies.getStatus()
|
|
return {
|
|
present: !!cookies.present,
|
|
loggedIn: !!cookies.loggedIn,
|
|
valid: !!cookies.valid,
|
|
invalidReason: cookies.invalidReason ?? null,
|
|
importedAt: cookies.importedAt ?? null
|
|
}
|
|
} catch (e) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* État des intégrations pour la section Configuration (aucun secret, seulement des indicateurs)
|
|
*/
|
|
function integrationsStatus() {
|
|
const website = websiteBase()
|
|
const google = googleAccountsInfo()
|
|
const warpStatus = warp.getStatus()
|
|
const client = getClient()
|
|
return {
|
|
website: website || null,
|
|
discord: {
|
|
ready: !!discordBot.isReady(),
|
|
tag: client?.user?.tag ?? null,
|
|
// À déclarer dans le portail développeur Discord (OAuth2 > Redirects)
|
|
redirectUri: website ? website + "/redirect" : null
|
|
},
|
|
google: {
|
|
configured: !!(configuration.getYoutubeApiClientId() && configuration.getYoutubeApiClientSecret() && website),
|
|
clientIdSet: !!configuration.getYoutubeApiClientId(),
|
|
clientSecretSet: !!configuration.getYoutubeApiClientSecret(),
|
|
// URI de redirection autorisée à déclarer dans la console Google Cloud
|
|
redirectUri: website ? website + "/oauth2callback" : null,
|
|
accountsAvailable: google.available,
|
|
linkedUsers: google.linked,
|
|
invalidUsers: google.invalid
|
|
},
|
|
spotify: {
|
|
configured: !!(configuration.getSpotifyClientId() && configuration.getSpotifyClientSecret()),
|
|
lastTest: spotifyLastTest
|
|
},
|
|
warp: {
|
|
configured: !!warpStatus.configured,
|
|
healthy: !!warpStatus.healthy,
|
|
url: warpStatus.url ?? null,
|
|
routes: warpStatus.routes || []
|
|
},
|
|
youtube: { cookies: youtubeCookiesSummary() },
|
|
media: mediaStatus(),
|
|
report: {
|
|
channelSet: !!configuration.getReportChannel(),
|
|
contactSet: !!configuration.getReportContact()
|
|
}
|
|
}
|
|
}
|
|
|
|
function configPayload() {
|
|
const config = configuration.getMasked()
|
|
return {
|
|
config,
|
|
restartRequired: Object.values(config).some(entry => entry.pendingRestart),
|
|
integrations: integrationsStatus()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Demande un jeton client credentials à Spotify avec les identifiants enregistrés
|
|
* (instance dédiée : le jeton en cache de SpotifyInformation n'est pas touché)
|
|
*/
|
|
async function testSpotify() {
|
|
const clientId = configuration.getSpotifyClientId()
|
|
const clientSecret = configuration.getSpotifyClientSecret()
|
|
if (!clientId || !clientSecret) throw new AdminError("UNAVAILABLE", "Identifiants Spotify non configurés")
|
|
const SpotifyWebApi = require('spotify-web-api-node')
|
|
const api = new SpotifyWebApi({ clientId, clientSecret })
|
|
const startedAt = Date.now()
|
|
let timer
|
|
let result
|
|
try {
|
|
const data = await Promise.race([
|
|
api.clientCredentialsGrant(),
|
|
new Promise((resolve, reject) => {
|
|
timer = setTimeout(() => reject(new Error("délai dépassé")), SPOTIFY_TEST_TIMEOUT)
|
|
timer.unref?.()
|
|
})
|
|
])
|
|
const expiresIn = Number(data?.body?.expires_in)
|
|
result = { ok: true, message: "Jeton obtenu : identifiants valides", expiresIn: Number.isFinite(expiresIn) ? expiresIn : null }
|
|
} catch (e) {
|
|
const status = Number(e?.statusCode)
|
|
let detail = String(e?.body?.error_description || e?.body?.error || e?.message || e)
|
|
// Par précaution : jamais le secret dans un message renvoyé ou journalisé
|
|
detail = detail.split(clientSecret).join("***").slice(0, 200)
|
|
result = {
|
|
ok: false,
|
|
message: status === 400 || status === 401 ? `Identifiants refusés par Spotify (${detail})` : `Spotify injoignable : ${detail}`
|
|
}
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
spotifyLastTest = { ...result, at: new Date().toISOString(), ms: Date.now() - startedAt }
|
|
return spotifyLastTest
|
|
}
|
|
|
|
// VUE D'ENSEMBLE
|
|
|
|
async function databasesInfo(withEntries = false) {
|
|
const result = []
|
|
for (const name of DATABASES) {
|
|
const db = getDatabase(name)
|
|
if (!db?.path) continue
|
|
const info = { name, sizeBytes: 0, modifiedAt: null }
|
|
try {
|
|
const stat = await fs.promises.stat(db.path)
|
|
info.sizeBytes = stat.size
|
|
info.modifiedAt = stat.mtime.toISOString()
|
|
} catch (e) {
|
|
// Fichier absent : taille 0
|
|
}
|
|
if (withEntries) {
|
|
const data = db.data
|
|
info.entries = Array.isArray(data) ? data.length : (data && typeof data === "object" ? Object.keys(data).length : 0)
|
|
}
|
|
result.push(info)
|
|
}
|
|
return result
|
|
}
|
|
|
|
function packageName() {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(__glob.PACKAGEINFO, "utf-8")).name || "chopin-backend"
|
|
} catch (e) {
|
|
return "chopin-backend"
|
|
}
|
|
}
|
|
|
|
function mediaStatus() {
|
|
const configured = !!(configuration.getMediaGuildId() && configuration.getMediaChannelId())
|
|
let connected = false
|
|
const mediaBase = getMediaBase()
|
|
if (typeof mediaBase?.isConnected === "function") {
|
|
connected = !!mediaBase.isConnected()
|
|
} else if (configured) {
|
|
// À défaut d'accesseur : le salon multimédia est-il visible par le bot ?
|
|
try {
|
|
connected = !!discordBot.getChannel(configuration.getMediaGuildId(), configuration.getMediaChannelId())
|
|
} catch (e) {
|
|
connected = false
|
|
}
|
|
}
|
|
return { configured, connected }
|
|
}
|
|
|
|
let cachedPackageName = null
|
|
|
|
async function buildOverview() {
|
|
const client = getClient()
|
|
const uptime = process.uptime()
|
|
const memory = process.memoryUsage()
|
|
|
|
// Joueurs
|
|
const playerCounts = { connected: 0, playing: 0, paused: 0, idle: 0 }
|
|
for (const player of connectedPlayers()) {
|
|
playerCounts.connected++
|
|
let status = "idle"
|
|
try {
|
|
status = playerStatus(player.getState())
|
|
} catch (e) {
|
|
status = "idle"
|
|
}
|
|
if (status === "playing") playerCounts.playing++
|
|
else if (status === "paused") playerCounts.paused++
|
|
else playerCounts.idle++
|
|
}
|
|
|
|
// Comptes
|
|
const allUsers = users.getUsers()
|
|
const userCounts = { registered: 0, admins: 0, fullBanned: 0, deleted: 0 }
|
|
for (const user of allUsers) {
|
|
const labels = user.labels || []
|
|
if (labels.includes("DELETED")) {
|
|
userCounts.deleted++
|
|
continue
|
|
}
|
|
userCounts.registered++
|
|
if (labels.includes("ADMIN")) userCounts.admins++
|
|
if (labels.includes("BAN")) userCounts.fullBanned++
|
|
}
|
|
|
|
// Web
|
|
const sockets = socketIndex()
|
|
let adminsOnline = 0
|
|
for (const userId of sockets.keys()) {
|
|
if (users.getUserById(userId)?.isAdmin()) adminsOnline++
|
|
}
|
|
|
|
// Discord
|
|
let voiceConnections = 0
|
|
try {
|
|
voiceConnections = require('@discordjs/voice').getVoiceConnections().size
|
|
} catch (e) {
|
|
voiceConnections = 0
|
|
}
|
|
const activity = client?.user?.presence?.activities?.[0]
|
|
|
|
const metrics = metricsData()
|
|
if (!cachedPackageName) cachedPackageName = packageName()
|
|
|
|
return {
|
|
app: {
|
|
name: cachedPackageName,
|
|
version: __glob.VERSION,
|
|
node: process.version,
|
|
platform: process.platform,
|
|
pid: process.pid,
|
|
startedAt: new Date(Date.now() - uptime * 1000).toISOString(),
|
|
uptime: Math.floor(uptime),
|
|
pm2: process.env.pm_id !== undefined
|
|
},
|
|
memory: {
|
|
rss: memory.rss,
|
|
heapUsed: memory.heapUsed,
|
|
heapTotal: memory.heapTotal,
|
|
external: memory.external
|
|
},
|
|
system: {
|
|
loadavg: os.loadavg().map(safeNum),
|
|
totalmem: os.totalmem(),
|
|
freemem: os.freemem(),
|
|
cpus: os.cpus().length,
|
|
hostname: os.hostname()
|
|
},
|
|
discord: {
|
|
ready: !!discordBot.isReady(),
|
|
id: client?.user?.id ?? null,
|
|
tag: client?.user?.tag ?? null,
|
|
avatar: client?.user ? client.user.displayAvatarURL() : null,
|
|
ping: safeNum(client?.ws?.ping),
|
|
readyAt: toIso(client?.readyAt),
|
|
guildCount: client?.guilds?.cache?.size ?? 0,
|
|
voiceConnections,
|
|
activity: activity ? (activity.state || activity.name || null) : null
|
|
},
|
|
web: {
|
|
sockets: io ? io.sockets.sockets.size : 0,
|
|
usersOnline: sockets.size,
|
|
pendingSessions: typeof session.getSessionCount === "function" ? session.getSessionCount() : 0,
|
|
adminsOnline
|
|
},
|
|
players: playerCounts,
|
|
users: userCounts,
|
|
warp: warp.getStatus(),
|
|
ytdlp: await youtubeOverview(),
|
|
media: mediaStatus(),
|
|
databases: await databasesInfo(false),
|
|
stats: metrics.global,
|
|
logs: {
|
|
current: LogReader.getCurrentName(),
|
|
interrupted: await LogReader.isInterrupted()
|
|
}
|
|
}
|
|
}
|
|
|
|
// SYNCHRONISATION DES SOCKETS
|
|
|
|
/**
|
|
* Applique les droits actuels de l'utilisateur à tous ses sockets (après chaque mutation de labels) :
|
|
* room ADMIN, sortie des guildes bannies, déconnexion si banni globalement ou supprimé.
|
|
*/
|
|
function syncUserSockets(userId) {
|
|
if (!io || !userId) return 0
|
|
const user = users.getUserById(userId)
|
|
const guilds = discordBot.getGuilds()
|
|
let affected = 0
|
|
for (const socket of [...io.sockets.sockets.values()]) {
|
|
if (socket.data?.userId !== userId) continue
|
|
affected++
|
|
if (!user || user.labels.includes("DELETED") || user.isFullBanned()) {
|
|
socket.emit("AUTH_ERROR", user?.isFullBanned() ? "Vous êtes banni du serveur" : "Votre compte a été supprimé")
|
|
socket.disconnect(true)
|
|
continue
|
|
}
|
|
if (user.isAdmin()) {
|
|
socket.join(ADMIN_ROOM)
|
|
} else {
|
|
socket.leave(ADMIN_ROOM)
|
|
socket.leave(LOGS_ROOM)
|
|
}
|
|
for (const room of [...socket.rooms]) {
|
|
if (guilds.has(room) && user.isBanned(room)) socket.leave(room)
|
|
}
|
|
}
|
|
process.emit("USERS_UPDATE")
|
|
return affected
|
|
}
|
|
|
|
/**
|
|
* Déconnecte toutes les sessions d'un utilisateur
|
|
* @returns {number} nombre de sockets déconnectés
|
|
*/
|
|
function disconnectUser(userId, message) {
|
|
if (!io) return 0
|
|
let count = 0
|
|
for (const socket of [...io.sockets.sockets.values()]) {
|
|
if (socket.data?.userId !== userId) continue
|
|
socket.emit("AUTH_ERROR", message)
|
|
socket.disconnect(true)
|
|
count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
// ACTIONS SUR LES COMPTES
|
|
|
|
function getTarget(userId) {
|
|
const target = users.getUserById(userId)
|
|
if (!target) throw new AdminError("NOT_FOUND", "Utilisateur introuvable")
|
|
return target
|
|
}
|
|
|
|
function freshSummary(userId) {
|
|
const user = users.getUserById(userId)
|
|
if (!user) throw new AdminError("NOT_FOUND", "Utilisateur introuvable")
|
|
return userSummary(user)
|
|
}
|
|
|
|
async function setAdminValue(me, userId, value) {
|
|
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas modifier vos propres droits")
|
|
const target = getTarget(userId)
|
|
if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé")
|
|
const state = await users.setAdmin(userId, value)
|
|
if (state === null) throw new AdminError("INTERNAL", "Modification impossible")
|
|
audit(me, value ? "promotion admin" : "retrait admin", describeUser(target))
|
|
syncUserSockets(userId)
|
|
return freshSummary(userId)
|
|
}
|
|
|
|
async function setFullBanValue(me, userId, value, reason) {
|
|
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas vous bannir vous-même")
|
|
const target = getTarget(userId)
|
|
if (value && target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être banni")
|
|
const state = await users.setFullBan(userId, value, reason || undefined)
|
|
if (state === null) throw new AdminError("INTERNAL", "Modification impossible")
|
|
audit(me, value ? "bannissement global" : "levée du bannissement global", describeUser(target) + (reason ? ` - raison : ${reason}` : ""))
|
|
syncUserSockets(userId)
|
|
return freshSummary(userId)
|
|
}
|
|
|
|
async function deleteUserAccount(me, userId, purge) {
|
|
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas supprimer votre propre compte ici")
|
|
const target = getTarget(userId)
|
|
if (target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être supprimé")
|
|
// Déconnexion d'abord : les sockets ne doivent plus agir pendant la suppression
|
|
disconnectUser(userId, "Votre compte a été supprimé")
|
|
const purged = await users.deleteAccount(userId, { purge })
|
|
if (purged === null) throw new AdminError("NOT_FOUND", "Utilisateur introuvable")
|
|
audit(me, purge ? "suppression du compte (avec purge)" : "suppression du compte", describeUser(target))
|
|
syncUserSockets(userId)
|
|
return { deleted: true, purged }
|
|
}
|
|
|
|
// ACTIONS SUR LES PLAYERS
|
|
|
|
function getConnectedPlayer(guildId) {
|
|
const player = players.AllPlayers.get(guildId)
|
|
if (!isConnectedPlayer(player)) throw new AdminError("NOT_FOUND", "Aucun player connecté sur ce serveur")
|
|
return player
|
|
}
|
|
|
|
/**
|
|
* @param {boolean} [value] loop / shuffle : état voulu (idempotent) ; absent = bascule (ancien client)
|
|
*/
|
|
async function runPlayerAction(player, action, value) {
|
|
const guildId = player.guildId
|
|
switch (action) {
|
|
case "pause":
|
|
case "resume": {
|
|
const value = action === "pause"
|
|
let result
|
|
if (typeof player.setPaused === "function") {
|
|
result = await player.setPaused(value)
|
|
} else {
|
|
// Ancienne API : pause() bascule, on vérifie l'état avant pour rester idempotent
|
|
const status = player.player?.state?.status
|
|
const paused = status === "paused" || status === "autopaused"
|
|
if (paused !== value) await player.pause()
|
|
result = { ok: true, paused: value }
|
|
}
|
|
if (result && result.ok === false) throw new AdminError("UNAVAILABLE", result.reason || "Action impossible")
|
|
break
|
|
}
|
|
case "skip":
|
|
case "previous": {
|
|
const result = await player[action]()
|
|
if (!result || result === "no_music") throw new AdminError("UNAVAILABLE", "no_music")
|
|
break
|
|
}
|
|
case "stop":
|
|
if (typeof player.stop === "function") {
|
|
await player.stop()
|
|
} else {
|
|
player.loop = false
|
|
player.queue?.clearNext?.()
|
|
player.player?.stop(true)
|
|
}
|
|
break
|
|
case "leave":
|
|
await player.leave("admin")
|
|
return null
|
|
case "loop":
|
|
if (value === undefined || !!player.loop !== value) await player.setLoop()
|
|
break
|
|
case "shuffle":
|
|
if (value === undefined || !!player.queue?.shuffle !== value) await player.setShuffle()
|
|
break
|
|
case "clear":
|
|
player.queue.clearNext()
|
|
break
|
|
}
|
|
const current = players.AllPlayers.get(guildId)
|
|
return isConnectedPlayer(current) ? playerSummary(current) : null
|
|
}
|
|
|
|
// LOGS TEMPS RÉEL
|
|
|
|
// Démarrage du suivi en cours : un second abonnement l'attend au lieu de renvoyer un tampon encore vide
|
|
let tailStarting = null
|
|
|
|
async function ensureTail() {
|
|
if (tailStarting) return tailStarting
|
|
if (LogReader.isTailing()) return
|
|
tailStarting = LogReader.startTail(({ name, lines }) => {
|
|
if (!io || roomSize(LOGS_ROOM) === 0) return
|
|
io.to(LOGS_ROOM).emit("/ADMIN/LOGS/APPEND", { name, lines })
|
|
}).finally(() => {
|
|
tailStarting = null
|
|
})
|
|
return tailStarting
|
|
}
|
|
|
|
/**
|
|
* Offset du début de la première ligne complète d'une lecture commençant à `start`
|
|
* (LogReader renvoie l'offset demandé et ignore la ligne partielle : le client en a besoin pour
|
|
* « Charger les lignes plus anciennes » sans perdre la ligne à cheval sur deux blocs)
|
|
*/
|
|
async function firstLineOffset(name, start, end) {
|
|
if (!(start > 0)) return 0
|
|
const handle = await fs.promises.open(path.join(__glob.LOGS, name), "r")
|
|
try {
|
|
const buffer = Buffer.alloc(64 * 1024)
|
|
// On part de l'octet précédent : s'il s'agit d'un saut de ligne, start est déjà un début de ligne
|
|
let position = start - 1
|
|
while (position < end) {
|
|
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, end - position), position)
|
|
if (!bytesRead) break
|
|
const index = buffer.subarray(0, bytesRead).indexOf(0x0a)
|
|
if (index !== -1) return position + index + 1
|
|
position += bytesRead
|
|
}
|
|
return end
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
}
|
|
|
|
// ENREGISTREMENT DES ROUTES
|
|
|
|
/**
|
|
* Enregistre toutes les routes d'administration pour un socket authentifié.
|
|
* @param {{io?: import('socket.io').Server, socket: import('socket.io').Socket, socketUser: function|Object}} options
|
|
* socketUser : fonction renvoyant l'utilisateur courant du socket (ou l'objet lui-même)
|
|
*/
|
|
function attach({ io: ioInstance, socket, socketUser }) {
|
|
if (!io && ioInstance) io = ioInstance
|
|
|
|
function getMe() {
|
|
const current = typeof socketUser === "function" ? socketUser() : socketUser
|
|
const id = current?.identity?.id || socket.data?.userId
|
|
return id ? users.getUserById(id) : null
|
|
}
|
|
|
|
/**
|
|
* Route d'administration : droits relus à chaque appel, réponse par enveloppe {ok, data|error}
|
|
*/
|
|
function AdminRequest(name, handler) {
|
|
socket.on(name, async (payload, ack) => {
|
|
if (typeof payload === "function" && ack === undefined) {
|
|
ack = payload
|
|
payload = undefined
|
|
}
|
|
const reply = (env) => {
|
|
if (typeof ack === "function") ack(env)
|
|
else socket.emit(name, env)
|
|
}
|
|
const me = getMe()
|
|
if (!me || !me.isAdmin() || me.isFullBanned() || me.labels.includes("DELETED")) {
|
|
alog.warn(`[ADMIN] Accès refusé à ${name} pour ${describeUser(me)} - Socket : ${socket.id}`)
|
|
socket.leave(ADMIN_ROOM)
|
|
socket.leave(LOGS_ROOM)
|
|
return reply({ ok: false, error: "FORBIDDEN", message: "Accès réservé aux administrateurs" })
|
|
}
|
|
try {
|
|
const data = await handler(payload, me)
|
|
reply({ ok: true, data: data === undefined ? null : data })
|
|
} catch (e) {
|
|
if (e instanceof AdminError) {
|
|
return reply({ ok: false, error: e.code, message: e.message })
|
|
}
|
|
alog.error(`${name} : ${e?.stack || e}`)
|
|
reply({ ok: false, error: "INTERNAL", message: String(e?.message || e) })
|
|
}
|
|
})
|
|
}
|
|
|
|
// 1. VUE D'ENSEMBLE
|
|
|
|
AdminRequest("/ADMIN/OVERVIEW", async () => buildOverview())
|
|
|
|
// 2. LOGS
|
|
|
|
AdminRequest("/ADMIN/LOGS/LIST", async () => LogReader.listLogs())
|
|
|
|
// Ancien nom : renvoie la liste, sans le contenu (l'ancienne version lisait tout en synchrone)
|
|
AdminRequest("/ADMIN/LOGS", async () => LogReader.listLogs())
|
|
|
|
AdminRequest("/ADMIN/LOGS/READ", async (payload) => {
|
|
const data = asObject(payload)
|
|
if (!LogReader.isValidName(data.name)) throw new AdminError("INVALID_PARAMS", "Nom de fichier invalide")
|
|
const offset = optionalInt(data.offset, "offset", 0, Number.MAX_SAFE_INTEGER, undefined)
|
|
const limit = optionalInt(data.limit, "limit", 1, 1048576, 262144)
|
|
let levels
|
|
if (data.levels !== undefined && data.levels !== null) {
|
|
if (!Array.isArray(data.levels) || data.levels.some(l => typeof l !== "string" || !LOG_LEVELS.includes(l.toUpperCase()))) {
|
|
throw new AdminError("INVALID_PARAMS", "Paramètre \"levels\" invalide")
|
|
}
|
|
levels = data.levels.map(l => l.toUpperCase())
|
|
}
|
|
const type = optionalString(data.type, "type", 64)
|
|
const search = optionalString(data.search, "search", 200)
|
|
const result = await LogReader.readLog(data.name, { offset, limit, levels, type, search })
|
|
if (!result) throw new AdminError("NOT_FOUND", "Fichier de logs introuvable")
|
|
let lineStart = result.offset
|
|
try {
|
|
lineStart = await firstLineOffset(data.name, result.offset, result.nextOffset)
|
|
} catch (e) {
|
|
lineStart = result.offset
|
|
}
|
|
return { ...result, firstLineOffset: lineStart }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/LOGS/SUBSCRIBE", async () => {
|
|
socket.join(LOGS_ROOM)
|
|
await ensureTail()
|
|
return { name: LogReader.getCurrentName(), lines: LogReader.getBuffer(300) }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/LOGS/UNSUBSCRIBE", async () => {
|
|
socket.leave(LOGS_ROOM)
|
|
return true
|
|
})
|
|
|
|
AdminRequest("/ADMIN/LOGS/DELETE", async (payload, me) => {
|
|
const name = asObject(payload).name
|
|
if (!LogReader.isValidName(name)) throw new AdminError("INVALID_PARAMS", "Nom de fichier invalide")
|
|
const result = await LogReader.deleteLog(name)
|
|
if (result === "protected") throw new AdminError("TARGET_PROTECTED", "Le fichier de logs courant ne peut pas être supprimé")
|
|
if (result === "not_found") throw new AdminError("NOT_FOUND", "Fichier de logs introuvable")
|
|
audit(me, "suppression du fichier de logs", name)
|
|
return { deleted: name }
|
|
})
|
|
|
|
// 3. COMPTES
|
|
|
|
AdminRequest("/ADMIN/USERS/LIST", async (payload) => {
|
|
const data = asObject(payload)
|
|
const search = optionalString(data.search, "search", 100)
|
|
const filter = data.filter === undefined || data.filter === null ? "all" : data.filter
|
|
if (!USER_FILTERS.includes(filter)) throw new AdminError("INVALID_PARAMS", "Paramètre \"filter\" invalide")
|
|
const page = optionalInt(data.page, "page", 1, 100000, 1)
|
|
const pageSize = optionalInt(data.pageSize, "pageSize", 1, 200, 50)
|
|
|
|
const ctx = buildContext()
|
|
let list = users.getUsers().map(u => userSummary(u, ctx))
|
|
if (search) {
|
|
const s = search.toLowerCase()
|
|
list = list.filter(u => (u.id || "").includes(s)
|
|
|| (u.username || "").toLowerCase().includes(s)
|
|
|| (u.global_name || "").toLowerCase().includes(s))
|
|
}
|
|
switch (filter) {
|
|
case "online": list = list.filter(u => u.online); break
|
|
case "admin": list = list.filter(u => u.isAdmin); break
|
|
case "banned": list = list.filter(u => u.isFullBanned || u.bannedGuilds.length > 0); break
|
|
case "deleted": list = list.filter(u => u.isDeleted); break
|
|
}
|
|
// En ligne d'abord, puis ordre alphabétique
|
|
list.sort((a, b) => (b.online - a.online) || (a.global_name || a.username || "").localeCompare(b.global_name || b.username || ""))
|
|
const start = (page - 1) * pageSize
|
|
return { total: list.length, page, pageSize, items: list.slice(start, start + pageSize) }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/USERS/GET", async (payload) => {
|
|
const userId = userIdFrom(payload)
|
|
const user = getTarget(userId)
|
|
const ctx = buildContext()
|
|
const summary = userSummary(user, ctx)
|
|
|
|
const guilds = []
|
|
for (const guildId of summary.guildIds) {
|
|
const guild = discordBot.getGuilds().get(guildId)
|
|
if (!guild) continue
|
|
guilds.push({
|
|
id: guildId,
|
|
name: guild.name ?? null,
|
|
icon: guild.icon ?? null,
|
|
isOwner: guild.owner === userId,
|
|
isMod: user.isMod(guildId),
|
|
isBanned: user.isBanned(guildId)
|
|
})
|
|
}
|
|
|
|
const sockets = (ctx.sockets.get(userId) || []).map(s => ({
|
|
socketId: s.id,
|
|
address: s.handshake?.headers?.["x-forwarded-for"]?.split(",")[0]?.trim() || s.handshake?.address || null,
|
|
userAgent: s.handshake?.headers?.["user-agent"] ?? null,
|
|
connectedAt: toIso(s.handshake?.issued),
|
|
guildId: socketGuildId(s)
|
|
}))
|
|
|
|
const userPlaylists = Array.isArray(ctx.playlists?.[userId]) ? ctx.playlists[userId] : []
|
|
const userHistory = Array.isArray(ctx.history?.[userId]) ? ctx.history[userId] : []
|
|
const media = dbData("media", [])
|
|
|
|
return {
|
|
...summary,
|
|
// Identité Discord complète (jamais auth ni tokens)
|
|
identity: { ...user.identity },
|
|
guilds,
|
|
sockets,
|
|
playlists: userPlaylists.map(p => ({
|
|
playlistId: p?.playlistId != null ? String(p.playlistId) : null,
|
|
title: p?.title ?? null,
|
|
type: p?.type ?? null,
|
|
songCount: Array.isArray(p?.songs) ? p.songs.length : 0
|
|
})),
|
|
history: userHistory.map(songLite).filter(Boolean),
|
|
media: (Array.isArray(media) ? media : []).filter(m => m?.userId === userId).map(m => ({
|
|
id: m.id ?? null,
|
|
name: m.name ?? null,
|
|
size: safeNum(m.size),
|
|
createdAt: toIso(m.createdAt),
|
|
url: m.url ?? null
|
|
}))
|
|
}
|
|
})
|
|
|
|
AdminRequest("/ADMIN/USERS/SET_ADMIN", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const userId = requireId(data.userId)
|
|
const value = requireBool(data.value)
|
|
return setAdminValue(me, userId, value)
|
|
})
|
|
|
|
// Ancien nom : bascule, userId en chaîne
|
|
AdminRequest("/ADMIN/USERS/SWITCH_ADMIN", async (payload, me) => {
|
|
const userId = userIdFrom(payload)
|
|
const target = getTarget(userId)
|
|
return setAdminValue(me, userId, !target.isAdmin())
|
|
})
|
|
|
|
AdminRequest("/ADMIN/USERS/SET_FULL_BAN", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const userId = requireId(data.userId)
|
|
const value = requireBool(data.value)
|
|
const reason = optionalString(data.reason, "reason", 500)
|
|
return setFullBanValue(me, userId, value, reason)
|
|
})
|
|
|
|
// Ancien nom : bascule, userId en chaîne
|
|
AdminRequest("/ADMIN/USERS/FULL_BAN", async (payload, me) => {
|
|
const userId = userIdFrom(payload)
|
|
const target = getTarget(userId)
|
|
return setFullBanValue(me, userId, !target.isFullBanned(), null)
|
|
})
|
|
|
|
AdminRequest("/ADMIN/USERS/SET_GUILD_BAN", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const userId = requireId(data.userId)
|
|
const guildId = requireId(data.guildId, "guildId")
|
|
const value = requireBool(data.value)
|
|
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas vous bannir vous-même")
|
|
const target = getTarget(userId)
|
|
if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable")
|
|
if (value && target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être banni")
|
|
const state = await users.setGuildBan(userId, guildId, value)
|
|
if (state === null) throw new AdminError("INTERNAL", "Modification impossible")
|
|
audit(me, value ? "bannissement du serveur" : "levée du bannissement du serveur", `${describeUser(target)} sur ${guildId}`)
|
|
if (value && io) io.in("user:" + userId).socketsLeave(guildId)
|
|
syncUserSockets(userId)
|
|
return freshSummary(userId)
|
|
})
|
|
|
|
AdminRequest("/ADMIN/USERS/SET_GUILD_MOD", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const userId = requireId(data.userId)
|
|
const guildId = requireId(data.guildId, "guildId")
|
|
const value = requireBool(data.value)
|
|
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas modifier vos propres droits")
|
|
const target = getTarget(userId)
|
|
if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé")
|
|
if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable")
|
|
if (value && (target.isFullBanned() || target.labels.includes("BAN_" + guildId))) {
|
|
throw new AdminError("INVALID_PARAMS", "Cet utilisateur est banni : levez d'abord le bannissement")
|
|
}
|
|
const state = await users.setGuildMod(userId, guildId, value)
|
|
if (state === null) throw new AdminError("INTERNAL", "Modification impossible")
|
|
audit(me, value ? "nomination modérateur" : "retrait modérateur", `${describeUser(target)} sur ${guildId}`)
|
|
syncUserSockets(userId)
|
|
return freshSummary(userId)
|
|
})
|
|
|
|
AdminRequest("/ADMIN/USERS/DISCONNECT", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const userId = requireId(data.userId)
|
|
const revokeTokens = data.revokeTokens === undefined ? false : requireBool(data.revokeTokens, "revokeTokens")
|
|
const message = optionalString(data.message, "message", 200)
|
|
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Utilisez la déconnexion de votre compte")
|
|
const target = getTarget(userId)
|
|
const disconnectedSockets = disconnectUser(userId, message || "Vous avez été déconnecté par un administrateur")
|
|
const revokedTokens = revokeTokens ? users.revokeTokens(userId) : 0
|
|
audit(me, revokeTokens ? "déconnexion et révocation des sessions" : "déconnexion", `${describeUser(target)} (${disconnectedSockets} socket(s), ${revokedTokens} token(s))`)
|
|
process.emit("USERS_UPDATE")
|
|
return { disconnectedSockets, revokedTokens }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/USERS/REFRESH", async (payload, me) => {
|
|
const userId = userIdFrom(payload)
|
|
const target = getTarget(userId)
|
|
if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé")
|
|
if (!target.auth) throw new AdminError("UNAVAILABLE", "Aucune authentification Discord pour ce compte")
|
|
const identity = await users.updateIdentity(userId)
|
|
if (!identity) throw new AdminError("UNAVAILABLE", "L'API Discord n'a pas répondu correctement")
|
|
users.getUserById(userId)?.justUpdated()
|
|
audit(me, "rafraîchissement de l'identité", describeUser(target))
|
|
process.emit("USERS_UPDATE")
|
|
return freshSummary(userId)
|
|
})
|
|
|
|
AdminRequest("/ADMIN/USERS/DELETE", async (payload, me) => {
|
|
const userId = userIdFrom(payload)
|
|
const data = asObject(payload)
|
|
// Purge uniquement sur demande explicite (l'ancien format "id" seul ne purge plus rien)
|
|
const purge = data.purge === undefined || data.purge === null ? false : requireBool(data.purge, "purge")
|
|
return deleteUserAccount(me, userId, purge)
|
|
})
|
|
|
|
// 4. SERVEURS DISCORD
|
|
|
|
AdminRequest("/ADMIN/GUILDS/LIST", async (payload) => {
|
|
const search = optionalString(asObject(payload).search, "search", 100)
|
|
return guildsList(search)
|
|
})
|
|
|
|
AdminRequest("/ADMIN/GUILDS/GET", async (payload) => {
|
|
const guildId = requireId(asObject(payload).guildId, "guildId")
|
|
const guildData = discordBot.getGuilds().get(guildId)
|
|
if (!guildData) throw new AdminError("NOT_FOUND", "Serveur introuvable")
|
|
const ctx = buildContext()
|
|
const summary = guildSummary(guildData, ctx)
|
|
const discordGuild = getClient().guilds.cache.get(guildId)
|
|
|
|
const roles = discordBot.getGuildRoles(guildId).sort((a, b) => b.position - a.position)
|
|
|
|
const members = []
|
|
for (const memberId of guildData.allMembers || []) {
|
|
const user = users.getUserById(memberId)
|
|
if (!user || user.labels.includes("DELETED")) continue
|
|
members.push({
|
|
...userLite(user.identity),
|
|
isOwner: guildData.owner === memberId,
|
|
isMod: user.isMod(guildId),
|
|
isBanned: user.isBanned(guildId),
|
|
isAdmin: user.isAdmin(),
|
|
online: ctx.sockets.has(memberId)
|
|
})
|
|
}
|
|
|
|
const voiceChannels = []
|
|
if (discordGuild) {
|
|
for (const channel of discordGuild.channels.cache.values()) {
|
|
if (typeof channel.isVoiceBased !== "function" || !channel.isVoiceBased()) continue
|
|
voiceChannels.push({
|
|
id: channel.id,
|
|
name: channel.name,
|
|
members: channel.members ? channel.members.filter(m => !m.user?.bot).size : 0
|
|
})
|
|
}
|
|
}
|
|
|
|
const metrics = SafeMetric.getGuildMetrics(guildId).map(m => ({
|
|
name: m.name,
|
|
description: m.description ?? m.name,
|
|
value: safeNum(m.value),
|
|
invalid: !Number.isFinite(Number(m.value))
|
|
}))
|
|
|
|
return { ...summary, roles, members, voiceChannels, metrics }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/GUILDS/LEAVE", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const guildId = requireId(data.guildId, "guildId")
|
|
if (typeof data.confirmName !== "string") throw new AdminError("INVALID_PARAMS", "Confirmation du nom du serveur requise")
|
|
const discordGuild = getClient().guilds.cache.get(guildId)
|
|
const guildData = discordBot.getGuilds().get(guildId)
|
|
if (!discordGuild && !guildData) throw new AdminError("NOT_FOUND", "Serveur introuvable")
|
|
if (guildId === configuration.getMediaGuildId()) {
|
|
throw new AdminError("TARGET_PROTECTED", "Le serveur de la base multimédia ne peut pas être quitté")
|
|
}
|
|
const name = discordGuild?.name ?? guildData?.name
|
|
if (data.confirmName.trim() !== String(name ?? "").trim()) throw new AdminError("INVALID_PARAMS", "Le nom de confirmation ne correspond pas au nom du serveur")
|
|
if (!discordGuild) throw new AdminError("NOT_FOUND", "Le bot n'est plus sur ce serveur")
|
|
|
|
const player = players.AllPlayers.get(guildId)
|
|
if (isConnectedPlayer(player)) {
|
|
try {
|
|
await player.leave("admin")
|
|
} catch (e) {
|
|
alog.error(`Impossible de quitter le salon vocal de ${guildId} : ${e?.message || e}`)
|
|
}
|
|
}
|
|
if (typeof discordBot.clearAutoLeaveTimer === "function") discordBot.clearAutoLeaveTimer(guildId)
|
|
try {
|
|
await discordGuild.leave()
|
|
} catch (e) {
|
|
throw new AdminError("UNAVAILABLE", `Discord a refusé le départ du serveur : ${e?.message || e}`)
|
|
}
|
|
audit(me, "départ du serveur", `${name} (${guildId})`)
|
|
// guildDelete retire aussi l'entrée, on le fait tout de suite pour une réponse cohérente
|
|
discordBot.getGuilds().delete(guildId)
|
|
if (io) {
|
|
io.to(guildId).emit("/GUILD/UPDATE")
|
|
io.in(guildId).socketsLeave(guildId)
|
|
}
|
|
process.emit("USERS_UPDATE")
|
|
process.emit("GUILDS_UPDATE")
|
|
return { left: true, guildId }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/GUILDS/SET_SECURE_ROLE", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const guildId = requireId(data.guildId, "guildId")
|
|
if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable")
|
|
let role = null
|
|
if (data.roleId !== null && data.roleId !== undefined) {
|
|
const roleId = requireId(data.roleId, "roleId")
|
|
const found = discordBot.getGuildRoles(guildId).find(r => r.id === roleId)
|
|
if (!found) throw new AdminError("INVALID_PARAMS", "Rôle introuvable sur ce serveur")
|
|
// @everyone équivaut à aucun rôle
|
|
role = found.name === "@everyone" ? null : { id: found.id, name: found.name, color: found.color, position: found.position }
|
|
}
|
|
serverSettings.setSecureRole(guildId, role)
|
|
audit(me, "rôle sécurisé", `${guildId} -> ${role ? role.name + " (" + role.id + ")" : "aucun"}`)
|
|
process.emit("GUILDS_UPDATE")
|
|
return { secureRole: role ? { id: role.id, name: role.name } : null }
|
|
})
|
|
|
|
// Playlists partagées d'un serveur : {guildId, enabled?, editors?} (désactiver masque sans rien supprimer)
|
|
AdminRequest("/ADMIN/GUILDS/SET_SHARED_PLAYLISTS", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const guildId = requireId(data.guildId, "guildId")
|
|
if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable")
|
|
const patch = {}
|
|
if (data.enabled !== undefined) patch.enabled = requireBool(data.enabled, "enabled")
|
|
if (data.editors !== undefined) {
|
|
if (!SHARED_PLAYLISTS_EDITORS.includes(data.editors)) throw new AdminError("INVALID_PARAMS", "Paramètre \"editors\" invalide (members ou mods)")
|
|
patch.editors = data.editors
|
|
}
|
|
if (Object.keys(patch).length === 0) throw new AdminError("INVALID_PARAMS", "Aucune modification demandée (enabled ou editors)")
|
|
const settings = serverSettings.setSharedPlaylists(guildId, patch)
|
|
if (!settings) throw new AdminError("INVALID_PARAMS", "Réglage des playlists partagées invalide")
|
|
audit(me, "playlists partagées", `${guildId} -> ${settings.enabled ? "activées" : "désactivées"}, modification : ${settings.editors}`)
|
|
process.emit("GUILDS_UPDATE")
|
|
// Les membres présents sur ce serveur rechargent leurs playlists partagées
|
|
process.emit("GUILD_PLAYLISTS_UPDATE", guildId)
|
|
return { sharedPlaylists: sharedPlaylistsSummary(guildId) }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/GUILDS/REFRESH", async (payload, me) => {
|
|
if (typeof discordBot.refreshGuilds !== "function") throw new AdminError("UNAVAILABLE", "Rafraîchissement indisponible")
|
|
await discordBot.refreshGuilds()
|
|
audit(me, "rafraîchissement des serveurs")
|
|
process.emit("USERS_UPDATE")
|
|
return guildsList(null)
|
|
})
|
|
|
|
// 5. PLAYERS
|
|
|
|
AdminRequest("/ADMIN/PLAYERS/LIST", async () => playersList())
|
|
|
|
AdminRequest("/ADMIN/PLAYERS/GET", async (payload) => {
|
|
const guildId = requireId(asObject(payload).guildId, "guildId")
|
|
const player = getConnectedPlayer(guildId)
|
|
const summary = playerSummary(player, { full: true })
|
|
if (!summary) throw new AdminError("UNAVAILABLE", "État du player illisible")
|
|
return summary
|
|
})
|
|
|
|
AdminRequest("/ADMIN/PLAYERS/ACTION", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const guildId = requireId(data.guildId, "guildId")
|
|
if (!PLAYER_ACTIONS.includes(data.action)) throw new AdminError("INVALID_PARAMS", "Action inconnue")
|
|
// loop / shuffle : valeur explicite facultative (sinon bascule)
|
|
let value
|
|
if ((data.action === "loop" || data.action === "shuffle") && data.value !== undefined && data.value !== null) {
|
|
value = requireBool(data.value)
|
|
}
|
|
const player = getConnectedPlayer(guildId)
|
|
const guildName = discordBot.getGuilds().get(guildId)?.name ?? guildId
|
|
const result = await runPlayerAction(player, data.action, value)
|
|
// Journalisé après coup : une action refusée (UNAVAILABLE...) n'apparaît pas comme effectuée
|
|
audit(me, `player ${data.action}${value === undefined ? "" : (value ? " (activé)" : " (désactivé)")}`, `sur ${guildName} (${guildId})`)
|
|
return result
|
|
})
|
|
|
|
// Ancien nom (corrigé) : états bruts des players connectés
|
|
AdminRequest("/ADMIN/PLAYER/GETALLSTATE", async () => {
|
|
const states = []
|
|
for (const player of connectedPlayers()) {
|
|
try {
|
|
states.push(player.getState())
|
|
} catch (e) {
|
|
alog.error(`Lecture de l'état du player ${player.guildId} impossible : ${e?.message || e}`)
|
|
}
|
|
}
|
|
return states
|
|
})
|
|
|
|
// 6. WARP
|
|
|
|
AdminRequest("/ADMIN/WARP/STATUS", async () => warp.getStatus())
|
|
|
|
AdminRequest("/ADMIN/WARP/CHECK", async () => {
|
|
if (!warp.getStatus().configured) throw new AdminError("UNAVAILABLE", "Aucun proxy WARP configuré")
|
|
let timer
|
|
try {
|
|
await Promise.race([
|
|
warp.checkHealth(),
|
|
new Promise((resolve, reject) => {
|
|
timer = setTimeout(() => reject(new AdminError("UNAVAILABLE", "Délai de vérification dépassé")), WARP_CHECK_TIMEOUT)
|
|
})
|
|
])
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
return warp.getStatus()
|
|
})
|
|
|
|
AdminRequest("/ADMIN/WARP/CONFIG", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const patch = {}
|
|
for (const key of ["enabled", "url", "fallbackDirect"]) {
|
|
if (data[key] !== undefined) patch[key] = data[key]
|
|
}
|
|
if (Object.keys(patch).length === 0) throw new AdminError("INVALID_PARAMS", "Aucune modification demandée")
|
|
let result
|
|
try {
|
|
// Champs verrouillés par l'environnement, URL invalide... : INVALID_PARAMS
|
|
result = await warp.setConfig(patch, { check: false })
|
|
} catch (e) {
|
|
if (e?.code === "INVALID_PARAMS") throw new AdminError("INVALID_PARAMS", e.message)
|
|
throw e
|
|
}
|
|
// Jamais l'URL brute dans le journal (identifiants éventuels) : setConfig masque déjà les URL
|
|
if (result.changes.length) audit(me, "configuration WARP", result.changes.join(", "))
|
|
// Contrôle immédiat du proxy, borné pour répondre avant le délai du client
|
|
if (warp.getStatus().configured) {
|
|
let timer
|
|
try {
|
|
await Promise.race([
|
|
warp.checkHealth(),
|
|
new Promise((resolve) => {
|
|
timer = setTimeout(resolve, WARP_CHECK_TIMEOUT)
|
|
timer.unref?.()
|
|
})
|
|
])
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
return { status: warp.getStatus(), changes: result.changes }
|
|
})
|
|
|
|
// 7. MÉTRIQUES
|
|
|
|
AdminRequest("/ADMIN/METRICS", async () => metricsData())
|
|
|
|
AdminRequest("/ADMIN/METRICS/REPAIR", async (payload, me) => {
|
|
const repaired = SafeMetric.repair()
|
|
audit(me, "réparation des métriques", `${repaired} valeur(s) corrigée(s)`)
|
|
return { repaired }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/METRICS/DELETE", async (payload, me) => {
|
|
const name = requireString(asObject(payload).name, "name", 1, 200)
|
|
if (!SafeMetric.remove(name)) throw new AdminError("NOT_FOUND", "Métrique introuvable")
|
|
audit(me, "suppression de la métrique", name)
|
|
return { deleted: name }
|
|
})
|
|
|
|
// 8. MAINTENANCE
|
|
|
|
AdminRequest("/ADMIN/MAINTENANCE/RESTART", async (payload, me) => {
|
|
// Une chaîne seule reste acceptée (ancien client)
|
|
const reason = requireString(typeof payload === "string" ? payload : asObject(payload).reason, "reason", 3, 200)
|
|
if (maintenance.isRestarting()) throw new AdminError("UNAVAILABLE", "Un redémarrage est déjà en cours")
|
|
audit(me, "redémarrage", `raison : ${reason}`)
|
|
const at = new Date().toISOString()
|
|
// Après la réponse : annonce "/MAINTENANCE" (via l'événement MAINTENANCE), départ des salons vocaux puis arrêt
|
|
setTimeout(() => maintenance.restart(reason), 50)
|
|
return { restarting: true, at }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/MAINTENANCE/ANNOUNCE", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const message = requireString(data.message, "message", 1, 500)
|
|
const level = data.level === undefined || data.level === null ? "info" : data.level
|
|
if (level !== "info" && level !== "warn") throw new AdminError("INVALID_PARAMS", "Paramètre \"level\" invalide (info ou warn)")
|
|
if (io) io.emit("/ANNOUNCE", { message, level, at: new Date().toISOString() })
|
|
audit(me, "annonce globale", `[${level}] ${message}`)
|
|
return true
|
|
})
|
|
|
|
AdminRequest("/ADMIN/DATABASES", async () => databasesInfo(true))
|
|
|
|
AdminRequest("/ADMIN/PLAYLISTS/LIST", async (payload) => {
|
|
const data = asObject(payload)
|
|
const userId = data.userId === undefined || data.userId === null ? null : requireId(data.userId)
|
|
const all = dbData("Playlists", {})
|
|
const result = []
|
|
for (const ownerId of Object.keys(all)) {
|
|
if (userId && ownerId !== userId) continue
|
|
const list = Array.isArray(all[ownerId]) ? all[ownerId] : []
|
|
if (list.length === 0 && !userId) continue
|
|
const owner = users.getUserById(ownerId)
|
|
result.push({
|
|
userId: ownerId,
|
|
username: owner?.identity?.username ?? null,
|
|
// Identité légère pour l'affichage (jamais auth ni tokens)
|
|
owner: owner ? { ...userLite(owner.identity), isAdmin: owner.labels?.includes("ADMIN") ?? false, isDeleted: owner.labels?.includes("DELETED") ?? false } : null,
|
|
playlists: list.map(playlistLite)
|
|
})
|
|
}
|
|
return result
|
|
})
|
|
|
|
// Playlists partagées des serveurs : {guildId?}. Serveurs ayant des playlists (même désactivées ou quittés
|
|
// par le bot) ou l'option activée
|
|
AdminRequest("/ADMIN/PLAYLISTS/GUILDS", async (payload) => {
|
|
const data = asObject(payload)
|
|
const filter = data.guildId === undefined || data.guildId === null ? null : requireId(data.guildId, "guildId")
|
|
const botGuilds = discordBot.getGuilds()
|
|
const ids = new Set(guildPlaylists.listGuildIds())
|
|
if (typeof serverSettings.getSharedPlaylists === "function") {
|
|
for (const guildId of botGuilds.keys()) {
|
|
if (serverSettings.getSharedPlaylists(guildId)?.enabled) ids.add(guildId)
|
|
}
|
|
}
|
|
if (filter) {
|
|
ids.clear()
|
|
ids.add(filter)
|
|
}
|
|
const result = []
|
|
for (const guildId of ids) {
|
|
const list = guildPlaylists.getPlaylists(guildId)
|
|
const shared = sharedPlaylistsSummary(guildId)
|
|
if (!filter && list.length === 0 && !shared.enabled) continue
|
|
const guild = botGuilds.get(guildId)
|
|
result.push({
|
|
guildId,
|
|
guildName: guild?.name ?? null,
|
|
guildIcon: guild?.icon ?? null,
|
|
settings: { enabled: shared.enabled, editors: shared.editors },
|
|
playlists: list.map(p => ({
|
|
...playlistLite(p),
|
|
createdBy: p?.createdBy ? userLite(p.createdBy) : null
|
|
}))
|
|
})
|
|
}
|
|
return result.sort((a, b) => (a.guildName || a.guildId).localeCompare(b.guildName || b.guildId))
|
|
})
|
|
|
|
// {userId, playlistId} : playlist personnelle ; {guildId, playlistId} : playlist partagée d'un serveur
|
|
AdminRequest("/ADMIN/PLAYLISTS/DELETE", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
const playlistId = requireString(typeof data.playlistId === "number" ? String(data.playlistId) : data.playlistId, "playlistId", 1, 64)
|
|
if (data.guildId !== undefined && data.guildId !== null) {
|
|
const guildId = requireId(data.guildId, "guildId")
|
|
const playlist = guildPlaylists.getPlaylist(guildId, playlistId)
|
|
if (!playlist) throw new AdminError("NOT_FOUND", "Playlist introuvable")
|
|
if (!guildPlaylists.deletePlaylist(guildId, playlist.playlistId)) throw new AdminError("NOT_FOUND", "Playlist introuvable")
|
|
audit(me, "suppression de la playlist partagée", `"${playlist.title}" (${playlistId}) du serveur ${guildId}`)
|
|
process.emit("GUILDS_UPDATE")
|
|
process.emit("GUILD_PLAYLISTS_UPDATE", guildId)
|
|
return { deleted: true }
|
|
}
|
|
const userId = requireId(data.userId)
|
|
const list = dbData("Playlists", {})[userId]
|
|
const playlist = Array.isArray(list) ? list.find(p => p?.playlistId != null && String(p.playlistId) === playlistId) : null
|
|
if (!playlist) throw new AdminError("NOT_FOUND", "Playlist introuvable")
|
|
if (playlists.removePlaylist(userId, playlist.playlistId) === false) throw new AdminError("NOT_FOUND", "Playlist introuvable")
|
|
audit(me, "suppression de la playlist", `"${playlist.title}" (${playlistId}) de ${userId}`)
|
|
// Le propriétaire connecté recharge ses playlists
|
|
process.emit("PLAYLISTS_UPDATE", userId)
|
|
return { deleted: true }
|
|
})
|
|
|
|
// 9. YOUTUBE (cookies, yt-dlp, réglages, diagnostics)
|
|
// Jamais de contenu de cookie dans les réponses ni dans les logs : seulement des noms, des compteurs et des dates
|
|
|
|
AdminRequest("/ADMIN/YOUTUBE/STATUS", async () => {
|
|
await refreshYtdlpVersion()
|
|
return youtubeStatus()
|
|
})
|
|
|
|
AdminRequest("/ADMIN/YOUTUBE/COOKIES/IMPORT", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
if (typeof data.content !== "string" || !data.content.trim() || data.content.length > COOKIES_MAX_LENGTH) {
|
|
throw new AdminError("INVALID_PARAMS", `Contenu des cookies invalide (1 à ${COOKIES_MAX_LENGTH} caractères)`)
|
|
}
|
|
const source = data.source === undefined || data.source === null ? "paste" : data.source
|
|
if (!COOKIE_IMPORT_SOURCES.includes(source)) throw new AdminError("INVALID_PARAMS", "Paramètre \"source\" invalide (paste ou file)")
|
|
let result
|
|
try {
|
|
result = youtube.cookies.importCookies(data.content, { source, by: adminIdentity(me) })
|
|
} catch (e) {
|
|
throw youtubeError(e)
|
|
}
|
|
audit(me, "import des cookies YouTube", `(${source === "file" ? "fichier" : "collage"}, ${result.status.count} cookie(s), ${result.status.loggedIn ? "compte connecté" : "aucun compte connecté"})`)
|
|
// Vérification en direct en arrière-plan : le client reçoit le résultat par la poussée
|
|
const checking = startCookieCheck()
|
|
return { status: youtubeStatus(), warnings: result.warnings, checking }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/YOUTUBE/COOKIES/BROWSER", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
if (!YOUTUBE_BROWSERS.includes(data.browser)) throw new AdminError("INVALID_PARAMS", "Navigateur inconnu")
|
|
const profile = optionalString(data.profile, "profile", 200)
|
|
// Syntaxe de yt-dlp : NAVIGATEUR[:PROFIL][::CONTENEUR] (pas de retour à la ligne, pas de "::")
|
|
if (profile && (/[\r\n\0]/.test(profile) || profile.includes("::"))) throw new AdminError("INVALID_PARAMS", "Paramètre \"profile\" invalide")
|
|
let result
|
|
try {
|
|
result = await importFromBrowser(data.browser, profile, adminIdentity(me))
|
|
} catch (e) {
|
|
throw youtubeError(e)
|
|
}
|
|
audit(me, "import des cookies YouTube", `(navigateur ${data.browser}${profile ? ", profil personnalisé" : ""}, ${result.status.count} cookie(s), ${result.status.loggedIn ? "compte connecté" : "aucun compte connecté"})`)
|
|
const checking = startCookieCheck()
|
|
return { status: youtubeStatus(), warnings: result.warnings, checking }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/YOUTUBE/COOKIES/CHECK", async (payload, me) => {
|
|
if (!youtube.cookies.getStatus().present) throw new AdminError("UNAVAILABLE", "Aucun cookie YouTube enregistré")
|
|
let check
|
|
try {
|
|
check = await youtube.check.checkCookies()
|
|
} catch (e) {
|
|
throw youtubeError(e)
|
|
}
|
|
// L'état des cookies est déjà dans "status"
|
|
const { status: _cookieStatus, ...result } = check
|
|
audit(me, "vérification des cookies YouTube", `${result.ok ? "réussie" : "échouée"} : ${result.message}`)
|
|
return { result, status: youtubeStatus() }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/YOUTUBE/COOKIES/DELETE", async (payload, me) => {
|
|
const before = youtube.cookies.getStatus()
|
|
try {
|
|
youtube.cookies.deleteCookies()
|
|
} catch (e) {
|
|
throw new AdminError("INTERNAL", `Suppression des cookies impossible : ${e?.message || e}`)
|
|
}
|
|
audit(me, "suppression des cookies YouTube", before.present ? `(${before.count} cookie(s))` : "(aucun cookie enregistré)")
|
|
return youtubeStatus()
|
|
})
|
|
|
|
AdminRequest("/ADMIN/YOUTUBE/SETTINGS", async (payload, me) => {
|
|
const patch = asObject(payload).patch
|
|
if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new AdminError("INVALID_PARAMS", "Paramètre \"patch\" invalide (objet attendu)")
|
|
const keys = Object.keys(patch)
|
|
if (keys.length === 0) throw new AdminError("INVALID_PARAMS", "Aucun réglage à modifier")
|
|
if (keys.length > YOUTUBE_SETTINGS_MAX_KEYS) throw new AdminError("INVALID_PARAMS", "Trop de réglages dans la requête")
|
|
const before = youtube.settings.get()
|
|
try {
|
|
// Clés inconnues, valeurs invalides et clés verrouillées par l'environnement : INVALID_PARAMS
|
|
youtube.settings.update(patch, adminIdentity(me))
|
|
} catch (e) {
|
|
throw youtubeError(e)
|
|
}
|
|
const changes = describeSettingsChange(before, youtube.settings.get())
|
|
if (changes) audit(me, "réglages YouTube", changes)
|
|
return youtubeStatus()
|
|
})
|
|
|
|
AdminRequest("/ADMIN/YOUTUBE/TEST", async (payload) => {
|
|
const data = asObject(payload)
|
|
const url = optionalString(data.url, "url", 500)
|
|
if (url) {
|
|
let parsed = null
|
|
try {
|
|
parsed = new URL(url)
|
|
} catch (e) {
|
|
parsed = null
|
|
}
|
|
if (!parsed || !/^https?:$/.test(parsed.protocol) || /\s/.test(url)) throw new AdminError("INVALID_PARAMS", "Paramètre \"url\" invalide (lien http(s) attendu)")
|
|
}
|
|
const route = data.route === undefined || data.route === null ? "auto" : data.route
|
|
if (!YOUTUBE_TEST_ROUTES.includes(route)) throw new AdminError("INVALID_PARAMS", "Paramètre \"route\" invalide (auto, warp ou direct)")
|
|
const identity = data.identity === undefined || data.identity === null ? "auto" : data.identity
|
|
if (!YOUTUBE_TEST_IDENTITIES.includes(identity)) throw new AdminError("INVALID_PARAMS", "Paramètre \"identity\" invalide (auto, anon ou cookies)")
|
|
// Vide ou absent : clients des réglages
|
|
const clients = optionalString(data.clients, "clients", 200)
|
|
try {
|
|
return await youtube.check.testPlayback({ url: url || undefined, route, identity, clients: clients ?? undefined })
|
|
} catch (e) {
|
|
throw youtubeError(e)
|
|
}
|
|
})
|
|
|
|
AdminRequest("/ADMIN/YOUTUBE/UPDATE", async (payload, me) => {
|
|
let result
|
|
try {
|
|
// Une mise à jour déjà en cours (automatique) est simplement attendue
|
|
result = await youtube.ytdlp.update({ reason: `manuelle, par ${me.identity.username}` })
|
|
} catch (e) {
|
|
throw youtubeError(e)
|
|
}
|
|
audit(me, "mise à jour de yt-dlp", result?.message || "")
|
|
return { result, status: youtubeStatus() }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/YOUTUBE/DIAGNOSTICS/CLEAR", async (payload, me) => {
|
|
youtube.diagnostics.clear()
|
|
audit(me, "effacement des diagnostics YouTube")
|
|
return youtubeStatus()
|
|
})
|
|
|
|
// 10. CONFIGURATION (config.json) ET INTÉGRATIONS
|
|
// Les secrets ne sont jamais renvoyés ni journalisés : seulement {set, length}
|
|
|
|
AdminRequest("/ADMIN/CONFIG/GET", async () => configPayload())
|
|
|
|
AdminRequest("/ADMIN/CONFIG/SET", async (payload, me) => {
|
|
const data = asObject(payload)
|
|
if (typeof data.key !== "string" || !configuration.getKeys().includes(data.key)) {
|
|
throw new AdminError("INVALID_PARAMS", "Clé de configuration inconnue")
|
|
}
|
|
if (data.value === undefined || data.value === null) throw new AdminError("INVALID_PARAMS", "Paramètre \"value\" manquant")
|
|
let result
|
|
try {
|
|
result = configuration.setValue(data.key, data.value)
|
|
} catch (e) {
|
|
if (e?.code === "INVALID_PARAMS") throw new AdminError("INVALID_PARAMS", e.message)
|
|
throw e
|
|
}
|
|
if (result.changed) {
|
|
if (data.key.startsWith("api.spotify.")) spotifyLastTest = null
|
|
// Clé seulement, jamais la valeur
|
|
audit(me, "modification de la configuration", data.key + (result.requiresRestart ? " (redémarrage requis)" : ""))
|
|
// Serveur de la base multimédia : protégé dans la liste des serveurs
|
|
if (data.key === "media.guildId") process.emit("GUILDS_UPDATE")
|
|
}
|
|
return { ...configPayload(), result }
|
|
})
|
|
|
|
AdminRequest("/ADMIN/CONFIG/SPOTIFY/TEST", async (payload, me) => {
|
|
const result = await testSpotify()
|
|
audit(me, "test des identifiants Spotify", result.ok ? "réussi" : "échoué")
|
|
return { result, integrations: integrationsStatus() }
|
|
})
|
|
}
|
|
|
|
// POUSSÉES GLOBALES
|
|
|
|
/**
|
|
* Initialisation unique : poussées périodiques et événementielles vers les rooms ADMIN / ADMIN_LOGS
|
|
* @param {import('socket.io').Server} ioInstance
|
|
* @param {Object} [deps] réservé (dépendances supplémentaires)
|
|
*/
|
|
function init(ioInstance, deps = {}) {
|
|
if (initialized) return
|
|
initialized = true
|
|
io = ioInstance
|
|
LogReader.init()
|
|
|
|
// Vue d'ensemble toutes les 10 s, seulement si un admin regarde
|
|
setInterval(async () => {
|
|
if (roomSize(ADMIN_ROOM) === 0) return
|
|
try {
|
|
io.to(ADMIN_ROOM).emit("/ADMIN/OVERVIEW/UPDATE", await buildOverview())
|
|
} catch (e) {
|
|
alog.error(`Vue d'ensemble admin impossible : ${e?.stack || e}`)
|
|
}
|
|
}, OVERVIEW_INTERVAL).unref()
|
|
|
|
// Players : chaque changement d'état (coalescé à 250 ms)
|
|
const pushPlayers = debounce(() => {
|
|
if (roomSize(ADMIN_ROOM) === 0) return
|
|
io.to(ADMIN_ROOM).emit("/ADMIN/PLAYERS/UPDATE", playersList())
|
|
}, 250)
|
|
process.on("PLAYER_STATE", pushPlayers)
|
|
process.on("PLAYERS_UPDATE", pushPlayers)
|
|
|
|
// Processus yt-dlp (PID / route affichés dans les players et la section WARP) : Youtube.js ne déclenche
|
|
// pas d'état player à leur démarrage ou à leur fin, on compare donc leur signature toutes les 2 s
|
|
let ytdlpSignature = ""
|
|
setInterval(() => {
|
|
if (roomSize(ADMIN_ROOM) === 0) return
|
|
const map = getYoutubeProcesses()
|
|
const signature = map ? [...map.entries()].map(([guildId, proc]) => `${guildId}:${proc?.pid}:${proc?.route}`).sort().join("|") : ""
|
|
if (signature === ytdlpSignature) return
|
|
ytdlpSignature = signature
|
|
pushPlayers()
|
|
}, YTDLP_PROCESS_POLL).unref()
|
|
|
|
// Utilisateurs connectés
|
|
const pushUsers = debounce(() => {
|
|
if (roomSize(ADMIN_ROOM) === 0) return
|
|
io.to(ADMIN_ROOM).emit("/ADMIN/USERS/UPDATE", onlineUsersPayload())
|
|
}, 300)
|
|
process.on("USERS_UPDATE", pushUsers)
|
|
|
|
// Serveurs Discord
|
|
const pushGuilds = debounce(() => {
|
|
if (roomSize(ADMIN_ROOM) === 0) return
|
|
io.to(ADMIN_ROOM).emit("/ADMIN/GUILDS/UPDATE", guildsList(null))
|
|
}, 500)
|
|
process.on("GUILDS_UPDATE", pushGuilds)
|
|
|
|
// Playlists modifiées (personnelles ou partagées) : la section Playlists ouverte se relit d'elle-même
|
|
const changedScopes = new Set()
|
|
const pushPlaylists = debounce(() => {
|
|
const scopes = [...changedScopes]
|
|
changedScopes.clear()
|
|
if (roomSize(ADMIN_ROOM) === 0) return
|
|
io.to(ADMIN_ROOM).emit("/ADMIN/PLAYLISTS/UPDATE", { scopes })
|
|
}, 800)
|
|
process.on("PLAYLISTS_UPDATE", () => { changedScopes.add("personal"); pushPlaylists() })
|
|
process.on("GUILD_PLAYLISTS_UPDATE", () => { changedScopes.add("guild"); pushPlaylists() })
|
|
|
|
// WARP : changement d'état du proxy
|
|
process.on("WARP_UPDATE", () => {
|
|
if (roomSize(ADMIN_ROOM) === 0) return
|
|
try {
|
|
io.to(ADMIN_ROOM).emit("/ADMIN/WARP/UPDATE", warp.getStatus())
|
|
} catch (e) {
|
|
alog.error(`Poussée WARP impossible : ${e?.message || e}`)
|
|
}
|
|
})
|
|
|
|
// YouTube : cookies, réglages, mises à jour de yt-dlp, tentatives de lecture (coalescé à 500 ms)
|
|
const pushYoutube = debounce(() => {
|
|
if (roomSize(ADMIN_ROOM) === 0) return
|
|
io.to(ADMIN_ROOM).emit("/ADMIN/YOUTUBE/UPDATE", youtubeStatus())
|
|
}, 500)
|
|
process.on("YOUTUBE_UPDATE", pushYoutube)
|
|
|
|
// Configuration modifiée (par un autre admin) : version masquée, jamais de secret
|
|
const pushConfig = debounce(() => {
|
|
if (roomSize(ADMIN_ROOM) === 0) return
|
|
io.to(ADMIN_ROOM).emit("/ADMIN/CONFIG/UPDATE", configPayload())
|
|
}, 300)
|
|
process.on("CONFIG_UPDATE", pushConfig)
|
|
|
|
// Maintenance (redémarrage depuis l'admin ou la commande /restart) : prévient tous les clients
|
|
process.on("MAINTENANCE", (payload) => {
|
|
try {
|
|
io.emit("/MAINTENANCE", {
|
|
type: payload?.type || "restart",
|
|
reason: payload?.reason ?? null,
|
|
at: payload?.at || new Date().toISOString()
|
|
})
|
|
} catch (e) {
|
|
alog.error(`Annonce de maintenance impossible : ${e?.message || e}`)
|
|
}
|
|
})
|
|
|
|
// Plus personne n'écoute les logs : on arrête la surveillance du fichier
|
|
io.of("/").adapter.on("delete-room", (room) => {
|
|
if (room === LOGS_ROOM) LogReader.stopTail()
|
|
})
|
|
|
|
alog.log("Module d'administration initialisé")
|
|
}
|
|
|
|
module.exports = {
|
|
init,
|
|
attach,
|
|
AdminError,
|
|
syncUserSockets,
|
|
disconnectUser,
|
|
ADMIN_ROOM,
|
|
LOGS_ROOM
|
|
}
|