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:
co-authored by
Claude Opus 5
parent
2b999bf2a0
commit
d4880e757e
@@ -10,6 +10,9 @@ STOAT_BOT_TOKEN=
|
||||
# Префикс команд в чате.
|
||||
COMMAND_PREFIX=!
|
||||
|
||||
# Удалять сообщение с командой после её распознавания (нужно право ManageMessages).
|
||||
DELETE_COMMAND_MESSAGES=true
|
||||
|
||||
# ------------------------------------------------------------- Веб-панель ---
|
||||
PORT=3005
|
||||
HOST=0.0.0.0
|
||||
|
||||
@@ -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` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user