Add music bot for self-hosted Stoat with web control panel

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>
This commit is contained in:
Leonid Pershin
2026-09-08 23:12:43 +03:00
co-authored by Claude Opus 5
parent d9d0e9f6bf
commit a9b7ccdd16
43 changed files with 12436 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import { Client, type Message } from "stoat.js";
import { config } from "../config.js";
import { logger } from "../logger.js";
import type { MusicManager } from "../core/manager.js";
import { UserFacingError } from "../types.js";
import { findCommand, type CommandContext } from "./commands.js";
import { BotStoatContext } from "./context.js";
const log = logger.child({ mod: "bot" });
export interface Bot {
client: Client;
context: BotStoatContext;
stop(): Promise<void>;
}
export async function startBot(manager: MusicManager): Promise<Bot> {
const client = new Client({ baseURL: config.STOAT_API_URL });
const context = new BotStoatContext(client);
manager.attachStoat(context);
client.on("ready", () => {
log.info({ user: client.user?.username }, "bot is ready");
});
client.on("error", (error) => {
log.error({ err: error }, "client error");
});
client.on("disconnected", () => log.warn("gateway disconnected"));
client.on("messageCreate", (message) => {
void handleMessage(manager, message).catch((err) => {
log.error({ err }, "unhandled command failure");
});
});
await client.loginBot(config.STOAT_BOT_TOKEN);
return {
client,
context,
async stop() {
await manager.destroyAll();
},
};
}
async function handleMessage(manager: MusicManager, message: Message): Promise<void> {
const content = message.content?.trim();
if (!content || !content.startsWith(config.COMMAND_PREFIX)) return;
if (!message.authorId || message.author?.bot) return;
const serverId = message.server?.id;
const reply = async (text: string) => {
await message.channel?.sendMessage(text);
};
if (!serverId) {
await reply("Команды работают только внутри сервера.");
return;
}
const withoutPrefix = content.slice(config.COMMAND_PREFIX.length).trim();
const [rawName, ...args] = withoutPrefix.split(/\s+/);
if (!rawName) return;
const command = findCommand(rawName);
if (!command) return;
const ctx: CommandContext = {
manager,
serverId,
channelId: message.channelId,
actor: {
id: message.authorId,
username: message.member?.nickname || message.author?.username || "user",
},
args,
rest: withoutPrefix.slice(rawName.length).trim(),
reply,
};
log.debug({ command: command.name, user: ctx.actor.id, server: serverId }, "command");
try {
await command.run(ctx);
} catch (err) {
if (err instanceof UserFacingError) {
await reply(`⚠️ ${err.message}`);
return;
}
log.error({ err, command: command.name }, "command failed");
await reply("⚠️ Внутренняя ошибка, подробности в логах бота.");
}
}