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=!
|
COMMAND_PREFIX=!
|
||||||
|
|
||||||
|
# Удалять сообщение с командой после её распознавания (нужно право ManageMessages).
|
||||||
|
DELETE_COMMAND_MESSAGES=true
|
||||||
|
|
||||||
# ------------------------------------------------------------- Веб-панель ---
|
# ------------------------------------------------------------- Веб-панель ---
|
||||||
PORT=3005
|
PORT=3005
|
||||||
HOST=0.0.0.0
|
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). Что стоит знать:
|
Все параметры — в [.env.example](.env.example). Что стоит знать:
|
||||||
|
|
||||||
|
- `DELETE_COMMAND_MESSAGES=true` (по умолчанию) — бот удаляет сообщение с командой, чтобы не
|
||||||
|
засорять канал. Нужно право `ManageMessages`; без него команда всё равно отработает,
|
||||||
|
а неудачное удаление уйдёт в лог на уровне `debug`.
|
||||||
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
|
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
|
||||||
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
|
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
|
||||||
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
|
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ async function handleMessage(manager: MusicManager, message: Message): Promise<v
|
|||||||
const command = findCommand(rawName);
|
const command = findCommand(rawName);
|
||||||
if (!command) return;
|
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 = {
|
const ctx: CommandContext = {
|
||||||
manager,
|
manager,
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const schema = z.object({
|
|||||||
STOAT_API_URL: z.string().url(),
|
STOAT_API_URL: z.string().url(),
|
||||||
STOAT_BOT_TOKEN: z.string().min(1),
|
STOAT_BOT_TOKEN: z.string().min(1),
|
||||||
COMMAND_PREFIX: z.string().min(1).default("!"),
|
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),
|
PORT: z.coerce.number().int().positive().default(3005),
|
||||||
HOST: z.string().default("0.0.0.0"),
|
HOST: z.string().default("0.0.0.0"),
|
||||||
|
|||||||
+19
-7
@@ -12,6 +12,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
MediaPlayer,
|
MediaPlayer,
|
||||||
parseFfmpegDuration,
|
parseFfmpegDuration,
|
||||||
|
VOICE_STATE_OFFLINE,
|
||||||
type MediaPlayerLike,
|
type MediaPlayerLike,
|
||||||
type RevoiceLike,
|
type RevoiceLike,
|
||||||
type VoiceConnectionLike,
|
type VoiceConnectionLike,
|
||||||
@@ -69,6 +70,8 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
private status: PlayerStatus = "idle";
|
private status: PlayerStatus = "idle";
|
||||||
private readonly revoice: RevoiceLike;
|
private readonly revoice: RevoiceLike;
|
||||||
private connection: VoiceConnectionLike | null = null;
|
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 media: MediaPlayerLike | null = null;
|
||||||
private currentInput: PlaybackInput | null = null;
|
private currentInput: PlaybackInput | null = null;
|
||||||
private seekOffset = 0;
|
private seekOffset = 0;
|
||||||
@@ -129,11 +132,11 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
// ------------------------------------------------------------ connection ---
|
// ------------------------------------------------------------ connection ---
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return Boolean(this.connection?.connected);
|
return this.voiceReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(channelId: string, channelName: string | null): Promise<void> {
|
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;
|
this.voiceChannelName = channelName ?? this.voiceChannelName;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,23 +146,31 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
this.log.info({ channelId }, "joining voice channel");
|
this.log.info({ channelId }, "joining voice channel");
|
||||||
|
|
||||||
const connection = await this.revoice.join(channelId);
|
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) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const timer = setTimeout(
|
const timer = setTimeout(
|
||||||
() => reject(new UserFacingError("Не удалось подключиться к голосовому каналу")),
|
() => reject(new UserFacingError("Не удалось подключиться к голосовому каналу")),
|
||||||
JOIN_TIMEOUT_MS,
|
JOIN_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
connection.on("join", () => {
|
const done = () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve();
|
resolve();
|
||||||
|
};
|
||||||
|
connection.once("join", done);
|
||||||
|
connection.once("roomfetched", done);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
|
this.voiceReady = true;
|
||||||
this.voiceChannelId = channelId;
|
this.voiceChannelId = channelId;
|
||||||
this.voiceChannelName = channelName;
|
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("userLeave", () => this.checkEmptyChannel());
|
connection.on("userLeave", () => this.checkEmptyChannel());
|
||||||
connection.on("userJoin", () => this.clearIdleTimer());
|
connection.on("userJoin", () => this.clearIdleTimer());
|
||||||
@@ -190,6 +201,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
|
|
||||||
const connection = this.connection;
|
const connection = this.connection;
|
||||||
this.connection = null;
|
this.connection = null;
|
||||||
|
this.voiceReady = false;
|
||||||
this.media?.removeAllListeners();
|
this.media?.removeAllListeners();
|
||||||
this.media = null;
|
this.media = null;
|
||||||
this.voiceChannelId = null;
|
this.voiceChannelId = null;
|
||||||
@@ -216,7 +228,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
// -------------------------------------------------------------- playback ---
|
// -------------------------------------------------------------- playback ---
|
||||||
|
|
||||||
private assertReady(): MediaPlayerLike {
|
private assertReady(): MediaPlayerLike {
|
||||||
if (!this.media || !this.connection?.connected) {
|
if (!this.media || !this.voiceReady) {
|
||||||
throw new UserFacingError("Бот не подключён к голосовому каналу");
|
throw new UserFacingError("Бот не подключён к голосовому каналу");
|
||||||
}
|
}
|
||||||
return this.media;
|
return this.media;
|
||||||
|
|||||||
+7
-1
@@ -23,8 +23,10 @@ export interface MediaPlayerLike {
|
|||||||
removeAllListeners(event?: string): this;
|
removeAllListeners(event?: string): this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Voice connection state strings emitted by revoice.js (`Revoice.State`). */
|
||||||
|
export const VOICE_STATE_OFFLINE = "off";
|
||||||
|
|
||||||
export interface VoiceConnectionLike {
|
export interface VoiceConnectionLike {
|
||||||
readonly connected: boolean;
|
|
||||||
channelId: string;
|
channelId: string;
|
||||||
play(media: MediaPlayerLike): Promise<void>;
|
play(media: MediaPlayerLike): Promise<void>;
|
||||||
leave(): Promise<void>;
|
leave(): Promise<void>;
|
||||||
@@ -33,8 +35,12 @@ export interface VoiceConnectionLike {
|
|||||||
on(event: "join" | "leave" | "roomfetched" | "autoleave", listener: () => void): this;
|
on(event: "join" | "leave" | "roomfetched" | "autoleave", listener: () => void): this;
|
||||||
on(event: "state", listener: (state: string) => void): this;
|
on(event: "state", listener: (state: string) => void): this;
|
||||||
on(event: "userJoin" | "userleave" | "userLeave", listener: (user: { id: 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;
|
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 {
|
export interface RevoiceLike {
|
||||||
join(channelId: string, leaveIfEmpty?: boolean | number): Promise<VoiceConnectionLike>;
|
join(channelId: string, leaveIfEmpty?: boolean | number): Promise<VoiceConnectionLike>;
|
||||||
|
|||||||
Reference in New Issue
Block a user