diff --git a/src/api/server.ts b/src/api/server.ts index f182ee3..d1a280d 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -202,8 +202,9 @@ export async function startApiServer({ manager, context }: ApiServerOptions) { return reply.send({ state: manager.snapshot(id), voiceChannels: context.listVoiceChannels(id), - yourVoiceChannel: context.findUserVoiceChannel(id, session.userId), - voicePresenceKnown: context.hasVoicePresence(id), + yourVoiceChannel: + context.findUserVoiceChannel(id, session.userId) ?? manager.listenerChannel(id, session.userId), + voicePresenceKnown: manager.knowsVoicePresence(id), canControl: await context.canControl(id, session.userId), }); }); @@ -357,9 +358,11 @@ export async function startApiServer({ manager, context }: ApiServerOptions) { // changes — otherwise the panel only learns about it on reload. let lastPresence = ""; const sendPresence = () => { - const yourVoiceChannel = context.findUserVoiceChannel(serverId, session.userId); + const yourVoiceChannel = + context.findUserVoiceChannel(serverId, session.userId) ?? + manager.listenerChannel(serverId, session.userId); const voiceChannels = context.listVoiceChannels(serverId); - const voicePresenceKnown = context.hasVoicePresence(serverId); + const voicePresenceKnown = manager.knowsVoicePresence(serverId); const fingerprint = JSON.stringify([yourVoiceChannel, voiceChannels, voicePresenceKnown]); if (fingerprint === lastPresence) return; lastPresence = fingerprint; diff --git a/src/core/manager.ts b/src/core/manager.ts index 4cee567..4876885 100644 --- a/src/core/manager.ts +++ b/src/core/manager.ts @@ -125,6 +125,22 @@ export class MusicManager extends EventEmitter { // ------------------------------------------------------------ permissions --- + /** + * Presence as seen through the bot's own LiveKit room. Stoat's gateway does + * not tell a bot who is sitting in voice, so while the bot is connected this + * is the only first-hand answer we have. + */ + listenerChannel(serverId: string, userId: string): VoiceChannelRef | null { + const player = this.players.get(serverId); + if (!player?.voiceChannelId || !player.hasParticipant(userId)) return null; + return { id: player.voiceChannelId, name: player.voiceChannelName ?? "" }; + } + + /** True when we have a first-hand view of who is in voice. */ + knowsVoicePresence(serverId: string): boolean { + return this.chat.hasVoicePresence(serverId) || Boolean(this.players.get(serverId)?.isConnected()); + } + async assertControl(serverId: string, userId: string): Promise { if (!(await this.chat.canControl(serverId, userId))) { throw new UserFacingError("Недостаточно прав для управления плеером"); @@ -142,7 +158,8 @@ export class MusicManager extends EventEmitter { const player = this.getOrCreate(serverId); if (options.textChannelId) player.textChannelId = options.textChannelId; - const listening = this.chat.findUserVoiceChannel(serverId, userId); + const listening = + this.chat.findUserVoiceChannel(serverId, userId) ?? this.listenerChannel(serverId, userId); if (config.REQUIRE_LISTENER && listening) { // Music follows the listener: you cannot push the bot into a channel you @@ -154,7 +171,7 @@ export class MusicManager extends EventEmitter { // Refuse only when we can actually see who is in voice. With no presence // data at all our view is stale rather than empty, and blocking would // strand everyone until the next restart. - if (this.chat.hasVoicePresence(serverId)) { + if (this.knowsVoicePresence(serverId)) { throw new UserFacingError("Сначала зайдите в голосовой канал"); } if (!options.voiceChannelId) { diff --git a/src/core/player.ts b/src/core/player.ts index 781eb65..8920670 100644 --- a/src/core/player.ts +++ b/src/core/player.ts @@ -262,6 +262,16 @@ export class GuildPlayer extends EventEmitter { * participant name, so nobody is ever dropped and the channel never looks * empty — the bot would sit in it forever. */ + /** Whether this user is in the channel the bot is sitting in, per LiveKit. */ + hasParticipant(userId: string): boolean { + const participants = this.connection?.room?.remoteParticipants; + if (!participants) return false; + for (const participant of participants.values()) { + if (participant.identity === userId) return true; + } + return false; + } + private listenersInChannel(): number { return this.connection?.room?.remoteParticipants?.size ?? 0; } diff --git a/web/src/App.tsx b/web/src/App.tsx index 8dc0d42..e2f1b1e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -195,6 +195,7 @@ export function App() { onAction={(action, payload) => void runAction(action, payload)} onSeek={(seconds) => void runAction("seek", { position: seconds })} onJoin={(channelId) => { + setError(null); void api .join(serverId, channelId) .then(() => refresh(serverId))