Files
stoat-mbot/src/bot/index.ts
T
Leonid PershinandClaude Opus 5 e7e80e704b Tidy chat output and pick search results by reaction
A search reply carried ten markdown links, and Stoat expands every link
into a full-size player — the result was a wall of embeds burying the
list. Stoat's embed generator skips links inside code spans or angle
brackets, so links now go out quietly, and list lines carry no links at
all: title, artist, length, source. Only `!nowplaying` keeps a preview,
where it was asked for.

Choosing a track no longer needs a second command: the results message
gets 1️⃣5️⃣ reactions and picking one queues the track, with `!pick`
still there when reactions fail or five results are not enough. Only the
person who searched can pick, so a list cannot be hijacked.

Also `!playvideo` to turn video on and queue a track in one go, and
setVideo no longer requires a running player — the switch is a setting,
and demanding a player made it fail on a server nothing had played on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 05:41:20 +03:00

136 lines
4.3 KiB
TypeScript

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<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");
});
});
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<void> {
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<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) => 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("⚠️ Внутренняя ошибка, подробности в логах бота.");
}
}