Fix voice join against LiveKit 0.13+ and tidy up command messages

revoice.js reads `room.isConnected()`, but @livekit/rtc-node turned that
into a getter, so every join threw "this.room.isConnected is not a
function" before the bot ever reached the channel. The player no longer
touches that getter: readiness comes from the connection's own join /
roomfetched events and is cleared when it reports the offline state.

Also delete the invoking chat message once a command is recognised
(DELETE_COMMAND_MESSAGES, on by default) so channels stay readable;
failures are non-fatal since it needs ManageMessages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-08 23:34:39 +03:00
co-authored by Claude Opus 5
parent 2b999bf2a0
commit d4880e757e
6 changed files with 573 additions and 537 deletions
+3
View File
@@ -10,6 +10,9 @@ STOAT_BOT_TOKEN=
# Префикс команд в чате.
COMMAND_PREFIX=!
# Удалять сообщение с командой после её распознавания (нужно право ManageMessages).
DELETE_COMMAND_MESSAGES=true
# ------------------------------------------------------------- Веб-панель ---
PORT=3005
HOST=0.0.0.0
+3
View File
@@ -159,6 +159,9 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
Все параметры — в [.env.example](.env.example). Что стоит знать:
- `DELETE_COMMAND_MESSAGES=true` (по умолчанию) — бот удаляет сообщение с командой, чтобы не
засорять канал. Нужно право `ManageMessages`; без него команда всё равно отработает,
а неудачное удаление уйдёт в лог на уровне `debug`.
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
+7
View File
@@ -66,6 +66,13 @@ async function handleMessage(manager: MusicManager, message: Message): Promise<v
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,
+5
View File
@@ -27,6 +27,11 @@ const schema = z.object({
STOAT_API_URL: z.string().url(),
STOAT_BOT_TOKEN: z.string().min(1),
COMMAND_PREFIX: z.string().min(1).default("!"),
/** Remove the invoking message after a command is recognised. Needs ManageMessages. */
DELETE_COMMAND_MESSAGES: z
.enum(["true", "false"])
.default("true")
.transform((value) => value === "true"),
PORT: z.coerce.number().int().positive().default(3005),
HOST: z.string().default("0.0.0.0"),
+19 -7
View File
@@ -12,6 +12,7 @@ import {
import {
MediaPlayer,
parseFfmpegDuration,
VOICE_STATE_OFFLINE,
type MediaPlayerLike,
type RevoiceLike,
type VoiceConnectionLike,
@@ -69,6 +70,8 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
private status: PlayerStatus = "idle";
private readonly revoice: RevoiceLike;
private connection: VoiceConnectionLike | null = null;
/** Tracked from connection events: revoice's own `connected` getter is broken. */
private voiceReady = false;
private media: MediaPlayerLike | null = null;
private currentInput: PlaybackInput | null = null;
private seekOffset = 0;
@@ -129,11 +132,11 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
// ------------------------------------------------------------ connection ---
isConnected(): boolean {
return Boolean(this.connection?.connected);
return this.voiceReady;
}
async connect(channelId: string, channelName: string | null): Promise<void> {
if (this.connection?.connected && this.voiceChannelId === channelId) {
if (this.voiceReady && this.voiceChannelId === channelId) {
this.voiceChannelName = channelName ?? this.voiceChannelName;
return;
}
@@ -143,23 +146,31 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
this.log.info({ channelId }, "joining voice channel");
const connection = await this.revoice.join(channelId);
if (!connection.connected) {
// The room connects asynchronously inside revoice's constructor, so we wait
// for it to report readiness rather than polling a state getter.
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new UserFacingError("Не удалось подключиться к голосовому каналу")),
JOIN_TIMEOUT_MS,
);
connection.on("join", () => {
const done = () => {
clearTimeout(timer);
resolve();
};
connection.once("join", done);
connection.once("roomfetched", done);
});
});
}
this.connection = connection;
this.voiceReady = true;
this.voiceChannelId = channelId;
this.voiceChannelName = channelName;
connection.on("state", (state) => {
if (state !== VOICE_STATE_OFFLINE) return;
this.voiceReady = false;
this.log.warn({ channelId }, "voice connection went offline");
});
connection.on("userleave", () => this.checkEmptyChannel());
connection.on("userLeave", () => this.checkEmptyChannel());
connection.on("userJoin", () => this.clearIdleTimer());
@@ -190,6 +201,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
const connection = this.connection;
this.connection = null;
this.voiceReady = false;
this.media?.removeAllListeners();
this.media = null;
this.voiceChannelId = null;
@@ -216,7 +228,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
// -------------------------------------------------------------- playback ---
private assertReady(): MediaPlayerLike {
if (!this.media || !this.connection?.connected) {
if (!this.media || !this.voiceReady) {
throw new UserFacingError("Бот не подключён к голосовому каналу");
}
return this.media;
+7 -1
View File
@@ -23,8 +23,10 @@ export interface MediaPlayerLike {
removeAllListeners(event?: string): this;
}
/** Voice connection state strings emitted by revoice.js (`Revoice.State`). */
export const VOICE_STATE_OFFLINE = "off";
export interface VoiceConnectionLike {
readonly connected: boolean;
channelId: string;
play(media: MediaPlayerLike): Promise<void>;
leave(): Promise<void>;
@@ -33,8 +35,12 @@ export interface VoiceConnectionLike {
on(event: "join" | "leave" | "roomfetched" | "autoleave", listener: () => void): this;
on(event: "state", listener: (state: string) => void): this;
on(event: "userJoin" | "userleave" | "userLeave", listener: (user: { id: string }) => void): this;
once(event: "join" | "leave" | "roomfetched", listener: () => void): this;
removeAllListeners(event?: string): this;
}
// NB: revoice.js also exposes `connection.connected` / `isConnected()`, but both
// call `room.isConnected()` — a getter, not a method, in @livekit/rtc-node 0.13+,
// so touching them throws a TypeError. GuildPlayer tracks readiness from events.
export interface RevoiceLike {
join(channelId: string, leaveIfEmpty?: boolean | number): Promise<VoiceConnectionLike>;