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 { enqueueChoice, findCommand, trackForReaction, 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; } export async function startBot(manager: MusicManager): Promise { 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"); }); }); client.on("messageReactionAdd", (message, userId, emoji) => { if (userId === client.user?.id) return; void handleChoice(manager, message, userId, emoji).catch((err) => { log.error({ err }, "unhandled reaction failure"); }); }); await client.loginBot(config.STOAT_BOT_TOKEN); return { client, context, async stop() { await manager.destroyAll(); }, }; } /** Picking a search result by reacting to the bot's list of choices. */ async function handleChoice( manager: MusicManager, message: Message, userId: string, emoji: string, ): Promise { const serverId = message.server?.id; if (!serverId) return; const track = trackForReaction(message.id, userId, emoji); if (!track) return; const username = message.server?.getMember(userId)?.nickname ?? track.requestedBy.username; try { const text = await enqueueChoice(manager, serverId, message.channelId, { id: userId, username }, track); await message.channel?.sendMessage(text); } catch (err) { if (err instanceof UserFacingError) { await message.channel?.sendMessage(`⚠️ ${err.message}`); return; } throw err; } } async function handleMessage(manager: MusicManager, message: Message): Promise { 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) => 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; if (config.DELETE_COMMAND_MESSAGES) { // Fire-and-forget: a missing ManageMessages permission must not block playback. void message.delete().catch((err: unknown) => { const type = (err as { response?: { data?: { type?: string } } })?.response?.data?.type; log.warn( { type: type ?? String(err), command: command.name }, "could not delete command message (bot needs ManageMessages)", ); }); } 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("⚠️ Внутренняя ошибка, подробности в логах бота."); } }