Plays audio into Stoat voice channels over LiveKit and exposes the same player through both chat commands and a browser panel, so the two never drift apart: everything routes through a single MusicManager. - core: per-server GuildPlayer (queue, loop, shuffle, seek, volume, idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg - sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet radio, optional local library with path-traversal guards - bot: 18 chat commands with aliases, plus !panel one-time login links - api: Fastify REST + WebSocket, sessions authenticated against the instance's own /auth/session/login (TOTP supported), permissions re-checked against Stoat membership and roles on every request - web: React panel with search, queue editing, seek and volume - deploy: Dockerfile, compose.override.yml and Caddyfile snippets for dropping the service into an existing /opt/stoat stack Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs (9 API checks). Voice playback itself needs a live instance to test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { config } from "../config.js";
|
|
import { logger } from "../logger.js";
|
|
import { resolveQuery, searchTracks } 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 { Revoice, 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;
|
|
getVoiceChannel(channelId: string): VoiceChannelRef | null;
|
|
listVoiceChannels(serverId: string): VoiceChannelRef[];
|
|
getServerName(serverId: string): string | null;
|
|
listServersForUser(userId: string): Promise<ServerRef[]>;
|
|
isMember(serverId: string, userId: string): Promise<boolean>;
|
|
canControl(serverId: string, userId: string): Promise<boolean>;
|
|
sendMessage(channelId: string, content: string): Promise<void>;
|
|
}
|
|
|
|
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<ManagerEvents> {
|
|
private readonly players = new Map<string, GuildPlayer>();
|
|
private readonly revoice: RevoiceLike;
|
|
private stoat: StoatContext | null = null;
|
|
|
|
constructor() {
|
|
super();
|
|
this.revoice = new Revoice(config.STOAT_BOT_TOKEN, { baseURL: config.STOAT_API_URL });
|
|
}
|
|
|
|
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<void> {
|
|
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<void> {
|
|
const player = this.players.get(serverId);
|
|
if (!player) return;
|
|
this.players.delete(serverId);
|
|
await player.destroy();
|
|
}
|
|
|
|
async destroyAll(): Promise<void> {
|
|
await Promise.allSettled([...this.players.keys()].map((id) => this.destroy(id)));
|
|
}
|
|
|
|
// ------------------------------------------------------------ permissions ---
|
|
|
|
async assertControl(serverId: string, userId: string): Promise<void> {
|
|
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<GuildPlayer> {
|
|
const player = this.getOrCreate(serverId);
|
|
if (options.textChannelId) player.textChannelId = options.textChannelId;
|
|
|
|
const target = options.voiceChannelId
|
|
? this.chat.getVoiceChannel(options.voiceChannelId)
|
|
: (this.chat.findUserVoiceChannel(serverId, userId) ??
|
|
(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<PlayOutcome> {
|
|
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("Ничего не найдено");
|
|
|
|
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<PlayOutcome> {
|
|
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): Promise<Track[]> {
|
|
return searchTracks(query, requester, limit);
|
|
}
|
|
|
|
private async require(serverId: string, userId: string): Promise<GuildPlayer> {
|
|
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<void> {
|
|
(await this.require(serverId, userId)).pause();
|
|
}
|
|
|
|
async resume(serverId: string, userId: string): Promise<void> {
|
|
(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<Track | null> {
|
|
return (await this.require(serverId, userId)).skip(count);
|
|
}
|
|
|
|
async stop(serverId: string, userId: string): Promise<void> {
|
|
await (await this.require(serverId, userId)).stop();
|
|
}
|
|
|
|
async setVolume(serverId: string, userId: string, volume: number): Promise<void> {
|
|
(await this.require(serverId, userId)).setVolume(volume);
|
|
}
|
|
|
|
async setLoop(serverId: string, userId: string, mode: LoopMode): Promise<void> {
|
|
(await this.require(serverId, userId)).setLoop(mode);
|
|
}
|
|
|
|
async shuffle(serverId: string, userId: string): Promise<void> {
|
|
(await this.require(serverId, userId)).shuffle();
|
|
}
|
|
|
|
async seek(serverId: string, userId: string, seconds: number): Promise<void> {
|
|
await (await this.require(serverId, userId)).seek(seconds);
|
|
}
|
|
|
|
async remove(serverId: string, userId: string, trackId: string): Promise<Track> {
|
|
return (await this.require(serverId, userId)).remove(trackId);
|
|
}
|
|
|
|
async move(serverId: string, userId: string, trackId: string, toIndex: number): Promise<void> {
|
|
(await this.require(serverId, userId)).move(trackId, toIndex);
|
|
}
|
|
|
|
async clearQueue(serverId: string, userId: string): Promise<void> {
|
|
(await this.require(serverId, userId)).clearQueue();
|
|
}
|
|
|
|
async leave(serverId: string, userId: string): Promise<void> {
|
|
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,
|
|
updatedAt: Date.now(),
|
|
};
|
|
}
|
|
}
|