import { EventEmitter } from "node:events"; import { config } from "../config.js"; import { logger } from "../logger.js"; import { NOTHING_FOUND, resolveQuery, searchTracks, type SearchSource } from "../sources/index.js"; import { UserFacingError, type LoopMode, type PlayerSnapshot, type Requester, type SearchResult, type Track, } from "../types.js"; import { GuildPlayer, type Notice, type PositionUpdate } from "./player.js"; import { createRevoice, type RevoiceLike } from "./revoice.js"; const log = logger.child({ mod: "manager" }); export interface VoiceChannelRef { id: string; name: string; } export interface ServerRef { id: string; name: string; iconUrl: string | null; } /** * Everything the core needs to know about the chat side of Stoat. Implemented on * top of the bot's stoat.js client so the player itself stays testable. */ export interface StoatContext { findUserVoiceChannel(serverId: string, userId: string): VoiceChannelRef | null; hasVoicePresence(serverId: string): boolean; getVoiceChannel(channelId: string): VoiceChannelRef | null; listVoiceChannels(serverId: string): VoiceChannelRef[]; getServerName(serverId: string): string | null; listServersForUser(userId: string): Promise; isMember(serverId: string, userId: string): Promise; canControl(serverId: string, userId: string): Promise; sendMessage(channelId: string, content: string): Promise; } export type PlayMode = "append" | "next" | "now"; export interface ManagerEvents { update: [PlayerSnapshot]; position: [PositionUpdate]; } export interface PlayOutcome extends SearchResult { startedNow: boolean; queuePosition: number; } /** * Owns one GuildPlayer per server and exposes the high-level operations that * both the chat commands and the web panel call into. */ export class MusicManager extends EventEmitter { private readonly players = new Map(); private readonly revoice: RevoiceLike; private stoat: StoatContext | null = null; constructor() { super(); this.revoice = createRevoice(config.STOAT_BOT_TOKEN, config.STOAT_API_URL, config.VOICE_NODE); } attachStoat(context: StoatContext): void { this.stoat = context; } private get chat(): StoatContext { if (!this.stoat) throw new UserFacingError("Бот ещё не подключился к Stoat"); return this.stoat; } // ---------------------------------------------------------------- players --- get(serverId: string): GuildPlayer | undefined { return this.players.get(serverId); } list(): GuildPlayer[] { return [...this.players.values()]; } getOrCreate(serverId: string): GuildPlayer { const existing = this.players.get(serverId); if (existing) return existing; const player = new GuildPlayer({ serverId, serverName: this.stoat?.getServerName(serverId) ?? null, revoice: this.revoice, }); player.on("update", (snapshot) => this.emit("update", snapshot)); player.on("position", (position) => this.emit("position", position)); player.on("notice", (notice) => void this.deliverNotice(notice)); this.players.set(serverId, player); return player; } private async deliverNotice(notice: Notice): Promise { if (!notice.textChannelId || !this.stoat) return; try { await this.stoat.sendMessage(notice.textChannelId, notice.text); } catch (err) { log.warn({ err, channel: notice.textChannelId }, "failed to deliver notice"); } } async destroy(serverId: string): Promise { const player = this.players.get(serverId); if (!player) return; this.players.delete(serverId); await player.destroy(); } async destroyAll(): Promise { await Promise.allSettled([...this.players.keys()].map((id) => this.destroy(id))); } // ------------------------------------------------------------ permissions --- /** * Presence as seen through the bot's own LiveKit room. Stoat's gateway does * not tell a bot who is sitting in voice, so while the bot is connected this * is the only first-hand answer we have. */ listenerChannel(serverId: string, userId: string): VoiceChannelRef | null { const player = this.players.get(serverId); if (!player?.voiceChannelId || !player.hasParticipant(userId)) return null; return { id: player.voiceChannelId, name: player.voiceChannelName ?? "" }; } /** True when we have a first-hand view of who is in voice. */ knowsVoicePresence(serverId: string): boolean { return this.chat.hasVoicePresence(serverId) || Boolean(this.players.get(serverId)?.isConnected()); } async assertControl(serverId: string, userId: string): Promise { if (!(await this.chat.canControl(serverId, userId))) { throw new UserFacingError("Недостаточно прав для управления плеером"); } } // ---------------------------------------------------------------- actions --- /** Connects to the caller's voice channel (or an explicit one) and returns the player. */ async connect( serverId: string, userId: string, options: { voiceChannelId?: string | null; textChannelId?: string | null } = {}, ): Promise { const player = this.getOrCreate(serverId); if (options.textChannelId) player.textChannelId = options.textChannelId; const listening = this.chat.findUserVoiceChannel(serverId, userId) ?? this.listenerChannel(serverId, userId); if (config.REQUIRE_LISTENER && listening) { // Music follows the listener: you cannot push the bot into a channel you // are not sitting in. if (options.voiceChannelId && options.voiceChannelId !== listening.id) { throw new UserFacingError("Бота можно позвать только в тот канал, где вы находитесь"); } } else if (config.REQUIRE_LISTENER) { // Refuse only when we can actually see who is in voice. With no presence // data at all our view is stale rather than empty, and blocking would // strand everyone until the next restart. if (this.knowsVoicePresence(serverId)) { throw new UserFacingError("Сначала зайдите в голосовой канал"); } if (!options.voiceChannelId) { throw new UserFacingError( "Не вижу, кто в голосовых каналах — выберите канал явно (в панели он появится в списке)", ); } log.warn({ serverId, userId }, "voice presence unknown, trusting the requested channel"); } const target = config.REQUIRE_LISTENER && listening ? listening : (options.voiceChannelId ? this.chat.getVoiceChannel(options.voiceChannelId) : (listening ?? (player.voiceChannelId ? this.chat.getVoiceChannel(player.voiceChannelId) : null))); if (!target) { throw new UserFacingError("Зайдите в голосовой канал или укажите его явно"); } await player.connect(target.id, target.name); return player; } async play( serverId: string, requester: Requester, query: string, options: { mode?: PlayMode; voiceChannelId?: string | null; textChannelId?: string | null } = {}, ): Promise { await this.assertControl(serverId, requester.id); const player = await this.connect(serverId, requester.id, { voiceChannelId: options.voiceChannelId ?? null, textChannelId: options.textChannelId ?? null, }); const result = await resolveQuery(query, requester, config.MAX_QUEUE_SIZE - player.queue.length); if (result.tracks.length === 0) throw new UserFacingError(NOTHING_FOUND); const mode = options.mode ?? "append"; const wasIdle = !player.current; if (mode === "now") { await player.playNow(result.tracks); return { ...result, startedNow: true, queuePosition: 0 }; } player.enqueue(result.tracks, mode === "next" ? 0 : undefined); const queuePosition = mode === "next" ? 1 : player.queue.length - result.tracks.length + 1; await player.ensurePlaying(); return { ...result, startedNow: wasIdle, queuePosition }; } /** Queues already-resolved tracks (used by the panel's search results). */ async enqueueTracks( serverId: string, requester: Requester, tracks: Track[], options: { mode?: PlayMode; voiceChannelId?: string | null; textChannelId?: string | null } = {}, ): Promise { await this.assertControl(serverId, requester.id); const player = await this.connect(serverId, requester.id, { voiceChannelId: options.voiceChannelId ?? null, textChannelId: options.textChannelId ?? null, }); const owned = tracks.map((track) => ({ ...track, requestedBy: requester })); const wasIdle = !player.current; if (options.mode === "now") { await player.playNow(owned); return { tracks: owned, playlist: null, startedNow: true, queuePosition: 0 }; } player.enqueue(owned, options.mode === "next" ? 0 : undefined); await player.ensurePlaying(); return { tracks: owned, playlist: null, startedNow: wasIdle, queuePosition: options.mode === "next" ? 1 : player.queue.length - owned.length + 1, }; } search( query: string, requester: Requester, limit?: number, source: SearchSource = "all", ): Promise { return searchTracks(query, requester, limit, source); } private async require(serverId: string, userId: string): Promise { await this.assertControl(serverId, userId); const player = this.players.get(serverId); if (!player) throw new UserFacingError("Плеер не запущен на этом сервере"); return player; } async pause(serverId: string, userId: string): Promise { (await this.require(serverId, userId)).pause(); } async resume(serverId: string, userId: string): Promise { (await this.require(serverId, userId)).resume(); } async togglePause(serverId: string, userId: string): Promise<"paused" | "playing"> { const player = await this.require(serverId, userId); if (player.snapshot().status === "paused") { player.resume(); return "playing"; } player.pause(); return "paused"; } async skip(serverId: string, userId: string, count = 1): Promise { return (await this.require(serverId, userId)).skip(count); } async stop(serverId: string, userId: string): Promise { await (await this.require(serverId, userId)).stop(); } async setVolume(serverId: string, userId: string, volume: number): Promise { (await this.require(serverId, userId)).setVolume(volume); } async setVideo(serverId: string, userId: string, enabled: boolean): Promise { (await this.require(serverId, userId)).setVideo(enabled); } async setLoop(serverId: string, userId: string, mode: LoopMode): Promise { (await this.require(serverId, userId)).setLoop(mode); } async shuffle(serverId: string, userId: string): Promise { (await this.require(serverId, userId)).shuffle(); } async seek(serverId: string, userId: string, seconds: number): Promise { await (await this.require(serverId, userId)).seek(seconds); } async remove(serverId: string, userId: string, trackId: string): Promise { return (await this.require(serverId, userId)).remove(trackId); } async move(serverId: string, userId: string, trackId: string, toIndex: number): Promise { (await this.require(serverId, userId)).move(trackId, toIndex); } async clearQueue(serverId: string, userId: string): Promise { (await this.require(serverId, userId)).clearQueue(); } async leave(serverId: string, userId: string): Promise { await (await this.require(serverId, userId)).leaveVoice(); } snapshot(serverId: string): PlayerSnapshot { const player = this.players.get(serverId); if (player) return player.snapshot(); return { serverId, serverName: this.stoat?.getServerName(serverId) ?? null, voiceChannelId: null, voiceChannelName: null, textChannelId: null, status: "idle", current: null, position: 0, queue: [], history: [], volume: config.DEFAULT_VOLUME, loop: "off", shuffleUsed: false, videoEnabled: false, updatedAt: Date.now(), }; } }