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:
co-authored by
Claude Opus 5
parent
315760e076
commit
f22b08b350
+105
-101
@@ -1,101 +1,105 @@
|
||||
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;
|
||||
|
||||
if (config.DELETE_COMMAND_MESSAGES) {
|
||||
// Fire-and-forget: a missing ManageMessages permission must not block playback.
|
||||
void message.delete().catch((err) => {
|
||||
log.debug({ err, command: command.name }, "could not delete command message");
|
||||
});
|
||||
}
|
||||
|
||||
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("⚠️ Внутренняя ошибка, подробности в логах бота.");
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
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("⚠️ Внутренняя ошибка, подробности в логах бота.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user