Survive revoice's ffmpeg teardown and explain failed deletions

Switching tracks killed the process: revoice's #cleanUp() dereferences
this.fProc unconditionally, and its own ffmpeg error handler calls stop()
a second time after stop() has already nulled that field. Killing ffmpeg
is what triggers that error, so its handlers are detached first, the
instance's stop() is wrapped defensively (revoice calls it internally),
and an uncaughtException handler keeps the bot in the channel if the
dependency throws from another async callback.

Failed command-message deletions were logged at debug, i.e. invisible in
the default configuration; they now warn with Stoat's error type, and the
README says which permission the bot's role needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-08 23:50:31 +03:00
co-authored by Claude Opus 5
parent 315760e076
commit f22b08b350
5 changed files with 188 additions and 160 deletions
+3 -2
View File
@@ -178,8 +178,9 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
Все параметры — в [.env.example](.env.example). Что стоит знать: Все параметры — в [.env.example](.env.example). Что стоит знать:
- `DELETE_COMMAND_MESSAGES=true` (по умолчанию) — бот удаляет сообщение с командой, чтобы не - `DELETE_COMMAND_MESSAGES=true` (по умолчанию) — бот удаляет сообщение с командой, чтобы не
засорять канал. Нужно право `ManageMessages`; без него команда всё равно отработает, засорять канал. Для этого роли бота нужно право **Manage Messages** в настройках сервера
а неудачное удаление уйдёт в лог на уровне `debug`. (Settings → Roles → роль бота) или в правах самого канала. Без него команда всё равно
отработает, а в логе будет `could not delete command message` с причиной отказа.
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer` - `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера. и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса. - `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
+105 -101
View File
@@ -1,101 +1,105 @@
import { Client, type Message } from "stoat.js"; import { Client, type Message } from "stoat.js";
import { config } from "../config.js"; import { config } from "../config.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import type { MusicManager } from "../core/manager.js"; import type { MusicManager } from "../core/manager.js";
import { UserFacingError } from "../types.js"; import { UserFacingError } from "../types.js";
import { findCommand, type CommandContext } from "./commands.js"; import { findCommand, type CommandContext } from "./commands.js";
import { BotStoatContext } from "./context.js"; import { BotStoatContext } from "./context.js";
const log = logger.child({ mod: "bot" }); const log = logger.child({ mod: "bot" });
export interface Bot { export interface Bot {
client: Client; client: Client;
context: BotStoatContext; context: BotStoatContext;
stop(): Promise<void>; stop(): Promise<void>;
} }
export async function startBot(manager: MusicManager): Promise<Bot> { export async function startBot(manager: MusicManager): Promise<Bot> {
const client = new Client({ baseURL: config.STOAT_API_URL }); const client = new Client({ baseURL: config.STOAT_API_URL });
const context = new BotStoatContext(client); const context = new BotStoatContext(client);
manager.attachStoat(context); manager.attachStoat(context);
client.on("ready", () => { client.on("ready", () => {
log.info({ user: client.user?.username }, "bot is ready"); log.info({ user: client.user?.username }, "bot is ready");
}); });
client.on("error", (error) => { client.on("error", (error) => {
log.error({ err: error }, "client error"); log.error({ err: error }, "client error");
}); });
client.on("disconnected", () => log.warn("gateway disconnected")); client.on("disconnected", () => log.warn("gateway disconnected"));
client.on("messageCreate", (message) => { client.on("messageCreate", (message) => {
void handleMessage(manager, message).catch((err) => { void handleMessage(manager, message).catch((err) => {
log.error({ err }, "unhandled command failure"); log.error({ err }, "unhandled command failure");
}); });
}); });
await client.loginBot(config.STOAT_BOT_TOKEN); await client.loginBot(config.STOAT_BOT_TOKEN);
return { return {
client, client,
context, context,
async stop() { async stop() {
await manager.destroyAll(); await manager.destroyAll();
}, },
}; };
} }
async function handleMessage(manager: MusicManager, message: Message): Promise<void> { async function handleMessage(manager: MusicManager, message: Message): Promise<void> {
const content = message.content?.trim(); const content = message.content?.trim();
if (!content || !content.startsWith(config.COMMAND_PREFIX)) return; if (!content || !content.startsWith(config.COMMAND_PREFIX)) return;
if (!message.authorId || message.author?.bot) return; if (!message.authorId || message.author?.bot) return;
const serverId = message.server?.id; const serverId = message.server?.id;
const reply = async (text: string) => { const reply = async (text: string) => {
await message.channel?.sendMessage(text); await message.channel?.sendMessage(text);
}; };
if (!serverId) { if (!serverId) {
await reply("Команды работают только внутри сервера."); await reply("Команды работают только внутри сервера.");
return; return;
} }
const withoutPrefix = content.slice(config.COMMAND_PREFIX.length).trim(); const withoutPrefix = content.slice(config.COMMAND_PREFIX.length).trim();
const [rawName, ...args] = withoutPrefix.split(/\s+/); const [rawName, ...args] = withoutPrefix.split(/\s+/);
if (!rawName) return; if (!rawName) return;
const command = findCommand(rawName); const command = findCommand(rawName);
if (!command) return; if (!command) return;
if (config.DELETE_COMMAND_MESSAGES) { if (config.DELETE_COMMAND_MESSAGES) {
// Fire-and-forget: a missing ManageMessages permission must not block playback. // Fire-and-forget: a missing ManageMessages permission must not block playback.
void message.delete().catch((err) => { void message.delete().catch((err: unknown) => {
log.debug({ err, command: command.name }, "could not delete command message"); 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: { const ctx: CommandContext = {
id: message.authorId, manager,
username: message.member?.nickname || message.author?.username || "user", serverId,
}, channelId: message.channelId,
args, actor: {
rest: withoutPrefix.slice(rawName.length).trim(), id: message.authorId,
reply, username: message.member?.nickname || message.author?.username || "user",
}; },
args,
log.debug({ command: command.name, user: ctx.actor.id, server: serverId }, "command"); rest: withoutPrefix.slice(rawName.length).trim(),
reply,
try { };
await command.run(ctx);
} catch (err) { log.debug({ command: command.name, user: ctx.actor.id, server: serverId }, "command");
if (err instanceof UserFacingError) {
await reply(`⚠️ ${err.message}`); try {
return; await command.run(ctx);
} } catch (err) {
log.error({ err, command: command.name }, "command failed"); if (err instanceof UserFacingError) {
await reply("⚠️ Внутренняя ошибка, подробности в логах бота."); await reply(`⚠️ ${err.message}`);
} return;
} }
log.error({ err, command: command.name }, "command failed");
await reply("⚠️ Внутренняя ошибка, подробности в логах бота.");
}
}
+20 -1
View File
@@ -185,6 +185,18 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
connection.on("userJoin", () => this.clearIdleTimer()); connection.on("userJoin", () => this.clearIdleTimer());
const media = new MediaPlayer(true); const media = new MediaPlayer(true);
// revoice's #cleanUp() dereferences this.fProc unconditionally, so a second
// stop() (which its own ffmpeg error handler triggers) throws and would take
// the whole process down. Everything it calls goes through this instance
// method, so guarding it here covers its internal paths too.
const stop = media.stop.bind(media);
media.stop = (init?: boolean) => {
try {
stop(init);
} catch (err) {
this.log.debug({ err }, "revoice cleanup threw, ignoring");
}
};
media.on("startplay", () => { media.on("startplay", () => {
this.setStatus(media.paused ? "paused" : "playing"); this.setStatus(media.paused ? "paused" : "playing");
this.startTicker(); this.startTicker();
@@ -298,7 +310,14 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
if (this.media) { if (this.media) {
this.expectingStop = true; this.expectingStop = true;
try { try {
this.media.fProc?.kill("SIGKILL"); // Detach revoice's own handlers first: killing ffmpeg makes it emit
// "error", and its handler would call stop() again on a half-reset player.
const proc = this.media.fProc;
if (proc) {
proc.removeAllListeners("error");
proc.removeAllListeners("end");
proc.kill("SIGKILL");
}
} catch { } catch {
// ffmpeg may already be gone. // ffmpeg may already be gone.
} }
+1 -1
View File
@@ -12,7 +12,7 @@ export interface MediaPlayerLike {
codecData?: { duration?: string } | null; codecData?: { duration?: string } | null;
paused: boolean; paused: boolean;
playing: boolean; playing: boolean;
fProc?: { kill(signal?: string): void } | null; fProc?: { kill(signal?: string): void; removeAllListeners(event?: string): void } | null;
originStream?: { destroy(): void } | null; originStream?: { destroy(): void } | null;
playStream(input: Readable | string, inputOptions?: string[]): Promise<void>; playStream(input: Readable | string, inputOptions?: string[]): Promise<void>;
pause(): void; pause(): void;
+59 -55
View File
@@ -1,55 +1,59 @@
import { startApiServer } from "./api/server.js"; import { startApiServer } from "./api/server.js";
import { startBot } from "./bot/index.js"; import { startBot } from "./bot/index.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { MusicManager } from "./core/manager.js"; import { MusicManager } from "./core/manager.js";
import { logger } from "./logger.js"; import { logger } from "./logger.js";
import { checkCookies, checkYtDlp } from "./sources/index.js"; import { checkCookies, checkYtDlp } from "./sources/index.js";
async function main(): Promise<void> { async function main(): Promise<void> {
const ytdlpVersion = await checkYtDlp(); const ytdlpVersion = await checkYtDlp();
if (ytdlpVersion) { if (ytdlpVersion) {
logger.info({ version: ytdlpVersion }, "yt-dlp detected"); logger.info({ version: ytdlpVersion }, "yt-dlp detected");
} else { } else {
logger.warn( logger.warn(
{ path: config.YTDLP_PATH }, { path: config.YTDLP_PATH },
"yt-dlp not found — YouTube/SoundCloud playback will fail; only direct links and local files will work", "yt-dlp not found — YouTube/SoundCloud playback will fail; only direct links and local files will work",
); );
} }
const cookies = await checkCookies(); const cookies = await checkCookies();
if (cookies === "ok") { if (cookies === "ok") {
logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies"); logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies");
} else if (cookies === "read-only") { } else if (cookies === "read-only") {
logger.warn( logger.warn(
{ path: config.YTDLP_COOKIES }, { path: config.YTDLP_COOKIES },
"cookie file is not writable — yt-dlp cannot persist rotated cookies and the session will expire early", "cookie file is not writable — yt-dlp cannot persist rotated cookies and the session will expire early",
); );
} else if (cookies === "missing") { } else if (cookies === "missing") {
logger.warn({ path: config.YTDLP_COOKIES }, "cookie file from YTDLP_COOKIES does not exist"); logger.warn({ path: config.YTDLP_COOKIES }, "cookie file from YTDLP_COOKIES does not exist");
} }
const manager = new MusicManager(); const manager = new MusicManager();
const bot = await startBot(manager); const bot = await startBot(manager);
const app = await startApiServer({ manager, context: bot.context }); const app = await startApiServer({ manager, context: bot.context });
const shutdown = async (signal: string): Promise<void> => { const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, "shutting down"); logger.info({ signal }, "shutting down");
try { try {
await app.close(); await app.close();
await bot.stop(); await bot.stop();
} catch (err) { } catch (err) {
logger.error({ err }, "shutdown failed"); logger.error({ err }, "shutdown failed");
} finally { } finally {
process.exit(0); process.exit(0);
} }
}; };
process.on("SIGINT", () => void shutdown("SIGINT")); process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM")); process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("unhandledRejection", (err) => logger.error({ err }, "unhandled rejection")); process.on("unhandledRejection", (err) => logger.error({ err }, "unhandled rejection"));
} // revoice.js throws from async callbacks we cannot wrap (its ffmpeg teardown
// in particular). Dying there would drop the bot out of the voice channel
main().catch((err) => { // mid-track, so we log loudly and keep serving instead.
logger.fatal({ err }, "failed to start"); process.on("uncaughtException", (err) => logger.error({ err }, "uncaught exception, continuing"));
process.exit(1); }
});
main().catch((err) => {
logger.fatal({ err }, "failed to start");
process.exit(1);
});