Read voice presence from the bot's own LiveKit room

The panel still claimed it could not see anyone in voice while the bot
and the user were sitting in the same channel: Stoat's gateway does not
send voice states to bots, so the SDK's participant list stays empty no
matter what.

While the bot is connected its LiveKit room knows exactly who is there,
so that is now consulted alongside the SDK — presence is reported as
known, the panel names the channel the user is in, and the listener rule
has real data to work with instead of falling back to trust.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 02:51:24 +03:00
co-authored by Claude Opus 5
parent c7f2080604
commit ec50e4c4df
4 changed files with 37 additions and 6 deletions
+7 -4
View File
@@ -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;
+19 -2
View File
@@ -125,6 +125,22 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
// ------------------------------------------------------------ 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<void> {
if (!(await this.chat.canControl(serverId, userId))) {
throw new UserFacingError("Недостаточно прав для управления плеером");
@@ -142,7 +158,8 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
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<ManagerEvents> {
// 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) {
+10
View File
@@ -262,6 +262,16 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
* 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;
}
+1
View File
@@ -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))