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:
co-authored by
Claude Opus 5
parent
d9d0e9f6bf
commit
a9b7ccdd16
@@ -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("⚠️ Внутренняя ошибка, подробности в логах бота.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user