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
+61
-58
@@ -1,58 +1,61 @@
|
|||||||
# ---------------------------------------------------------------- Stoat ----
|
# ---------------------------------------------------------------- Stoat ----
|
||||||
# Публичный адрес API вашего инстанса (тот же, что в клиенте).
|
# Публичный адрес API вашего инстанса (тот же, что в клиенте).
|
||||||
# Бот ходит по нему как обычный клиент; домен резолвится в хост через
|
# Бот ходит по нему как обычный клиент; домен резолвится в хост через
|
||||||
# extra_hosts в compose.yml, поэтому TLS валидный и NAT loopback не нужен.
|
# extra_hosts в compose.yml, поэтому TLS валидный и NAT loopback не нужен.
|
||||||
STOAT_API_URL=https://chat.example.com/api
|
STOAT_API_URL=https://chat.example.com/api
|
||||||
|
|
||||||
# Токен бота: Settings → My Bots → создать бота → скопировать токен.
|
# Токен бота: Settings → My Bots → создать бота → скопировать токен.
|
||||||
STOAT_BOT_TOKEN=
|
STOAT_BOT_TOKEN=
|
||||||
|
|
||||||
# Префикс команд в чате.
|
# Префикс команд в чате.
|
||||||
COMMAND_PREFIX=!
|
COMMAND_PREFIX=!
|
||||||
|
|
||||||
# ------------------------------------------------------------- Веб-панель ---
|
# Удалять сообщение с командой после её распознавания (нужно право ManageMessages).
|
||||||
PORT=3005
|
DELETE_COMMAND_MESSAGES=true
|
||||||
HOST=0.0.0.0
|
|
||||||
|
# ------------------------------------------------------------- Веб-панель ---
|
||||||
# Адрес, по которому панель открывается в браузере (без слэша в конце).
|
PORT=3005
|
||||||
PUBLIC_URL=https://music.example.com
|
HOST=0.0.0.0
|
||||||
|
|
||||||
# Секрет для подписи сессионных cookie. Сгенерируйте: openssl rand -hex 32
|
# Адрес, по которому панель открывается в браузере (без слэша в конце).
|
||||||
JWT_SECRET=
|
PUBLIC_URL=https://music.example.com
|
||||||
|
|
||||||
# Срок жизни сессии панели, часов.
|
# Секрет для подписи сессионных cookie. Сгенерируйте: openssl rand -hex 32
|
||||||
SESSION_TTL_HOURS=168
|
JWT_SECRET=
|
||||||
|
|
||||||
# ---------------------------------------------------------------- Аудио ----
|
# Срок жизни сессии панели, часов.
|
||||||
# Путь к yt-dlp. В docker-образе он уже установлен.
|
SESSION_TTL_HOURS=168
|
||||||
YTDLP_PATH=yt-dlp
|
|
||||||
|
# ---------------------------------------------------------------- Аудио ----
|
||||||
# Необязательно: cookies.txt аккаунта YouTube — снимает возрастные ограничения,
|
# Путь к yt-dlp. В docker-образе он уже установлен.
|
||||||
# «Sign in to confirm you're not a bot» и открывает приватные/платные видео.
|
YTDLP_PATH=yt-dlp
|
||||||
# Файл должен быть доступен на ЗАПИСЬ: yt-dlp обновляет в нём ротируемые куки.
|
|
||||||
# Подробности — в README, раздел «Учётка YouTube (cookies)».
|
# Необязательно: cookies.txt аккаунта YouTube — снимает возрастные ограничения,
|
||||||
# YTDLP_COOKIES=/data/cookies.txt
|
# «Sign in to confirm you're not a bot» и открывает приватные/платные видео.
|
||||||
|
# Файл должен быть доступен на ЗАПИСЬ: yt-dlp обновляет в нём ротируемые куки.
|
||||||
# Необязательно: дополнительные --extractor-args, через ";".
|
# Подробности — в README, раздел «Учётка YouTube (cookies)».
|
||||||
# Помогает, когда YouTube не отдаёт форматы серверному IP:
|
# YTDLP_COOKIES=/data/cookies.txt
|
||||||
# YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari
|
|
||||||
|
# Необязательно: дополнительные --extractor-args, через ";".
|
||||||
# Необязательно: каталог с локальной медиатекой (смонтируйте том).
|
# Помогает, когда YouTube не отдаёт форматы серверному IP:
|
||||||
# LOCAL_MEDIA_DIR=/media/music
|
# YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari
|
||||||
|
|
||||||
DEFAULT_VOLUME=60
|
# Необязательно: каталог с локальной медиатекой (смонтируйте том).
|
||||||
MAX_QUEUE_SIZE=500
|
# LOCAL_MEDIA_DIR=/media/music
|
||||||
SEARCH_RESULT_LIMIT=10
|
|
||||||
|
DEFAULT_VOLUME=60
|
||||||
# Через сколько секунд простоя бот выходит из голосового канала (0 — никогда).
|
MAX_QUEUE_SIZE=500
|
||||||
IDLE_TIMEOUT_SECONDS=300
|
SEARCH_RESULT_LIMIT=10
|
||||||
|
|
||||||
# --------------------------------------------------------------- Доступ ----
|
# Через сколько секунд простоя бот выходит из голосового канала (0 — никогда).
|
||||||
# false — управлять может любой участник сервера.
|
IDLE_TIMEOUT_SECONDS=300
|
||||||
# true — только владелец, ManageServer или роль DJ_ROLE_NAME.
|
|
||||||
REQUIRE_DJ_ROLE=false
|
# --------------------------------------------------------------- Доступ ----
|
||||||
DJ_ROLE_NAME=DJ
|
# false — управлять может любой участник сервера.
|
||||||
|
# true — только владелец, ManageServer или роль DJ_ROLE_NAME.
|
||||||
# ----------------------------------------------------------------- Прочее ---
|
REQUIRE_DJ_ROLE=false
|
||||||
LOG_LEVEL=info
|
DJ_ROLE_NAME=DJ
|
||||||
NODE_ENV=production
|
|
||||||
|
# ----------------------------------------------------------------- Прочее ---
|
||||||
|
LOG_LEVEL=info
|
||||||
|
NODE_ENV=production
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
+490
-478
@@ -1,478 +1,490 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { openPlayback, type PlaybackInput } from "../sources/index.js";
|
import { openPlayback, type PlaybackInput } from "../sources/index.js";
|
||||||
import {
|
import {
|
||||||
UserFacingError,
|
UserFacingError,
|
||||||
type LoopMode,
|
type LoopMode,
|
||||||
type PlayerSnapshot,
|
type PlayerSnapshot,
|
||||||
type PlayerStatus,
|
type PlayerStatus,
|
||||||
type Track,
|
type Track,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import {
|
import {
|
||||||
MediaPlayer,
|
MediaPlayer,
|
||||||
parseFfmpegDuration,
|
parseFfmpegDuration,
|
||||||
type MediaPlayerLike,
|
VOICE_STATE_OFFLINE,
|
||||||
type RevoiceLike,
|
type MediaPlayerLike,
|
||||||
type VoiceConnectionLike,
|
type RevoiceLike,
|
||||||
} from "./revoice.js";
|
type VoiceConnectionLike,
|
||||||
|
} from "./revoice.js";
|
||||||
const HISTORY_LIMIT = 50;
|
|
||||||
const JOIN_TIMEOUT_MS = 20_000;
|
const HISTORY_LIMIT = 50;
|
||||||
|
const JOIN_TIMEOUT_MS = 20_000;
|
||||||
export interface PositionUpdate {
|
|
||||||
serverId: string;
|
export interface PositionUpdate {
|
||||||
position: number;
|
serverId: string;
|
||||||
duration: number;
|
position: number;
|
||||||
status: PlayerStatus;
|
duration: number;
|
||||||
}
|
status: PlayerStatus;
|
||||||
|
}
|
||||||
export interface Notice {
|
|
||||||
serverId: string;
|
export interface Notice {
|
||||||
textChannelId: string | null;
|
serverId: string;
|
||||||
text: string;
|
textChannelId: string | null;
|
||||||
}
|
text: string;
|
||||||
|
}
|
||||||
export interface GuildPlayerEvents {
|
|
||||||
update: [PlayerSnapshot];
|
export interface GuildPlayerEvents {
|
||||||
position: [PositionUpdate];
|
update: [PlayerSnapshot];
|
||||||
notice: [Notice];
|
position: [PositionUpdate];
|
||||||
destroyed: [{ serverId: string }];
|
notice: [Notice];
|
||||||
}
|
destroyed: [{ serverId: string }];
|
||||||
|
}
|
||||||
export interface GuildPlayerOptions {
|
|
||||||
serverId: string;
|
export interface GuildPlayerOptions {
|
||||||
serverName: string | null;
|
serverId: string;
|
||||||
revoice: RevoiceLike;
|
serverName: string | null;
|
||||||
}
|
revoice: RevoiceLike;
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Owns everything about music playback for one Stoat server: the voice
|
/**
|
||||||
* connection, the queue and the ffmpeg-backed media player. Chat commands and
|
* Owns everything about music playback for one Stoat server: the voice
|
||||||
* the web panel both drive playback exclusively through this class, so the two
|
* connection, the queue and the ffmpeg-backed media player. Chat commands and
|
||||||
* can never drift apart.
|
* the web panel both drive playback exclusively through this class, so the two
|
||||||
*/
|
* can never drift apart.
|
||||||
export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
*/
|
||||||
readonly serverId: string;
|
export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
||||||
serverName: string | null;
|
readonly serverId: string;
|
||||||
textChannelId: string | null = null;
|
serverName: string | null;
|
||||||
voiceChannelId: string | null = null;
|
textChannelId: string | null = null;
|
||||||
voiceChannelName: string | null = null;
|
voiceChannelId: string | null = null;
|
||||||
|
voiceChannelName: string | null = null;
|
||||||
queue: Track[] = [];
|
|
||||||
history: Track[] = [];
|
queue: Track[] = [];
|
||||||
current: Track | null = null;
|
history: Track[] = [];
|
||||||
volume = config.DEFAULT_VOLUME;
|
current: Track | null = null;
|
||||||
loop: LoopMode = "off";
|
volume = config.DEFAULT_VOLUME;
|
||||||
shuffleUsed = false;
|
loop: LoopMode = "off";
|
||||||
|
shuffleUsed = false;
|
||||||
private status: PlayerStatus = "idle";
|
|
||||||
private readonly revoice: RevoiceLike;
|
private status: PlayerStatus = "idle";
|
||||||
private connection: VoiceConnectionLike | null = null;
|
private readonly revoice: RevoiceLike;
|
||||||
private media: MediaPlayerLike | null = null;
|
private connection: VoiceConnectionLike | null = null;
|
||||||
private currentInput: PlaybackInput | null = null;
|
/** Tracked from connection events: revoice's own `connected` getter is broken. */
|
||||||
private seekOffset = 0;
|
private voiceReady = false;
|
||||||
/** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */
|
private media: MediaPlayerLike | null = null;
|
||||||
private expectingStop = false;
|
private currentInput: PlaybackInput | null = null;
|
||||||
private idleTimer: NodeJS.Timeout | null = null;
|
private seekOffset = 0;
|
||||||
private ticker: NodeJS.Timeout | null = null;
|
/** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */
|
||||||
private readonly log;
|
private expectingStop = false;
|
||||||
|
private idleTimer: NodeJS.Timeout | null = null;
|
||||||
constructor(options: GuildPlayerOptions) {
|
private ticker: NodeJS.Timeout | null = null;
|
||||||
super();
|
private readonly log;
|
||||||
this.serverId = options.serverId;
|
|
||||||
this.serverName = options.serverName;
|
constructor(options: GuildPlayerOptions) {
|
||||||
this.revoice = options.revoice;
|
super();
|
||||||
this.log = logger.child({ mod: "player", server: options.serverId });
|
this.serverId = options.serverId;
|
||||||
}
|
this.serverName = options.serverName;
|
||||||
|
this.revoice = options.revoice;
|
||||||
// ---------------------------------------------------------------- state ---
|
this.log = logger.child({ mod: "player", server: options.serverId });
|
||||||
|
}
|
||||||
get position(): number {
|
|
||||||
if (!this.media) return 0;
|
// ---------------------------------------------------------------- state ---
|
||||||
return this.seekOffset + this.media.seconds;
|
|
||||||
}
|
get position(): number {
|
||||||
|
if (!this.media) return 0;
|
||||||
snapshot(): PlayerSnapshot {
|
return this.seekOffset + this.media.seconds;
|
||||||
return {
|
}
|
||||||
serverId: this.serverId,
|
|
||||||
serverName: this.serverName,
|
snapshot(): PlayerSnapshot {
|
||||||
voiceChannelId: this.voiceChannelId,
|
return {
|
||||||
voiceChannelName: this.voiceChannelName,
|
serverId: this.serverId,
|
||||||
textChannelId: this.textChannelId,
|
serverName: this.serverName,
|
||||||
status: this.status,
|
voiceChannelId: this.voiceChannelId,
|
||||||
current: this.current,
|
voiceChannelName: this.voiceChannelName,
|
||||||
position: Math.round(this.position * 10) / 10,
|
textChannelId: this.textChannelId,
|
||||||
queue: this.queue,
|
status: this.status,
|
||||||
history: this.history.slice(0, 10),
|
current: this.current,
|
||||||
volume: this.volume,
|
position: Math.round(this.position * 10) / 10,
|
||||||
loop: this.loop,
|
queue: this.queue,
|
||||||
shuffleUsed: this.shuffleUsed,
|
history: this.history.slice(0, 10),
|
||||||
updatedAt: Date.now(),
|
volume: this.volume,
|
||||||
};
|
loop: this.loop,
|
||||||
}
|
shuffleUsed: this.shuffleUsed,
|
||||||
|
updatedAt: Date.now(),
|
||||||
private setStatus(status: PlayerStatus): void {
|
};
|
||||||
if (this.status === status) return;
|
}
|
||||||
this.status = status;
|
|
||||||
this.publish();
|
private setStatus(status: PlayerStatus): void {
|
||||||
}
|
if (this.status === status) return;
|
||||||
|
this.status = status;
|
||||||
publish(): void {
|
this.publish();
|
||||||
this.emit("update", this.snapshot());
|
}
|
||||||
}
|
|
||||||
|
publish(): void {
|
||||||
private notify(text: string): void {
|
this.emit("update", this.snapshot());
|
||||||
this.emit("notice", { serverId: this.serverId, textChannelId: this.textChannelId, text });
|
}
|
||||||
}
|
|
||||||
|
private notify(text: string): void {
|
||||||
// ------------------------------------------------------------ connection ---
|
this.emit("notice", { serverId: this.serverId, textChannelId: this.textChannelId, text });
|
||||||
|
}
|
||||||
isConnected(): boolean {
|
|
||||||
return Boolean(this.connection?.connected);
|
// ------------------------------------------------------------ connection ---
|
||||||
}
|
|
||||||
|
isConnected(): boolean {
|
||||||
async connect(channelId: string, channelName: string | null): Promise<void> {
|
return this.voiceReady;
|
||||||
if (this.connection?.connected && this.voiceChannelId === channelId) {
|
}
|
||||||
this.voiceChannelName = channelName ?? this.voiceChannelName;
|
|
||||||
return;
|
async connect(channelId: string, channelName: string | null): Promise<void> {
|
||||||
}
|
if (this.voiceReady && this.voiceChannelId === channelId) {
|
||||||
if (this.connection) await this.leaveVoice();
|
this.voiceChannelName = channelName ?? this.voiceChannelName;
|
||||||
|
return;
|
||||||
this.setStatus("connecting");
|
}
|
||||||
this.log.info({ channelId }, "joining voice channel");
|
if (this.connection) await this.leaveVoice();
|
||||||
|
|
||||||
const connection = await this.revoice.join(channelId);
|
this.setStatus("connecting");
|
||||||
if (!connection.connected) {
|
this.log.info({ channelId }, "joining voice channel");
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(
|
const connection = await this.revoice.join(channelId);
|
||||||
() => reject(new UserFacingError("Не удалось подключиться к голосовому каналу")),
|
// The room connects asynchronously inside revoice's constructor, so we wait
|
||||||
JOIN_TIMEOUT_MS,
|
// for it to report readiness rather than polling a state getter.
|
||||||
);
|
await new Promise<void>((resolve, reject) => {
|
||||||
connection.on("join", () => {
|
const timer = setTimeout(
|
||||||
clearTimeout(timer);
|
() => reject(new UserFacingError("Не удалось подключиться к голосовому каналу")),
|
||||||
resolve();
|
JOIN_TIMEOUT_MS,
|
||||||
});
|
);
|
||||||
});
|
const done = () => {
|
||||||
}
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
this.connection = connection;
|
};
|
||||||
this.voiceChannelId = channelId;
|
connection.once("join", done);
|
||||||
this.voiceChannelName = channelName;
|
connection.once("roomfetched", done);
|
||||||
|
});
|
||||||
connection.on("userleave", () => this.checkEmptyChannel());
|
|
||||||
connection.on("userLeave", () => this.checkEmptyChannel());
|
this.connection = connection;
|
||||||
connection.on("userJoin", () => this.clearIdleTimer());
|
this.voiceReady = true;
|
||||||
|
this.voiceChannelId = channelId;
|
||||||
const media = new MediaPlayer(true);
|
this.voiceChannelName = channelName;
|
||||||
media.on("startplay", () => {
|
|
||||||
this.setStatus(media.paused ? "paused" : "playing");
|
connection.on("state", (state) => {
|
||||||
this.startTicker();
|
if (state !== VOICE_STATE_OFFLINE) return;
|
||||||
});
|
this.voiceReady = false;
|
||||||
media.on("buffer", () => this.setStatus("buffering"));
|
this.log.warn({ channelId }, "voice connection went offline");
|
||||||
media.on("pause", () => this.setStatus("paused"));
|
});
|
||||||
media.on("unpause", () => this.setStatus("playing"));
|
connection.on("userleave", () => this.checkEmptyChannel());
|
||||||
media.on("finish", () => {
|
connection.on("userLeave", () => this.checkEmptyChannel());
|
||||||
void this.handleFinish();
|
connection.on("userJoin", () => this.clearIdleTimer());
|
||||||
});
|
|
||||||
this.media = media;
|
const media = new MediaPlayer(true);
|
||||||
await connection.play(media);
|
media.on("startplay", () => {
|
||||||
|
this.setStatus(media.paused ? "paused" : "playing");
|
||||||
this.setStatus("idle");
|
this.startTicker();
|
||||||
this.log.info({ channelId }, "voice connection established");
|
});
|
||||||
}
|
media.on("buffer", () => this.setStatus("buffering"));
|
||||||
|
media.on("pause", () => this.setStatus("paused"));
|
||||||
async leaveVoice(): Promise<void> {
|
media.on("unpause", () => this.setStatus("playing"));
|
||||||
this.clearIdleTimer();
|
media.on("finish", () => {
|
||||||
this.stopTicker();
|
void this.handleFinish();
|
||||||
this.teardownPlayback();
|
});
|
||||||
this.current = null;
|
this.media = media;
|
||||||
|
await connection.play(media);
|
||||||
const connection = this.connection;
|
|
||||||
this.connection = null;
|
this.setStatus("idle");
|
||||||
this.media?.removeAllListeners();
|
this.log.info({ channelId }, "voice connection established");
|
||||||
this.media = null;
|
}
|
||||||
this.voiceChannelId = null;
|
|
||||||
this.voiceChannelName = null;
|
async leaveVoice(): Promise<void> {
|
||||||
|
this.clearIdleTimer();
|
||||||
if (connection) {
|
this.stopTicker();
|
||||||
try {
|
this.teardownPlayback();
|
||||||
await connection.destroy();
|
this.current = null;
|
||||||
} catch (err) {
|
|
||||||
this.log.warn({ err }, "failed to leave voice channel cleanly");
|
const connection = this.connection;
|
||||||
}
|
this.connection = null;
|
||||||
connection.removeAllListeners();
|
this.voiceReady = false;
|
||||||
}
|
this.media?.removeAllListeners();
|
||||||
this.setStatus("idle");
|
this.media = null;
|
||||||
this.publish();
|
this.voiceChannelId = null;
|
||||||
}
|
this.voiceChannelName = null;
|
||||||
|
|
||||||
private checkEmptyChannel(): void {
|
if (connection) {
|
||||||
if (!this.connection) return;
|
try {
|
||||||
if (this.connection.getUsers().length > 0) return;
|
await connection.destroy();
|
||||||
this.startIdleTimer("В канале никого не осталось");
|
} catch (err) {
|
||||||
}
|
this.log.warn({ err }, "failed to leave voice channel cleanly");
|
||||||
|
}
|
||||||
// -------------------------------------------------------------- playback ---
|
connection.removeAllListeners();
|
||||||
|
}
|
||||||
private assertReady(): MediaPlayerLike {
|
this.setStatus("idle");
|
||||||
if (!this.media || !this.connection?.connected) {
|
this.publish();
|
||||||
throw new UserFacingError("Бот не подключён к голосовому каналу");
|
}
|
||||||
}
|
|
||||||
return this.media;
|
private checkEmptyChannel(): void {
|
||||||
}
|
if (!this.connection) return;
|
||||||
|
if (this.connection.getUsers().length > 0) return;
|
||||||
enqueue(tracks: Track[], position?: number): void {
|
this.startIdleTimer("В канале никого не осталось");
|
||||||
if (this.queue.length + tracks.length > config.MAX_QUEUE_SIZE) {
|
}
|
||||||
throw new UserFacingError(`Очередь ограничена ${config.MAX_QUEUE_SIZE} треками`);
|
|
||||||
}
|
// -------------------------------------------------------------- playback ---
|
||||||
if (position === undefined) this.queue.push(...tracks);
|
|
||||||
else this.queue.splice(Math.max(0, position), 0, ...tracks);
|
private assertReady(): MediaPlayerLike {
|
||||||
this.publish();
|
if (!this.media || !this.voiceReady) {
|
||||||
}
|
throw new UserFacingError("Бот не подключён к голосовому каналу");
|
||||||
|
}
|
||||||
/** Starts playback if nothing is currently playing. */
|
return this.media;
|
||||||
async ensurePlaying(): Promise<void> {
|
}
|
||||||
if (this.current || this.status === "buffering" || this.status === "connecting") return;
|
|
||||||
await this.advance(false);
|
enqueue(tracks: Track[], position?: number): void {
|
||||||
}
|
if (this.queue.length + tracks.length > config.MAX_QUEUE_SIZE) {
|
||||||
|
throw new UserFacingError(`Очередь ограничена ${config.MAX_QUEUE_SIZE} треками`);
|
||||||
private async startPlayback(track: Track, seekSeconds = 0): Promise<void> {
|
}
|
||||||
const media = this.assertReady();
|
if (position === undefined) this.queue.push(...tracks);
|
||||||
this.clearIdleTimer();
|
else this.queue.splice(Math.max(0, position), 0, ...tracks);
|
||||||
this.teardownPlayback();
|
this.publish();
|
||||||
|
}
|
||||||
this.current = track;
|
|
||||||
this.seekOffset = seekSeconds;
|
/** Starts playback if nothing is currently playing. */
|
||||||
this.setStatus("buffering");
|
async ensurePlaying(): Promise<void> {
|
||||||
this.publish();
|
if (this.current || this.status === "buffering" || this.status === "connecting") return;
|
||||||
|
await this.advance(false);
|
||||||
try {
|
}
|
||||||
const input = await openPlayback(track, seekSeconds);
|
|
||||||
this.currentInput = input;
|
private async startPlayback(track: Track, seekSeconds = 0): Promise<void> {
|
||||||
this.expectingStop = false;
|
const media = this.assertReady();
|
||||||
await media.playStream(input.input, input.inputOptions);
|
this.clearIdleTimer();
|
||||||
// stop() rebuilds the volume transformer, so volume is applied per track.
|
this.teardownPlayback();
|
||||||
media.setVolume(this.volume / 100);
|
|
||||||
this.startTicker();
|
this.current = track;
|
||||||
} catch (err) {
|
this.seekOffset = seekSeconds;
|
||||||
this.log.warn({ err, track: track.title }, "playback failed");
|
this.setStatus("buffering");
|
||||||
const message = err instanceof UserFacingError ? err.message : "неизвестная ошибка";
|
this.publish();
|
||||||
this.notify(`⚠️ Не удалось воспроизвести **${track.title}** (${message}), пропускаю.`);
|
|
||||||
this.current = null;
|
try {
|
||||||
await this.advance(true);
|
const input = await openPlayback(track, seekSeconds);
|
||||||
}
|
this.currentInput = input;
|
||||||
}
|
this.expectingStop = false;
|
||||||
|
await media.playStream(input.input, input.inputOptions);
|
||||||
/** Tears down ffmpeg/yt-dlp for the current track without advancing the queue. */
|
// stop() rebuilds the volume transformer, so volume is applied per track.
|
||||||
private teardownPlayback(): void {
|
media.setVolume(this.volume / 100);
|
||||||
if (this.media) {
|
this.startTicker();
|
||||||
this.expectingStop = true;
|
} catch (err) {
|
||||||
try {
|
this.log.warn({ err, track: track.title }, "playback failed");
|
||||||
this.media.fProc?.kill("SIGKILL");
|
const message = err instanceof UserFacingError ? err.message : "неизвестная ошибка";
|
||||||
} catch {
|
this.notify(`⚠️ Не удалось воспроизвести **${track.title}** (${message}), пропускаю.`);
|
||||||
// ffmpeg may already be gone.
|
this.current = null;
|
||||||
}
|
await this.advance(true);
|
||||||
try {
|
}
|
||||||
this.media.stop();
|
}
|
||||||
} catch (err) {
|
|
||||||
this.log.debug({ err }, "media.stop() threw");
|
/** Tears down ffmpeg/yt-dlp for the current track without advancing the queue. */
|
||||||
}
|
private teardownPlayback(): void {
|
||||||
}
|
if (this.media) {
|
||||||
this.currentInput?.cleanup();
|
this.expectingStop = true;
|
||||||
this.currentInput = null;
|
try {
|
||||||
this.seekOffset = 0;
|
this.media.fProc?.kill("SIGKILL");
|
||||||
}
|
} catch {
|
||||||
|
// ffmpeg may already be gone.
|
||||||
private async handleFinish(): Promise<void> {
|
}
|
||||||
if (this.expectingStop) {
|
try {
|
||||||
this.expectingStop = false;
|
this.media.stop();
|
||||||
return;
|
} catch (err) {
|
||||||
}
|
this.log.debug({ err }, "media.stop() threw");
|
||||||
await this.advance(false);
|
}
|
||||||
}
|
}
|
||||||
|
this.currentInput?.cleanup();
|
||||||
/** Moves to the next track. `skipLoop` ignores per-track looping (used by skip). */
|
this.currentInput = null;
|
||||||
private async advance(skipLoop: boolean): Promise<void> {
|
this.seekOffset = 0;
|
||||||
const finished = this.current;
|
}
|
||||||
this.current = null;
|
|
||||||
this.currentInput?.cleanup();
|
private async handleFinish(): Promise<void> {
|
||||||
this.currentInput = null;
|
if (this.expectingStop) {
|
||||||
this.seekOffset = 0;
|
this.expectingStop = false;
|
||||||
|
return;
|
||||||
if (finished) {
|
}
|
||||||
this.history.unshift(finished);
|
await this.advance(false);
|
||||||
this.history = this.history.slice(0, HISTORY_LIMIT);
|
}
|
||||||
if (!skipLoop && this.loop === "track") this.queue.unshift(finished);
|
|
||||||
else if (this.loop === "queue") this.queue.push(finished);
|
/** Moves to the next track. `skipLoop` ignores per-track looping (used by skip). */
|
||||||
}
|
private async advance(skipLoop: boolean): Promise<void> {
|
||||||
|
const finished = this.current;
|
||||||
const next = this.queue.shift();
|
this.current = null;
|
||||||
if (!next) {
|
this.currentInput?.cleanup();
|
||||||
this.stopTicker();
|
this.currentInput = null;
|
||||||
this.setStatus("idle");
|
this.seekOffset = 0;
|
||||||
this.publish();
|
|
||||||
if (finished) this.notify("⏹️ Очередь закончилась.");
|
if (finished) {
|
||||||
this.startIdleTimer();
|
this.history.unshift(finished);
|
||||||
return;
|
this.history = this.history.slice(0, HISTORY_LIMIT);
|
||||||
}
|
if (!skipLoop && this.loop === "track") this.queue.unshift(finished);
|
||||||
|
else if (this.loop === "queue") this.queue.push(finished);
|
||||||
await this.startPlayback(next);
|
}
|
||||||
this.notify(`▶️ Сейчас играет: **${next.title}**`);
|
|
||||||
}
|
const next = this.queue.shift();
|
||||||
|
if (!next) {
|
||||||
async skip(count = 1): Promise<Track | null> {
|
this.stopTicker();
|
||||||
if (!this.current && this.queue.length === 0) throw new UserFacingError("Нечего пропускать");
|
this.setStatus("idle");
|
||||||
for (let i = 1; i < count; i += 1) this.queue.shift();
|
this.publish();
|
||||||
this.teardownPlayback();
|
if (finished) this.notify("⏹️ Очередь закончилась.");
|
||||||
await this.advance(true);
|
this.startIdleTimer();
|
||||||
return this.current;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
await this.startPlayback(next);
|
||||||
this.queue = [];
|
this.notify(`▶️ Сейчас играет: **${next.title}**`);
|
||||||
this.loop = "off";
|
}
|
||||||
this.teardownPlayback();
|
|
||||||
this.current = null;
|
async skip(count = 1): Promise<Track | null> {
|
||||||
this.stopTicker();
|
if (!this.current && this.queue.length === 0) throw new UserFacingError("Нечего пропускать");
|
||||||
this.setStatus("idle");
|
for (let i = 1; i < count; i += 1) this.queue.shift();
|
||||||
this.publish();
|
this.teardownPlayback();
|
||||||
this.startIdleTimer();
|
await this.advance(true);
|
||||||
}
|
return this.current;
|
||||||
|
}
|
||||||
pause(): void {
|
|
||||||
const media = this.assertReady();
|
async stop(): Promise<void> {
|
||||||
if (!this.current) throw new UserFacingError("Сейчас ничего не играет");
|
this.queue = [];
|
||||||
media.pause();
|
this.loop = "off";
|
||||||
this.setStatus("paused");
|
this.teardownPlayback();
|
||||||
this.publish();
|
this.current = null;
|
||||||
}
|
this.stopTicker();
|
||||||
|
this.setStatus("idle");
|
||||||
resume(): void {
|
this.publish();
|
||||||
const media = this.assertReady();
|
this.startIdleTimer();
|
||||||
if (!this.current) throw new UserFacingError("Сейчас ничего не играет");
|
}
|
||||||
media.resume();
|
|
||||||
this.setStatus("playing");
|
pause(): void {
|
||||||
this.publish();
|
const media = this.assertReady();
|
||||||
}
|
if (!this.current) throw new UserFacingError("Сейчас ничего не играет");
|
||||||
|
media.pause();
|
||||||
setVolume(volume: number): void {
|
this.setStatus("paused");
|
||||||
const clamped = Math.min(200, Math.max(0, Math.round(volume)));
|
this.publish();
|
||||||
this.volume = clamped;
|
}
|
||||||
this.media?.setVolume(clamped / 100);
|
|
||||||
this.publish();
|
resume(): void {
|
||||||
}
|
const media = this.assertReady();
|
||||||
|
if (!this.current) throw new UserFacingError("Сейчас ничего не играет");
|
||||||
setLoop(mode: LoopMode): void {
|
media.resume();
|
||||||
this.loop = mode;
|
this.setStatus("playing");
|
||||||
this.publish();
|
this.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
shuffle(): void {
|
setVolume(volume: number): void {
|
||||||
for (let i = this.queue.length - 1; i > 0; i -= 1) {
|
const clamped = Math.min(200, Math.max(0, Math.round(volume)));
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
this.volume = clamped;
|
||||||
const a = this.queue[i];
|
this.media?.setVolume(clamped / 100);
|
||||||
const b = this.queue[j];
|
this.publish();
|
||||||
if (a && b) {
|
}
|
||||||
this.queue[i] = b;
|
|
||||||
this.queue[j] = a;
|
setLoop(mode: LoopMode): void {
|
||||||
}
|
this.loop = mode;
|
||||||
}
|
this.publish();
|
||||||
this.shuffleUsed = true;
|
}
|
||||||
this.publish();
|
|
||||||
}
|
shuffle(): void {
|
||||||
|
for (let i = this.queue.length - 1; i > 0; i -= 1) {
|
||||||
remove(trackId: string): Track {
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
const index = this.queue.findIndex((track) => track.id === trackId);
|
const a = this.queue[i];
|
||||||
if (index === -1) throw new UserFacingError("Трек не найден в очереди");
|
const b = this.queue[j];
|
||||||
const [removed] = this.queue.splice(index, 1);
|
if (a && b) {
|
||||||
this.publish();
|
this.queue[i] = b;
|
||||||
return removed as Track;
|
this.queue[j] = a;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
move(trackId: string, toIndex: number): void {
|
this.shuffleUsed = true;
|
||||||
const from = this.queue.findIndex((track) => track.id === trackId);
|
this.publish();
|
||||||
if (from === -1) throw new UserFacingError("Трек не найден в очереди");
|
}
|
||||||
const target = Math.min(this.queue.length - 1, Math.max(0, toIndex));
|
|
||||||
const [track] = this.queue.splice(from, 1);
|
remove(trackId: string): Track {
|
||||||
if (track) this.queue.splice(target, 0, track);
|
const index = this.queue.findIndex((track) => track.id === trackId);
|
||||||
this.publish();
|
if (index === -1) throw new UserFacingError("Трек не найден в очереди");
|
||||||
}
|
const [removed] = this.queue.splice(index, 1);
|
||||||
|
this.publish();
|
||||||
clearQueue(): void {
|
return removed as Track;
|
||||||
this.queue = [];
|
}
|
||||||
this.publish();
|
|
||||||
}
|
move(trackId: string, toIndex: number): void {
|
||||||
|
const from = this.queue.findIndex((track) => track.id === trackId);
|
||||||
async seek(seconds: number): Promise<void> {
|
if (from === -1) throw new UserFacingError("Трек не найден в очереди");
|
||||||
const track = this.current;
|
const target = Math.min(this.queue.length - 1, Math.max(0, toIndex));
|
||||||
if (!track) throw new UserFacingError("Сейчас ничего не играет");
|
const [track] = this.queue.splice(from, 1);
|
||||||
if (track.isLive) throw new UserFacingError("Нельзя перематывать прямой эфир");
|
if (track) this.queue.splice(target, 0, track);
|
||||||
if (track.duration > 0 && seconds >= track.duration) {
|
this.publish();
|
||||||
throw new UserFacingError("Позиция за пределами трека");
|
}
|
||||||
}
|
|
||||||
this.teardownPlayback();
|
clearQueue(): void {
|
||||||
await this.startPlayback(track, Math.max(0, seconds));
|
this.queue = [];
|
||||||
}
|
this.publish();
|
||||||
|
}
|
||||||
async playNow(tracks: Track[]): Promise<void> {
|
|
||||||
if (tracks.length === 0) return;
|
async seek(seconds: number): Promise<void> {
|
||||||
this.queue.unshift(...tracks);
|
const track = this.current;
|
||||||
this.teardownPlayback();
|
if (!track) throw new UserFacingError("Сейчас ничего не играет");
|
||||||
await this.advance(true);
|
if (track.isLive) throw new UserFacingError("Нельзя перематывать прямой эфир");
|
||||||
}
|
if (track.duration > 0 && seconds >= track.duration) {
|
||||||
|
throw new UserFacingError("Позиция за пределами трека");
|
||||||
// ---------------------------------------------------------- housekeeping ---
|
}
|
||||||
|
this.teardownPlayback();
|
||||||
private startTicker(): void {
|
await this.startPlayback(track, Math.max(0, seconds));
|
||||||
if (this.ticker) return;
|
}
|
||||||
this.ticker = setInterval(() => {
|
|
||||||
if (!this.current || !this.media) return;
|
async playNow(tracks: Track[]): Promise<void> {
|
||||||
// ffmpeg reports the real duration once it has probed the input, which is
|
if (tracks.length === 0) return;
|
||||||
// the only way we learn how long a local file or a direct URL is.
|
this.queue.unshift(...tracks);
|
||||||
if (this.current.duration === 0 && !this.current.isLive) {
|
this.teardownPlayback();
|
||||||
const probed = parseFfmpegDuration(this.media.codecData?.duration);
|
await this.advance(true);
|
||||||
if (probed > 0) {
|
}
|
||||||
this.current.duration = Math.round(probed);
|
|
||||||
this.publish();
|
// ---------------------------------------------------------- housekeeping ---
|
||||||
}
|
|
||||||
}
|
private startTicker(): void {
|
||||||
this.emit("position", {
|
if (this.ticker) return;
|
||||||
serverId: this.serverId,
|
this.ticker = setInterval(() => {
|
||||||
position: Math.round(this.position * 10) / 10,
|
if (!this.current || !this.media) return;
|
||||||
duration: this.current.duration,
|
// ffmpeg reports the real duration once it has probed the input, which is
|
||||||
status: this.status,
|
// the only way we learn how long a local file or a direct URL is.
|
||||||
});
|
if (this.current.duration === 0 && !this.current.isLive) {
|
||||||
}, 1000);
|
const probed = parseFfmpegDuration(this.media.codecData?.duration);
|
||||||
this.ticker.unref?.();
|
if (probed > 0) {
|
||||||
}
|
this.current.duration = Math.round(probed);
|
||||||
|
this.publish();
|
||||||
private stopTicker(): void {
|
}
|
||||||
if (!this.ticker) return;
|
}
|
||||||
clearInterval(this.ticker);
|
this.emit("position", {
|
||||||
this.ticker = null;
|
serverId: this.serverId,
|
||||||
}
|
position: Math.round(this.position * 10) / 10,
|
||||||
|
duration: this.current.duration,
|
||||||
private clearIdleTimer(): void {
|
status: this.status,
|
||||||
if (!this.idleTimer) return;
|
});
|
||||||
clearTimeout(this.idleTimer);
|
}, 1000);
|
||||||
this.idleTimer = null;
|
this.ticker.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
private startIdleTimer(reason?: string): void {
|
private stopTicker(): void {
|
||||||
this.clearIdleTimer();
|
if (!this.ticker) return;
|
||||||
if (config.IDLE_TIMEOUT_SECONDS <= 0 || !this.connection) return;
|
clearInterval(this.ticker);
|
||||||
this.idleTimer = setTimeout(() => {
|
this.ticker = null;
|
||||||
if (this.current) return;
|
}
|
||||||
this.notify(`👋 ${reason ?? "Нет активности"}, выхожу из голосового канала.`);
|
|
||||||
void this.leaveVoice();
|
private clearIdleTimer(): void {
|
||||||
}, config.IDLE_TIMEOUT_SECONDS * 1000);
|
if (!this.idleTimer) return;
|
||||||
this.idleTimer.unref?.();
|
clearTimeout(this.idleTimer);
|
||||||
}
|
this.idleTimer = null;
|
||||||
|
}
|
||||||
async destroy(): Promise<void> {
|
|
||||||
await this.leaveVoice();
|
private startIdleTimer(reason?: string): void {
|
||||||
this.emit("destroyed", { serverId: this.serverId });
|
this.clearIdleTimer();
|
||||||
this.removeAllListeners();
|
if (config.IDLE_TIMEOUT_SECONDS <= 0 || !this.connection) return;
|
||||||
}
|
this.idleTimer = setTimeout(() => {
|
||||||
}
|
if (this.current) return;
|
||||||
|
this.notify(`👋 ${reason ?? "Нет активности"}, выхожу из голосового канала.`);
|
||||||
|
void this.leaveVoice();
|
||||||
|
}, config.IDLE_TIMEOUT_SECONDS * 1000);
|
||||||
|
this.idleTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(): Promise<void> {
|
||||||
|
await this.leaveVoice();
|
||||||
|
this.emit("destroyed", { serverId: this.serverId });
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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