Stop the listener rule from locking people out on stale presence

The panel said "you are not in a voice channel" while the client showed
the user sitting in the call, which also disabled the only button that
could summon the bot. The SDK learns voice participants from gateway
events alone — no endpoint to ask — so a bot that restarted, or missed a
VoiceChannelJoin, sees every channel as empty forever, and that looks
exactly like nobody listening.

The rule now refuses only on proven absence: when the bot can see people
in voice somewhere in the server and the requester is not among them.
With no presence data at all it accepts an explicitly chosen channel and
logs that it is trusting the request, and the panel falls back to the
channel picker instead of a dead button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 02:34:54 +03:00
co-authored by Claude Opus 5
parent 0b17b880d6
commit 224d5679a3
6 changed files with 48 additions and 8 deletions
+1
View File
@@ -17,6 +17,7 @@ const context = {
getVoiceChannel: (id) => ({ id, name: "General" }), getVoiceChannel: (id) => ({ id, name: "General" }),
listVoiceChannels: () => [{ id: "vc1", name: "General" }], listVoiceChannels: () => [{ id: "vc1", name: "General" }],
findUserVoiceChannel: () => ({ id: "vc1", name: "General" }), findUserVoiceChannel: () => ({ id: "vc1", name: "General" }),
hasVoicePresence: () => true,
listServersForUser: async () => [{ id: "srv1", name: "Test Server", iconUrl: null }], listServersForUser: async () => [{ id: "srv1", name: "Test Server", iconUrl: null }],
isMember: async (s, u) => s === "srv1" && u === "user1", isMember: async (s, u) => s === "srv1" && u === "user1",
canControl: async () => true, canControl: async () => true,
+6 -2
View File
@@ -203,6 +203,7 @@ export async function startApiServer({ manager, context }: ApiServerOptions) {
state: manager.snapshot(id), state: manager.snapshot(id),
voiceChannels: context.listVoiceChannels(id), voiceChannels: context.listVoiceChannels(id),
yourVoiceChannel: context.findUserVoiceChannel(id, session.userId), yourVoiceChannel: context.findUserVoiceChannel(id, session.userId),
voicePresenceKnown: context.hasVoicePresence(id),
canControl: await context.canControl(id, session.userId), canControl: await context.canControl(id, session.userId),
}); });
}); });
@@ -358,10 +359,13 @@ export async function startApiServer({ manager, context }: ApiServerOptions) {
const sendPresence = () => { const sendPresence = () => {
const yourVoiceChannel = context.findUserVoiceChannel(serverId, session.userId); const yourVoiceChannel = context.findUserVoiceChannel(serverId, session.userId);
const voiceChannels = context.listVoiceChannels(serverId); const voiceChannels = context.listVoiceChannels(serverId);
const fingerprint = JSON.stringify([yourVoiceChannel, voiceChannels]); const voicePresenceKnown = context.hasVoicePresence(serverId);
const fingerprint = JSON.stringify([yourVoiceChannel, voiceChannels, voicePresenceKnown]);
if (fingerprint === lastPresence) return; if (fingerprint === lastPresence) return;
lastPresence = fingerprint; lastPresence = fingerprint;
socket.send(JSON.stringify({ type: "presence", yourVoiceChannel, voiceChannels })); socket.send(
JSON.stringify({ type: "presence", yourVoiceChannel, voiceChannels, voicePresenceKnown }),
);
}; };
sendPresence(); sendPresence();
const presenceTimer = setInterval(sendPresence, 3000); const presenceTimer = setInterval(sendPresence, 3000);
+12
View File
@@ -37,6 +37,18 @@ export class BotStoatContext implements StoatContext {
.map((channel) => ({ id: channel.id, name: channel.name })); .map((channel) => ({ id: channel.id, name: channel.name }));
} }
/**
* Whether we have any idea who is sitting in voice right now. The SDK learns
* participants from gateway events only, so a bot that restarted (or missed an
* event) sees every channel as empty — which is indistinguishable from an
* actually empty server unless we ask this question separately.
*/
hasVoicePresence(serverId: string): boolean {
const server = this.client.servers.get(serverId);
if (!server) return false;
return server.channels.some((channel) => channel.isVoice && channel.voiceParticipants.size > 0);
}
findUserVoiceChannel(serverId: string, userId: string): VoiceChannelRef | null { findUserVoiceChannel(serverId: string, userId: string): VoiceChannelRef | null {
const server = this.client.servers.get(serverId); const server = this.client.servers.get(serverId);
if (!server) return null; if (!server) return null;
+17 -4
View File
@@ -32,6 +32,7 @@ export interface ServerRef {
*/ */
export interface StoatContext { export interface StoatContext {
findUserVoiceChannel(serverId: string, userId: string): VoiceChannelRef | null; findUserVoiceChannel(serverId: string, userId: string): VoiceChannelRef | null;
hasVoicePresence(serverId: string): boolean;
getVoiceChannel(channelId: string): VoiceChannelRef | null; getVoiceChannel(channelId: string): VoiceChannelRef | null;
listVoiceChannels(serverId: string): VoiceChannelRef[]; listVoiceChannels(serverId: string): VoiceChannelRef[];
getServerName(serverId: string): string | null; getServerName(serverId: string): string | null;
@@ -143,16 +144,28 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
const listening = this.chat.findUserVoiceChannel(serverId, userId); const listening = this.chat.findUserVoiceChannel(serverId, userId);
if (config.REQUIRE_LISTENER) { if (config.REQUIRE_LISTENER && listening) {
// Music follows the listener: you cannot push the bot into a channel you // Music follows the listener: you cannot push the bot into a channel you
// are not sitting in, and you cannot start playback from nowhere. // are not sitting in.
if (!listening) throw new UserFacingError("Сначала зайдите в голосовой канал");
if (options.voiceChannelId && options.voiceChannelId !== listening.id) { if (options.voiceChannelId && options.voiceChannelId !== listening.id) {
throw new UserFacingError("Бота можно позвать только в тот канал, где вы находитесь"); throw new UserFacingError("Бота можно позвать только в тот канал, где вы находитесь");
} }
} else if (config.REQUIRE_LISTENER) {
// 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)) {
throw new UserFacingError("Сначала зайдите в голосовой канал");
}
if (!options.voiceChannelId) {
throw new UserFacingError(
"Не вижу, кто в голосовых каналах — выберите канал явно (в панели он появится в списке)",
);
}
log.warn({ serverId, userId }, "voice presence unknown, trusting the requested channel");
} }
const target = config.REQUIRE_LISTENER const target = config.REQUIRE_LISTENER && listening
? listening ? listening
: (options.voiceChannelId : (options.voiceChannelId
? this.chat.getVoiceChannel(options.voiceChannelId) ? this.chat.getVoiceChannel(options.voiceChannelId)
+10 -2
View File
@@ -17,6 +17,7 @@ export function App() {
const [voiceChannels, setVoiceChannels] = useState<VoiceChannel[]>([]); const [voiceChannels, setVoiceChannels] = useState<VoiceChannel[]>([]);
const [yourVoiceChannel, setYourVoiceChannel] = useState<VoiceChannel | null>(null); const [yourVoiceChannel, setYourVoiceChannel] = useState<VoiceChannel | null>(null);
const [canControl, setCanControl] = useState(false); const [canControl, setCanControl] = useState(false);
const [presenceKnown, setPresenceKnown] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const socketRef = useRef<WebSocket | null>(null); const socketRef = useRef<WebSocket | null>(null);
@@ -65,6 +66,7 @@ export function App() {
setPosition(payload.state.position); setPosition(payload.state.position);
setVoiceChannels(payload.voiceChannels); setVoiceChannels(payload.voiceChannels);
setYourVoiceChannel(payload.yourVoiceChannel); setYourVoiceChannel(payload.yourVoiceChannel);
setPresenceKnown(payload.voicePresenceKnown);
setCanControl(payload.canControl); setCanControl(payload.canControl);
}, []); }, []);
@@ -93,13 +95,19 @@ export function App() {
const payload = JSON.parse(event.data as string) as const payload = JSON.parse(event.data as string) as
| { type: "state"; state: PlayerState } | { type: "state"; state: PlayerState }
| { type: "position"; position: number } | { type: "position"; position: number }
| { type: "presence"; yourVoiceChannel: VoiceChannel | null; voiceChannels: VoiceChannel[] }; | {
type: "presence";
yourVoiceChannel: VoiceChannel | null;
voiceChannels: VoiceChannel[];
voicePresenceKnown: boolean;
};
if (payload.type === "state") { if (payload.type === "state") {
setState(payload.state); setState(payload.state);
setPosition(payload.state.position); setPosition(payload.state.position);
} else if (payload.type === "presence") { } else if (payload.type === "presence") {
setYourVoiceChannel(payload.yourVoiceChannel); setYourVoiceChannel(payload.yourVoiceChannel);
setVoiceChannels(payload.voiceChannels); setVoiceChannels(payload.voiceChannels);
setPresenceKnown(payload.voicePresenceKnown);
} else { } else {
setPosition(payload.position); setPosition(payload.position);
} }
@@ -182,7 +190,7 @@ export function App() {
canControl={canControl} canControl={canControl}
voiceChannels={voiceChannels} voiceChannels={voiceChannels}
yourVoiceChannel={yourVoiceChannel} yourVoiceChannel={yourVoiceChannel}
requireListener={me.features.requireListener} requireListener={me.features.requireListener && presenceKnown}
videoAvailable={me.features.video} videoAvailable={me.features.video}
onAction={(action, payload) => void runAction(action, payload)} onAction={(action, payload) => void runAction(action, payload)}
onSeek={(seconds) => void runAction("seek", { position: seconds })} onSeek={(seconds) => void runAction("seek", { position: seconds })}
+2
View File
@@ -51,5 +51,7 @@ export interface ServerStateResponse {
state: PlayerState; state: PlayerState;
voiceChannels: VoiceChannel[]; voiceChannels: VoiceChannel[];
yourVoiceChannel: VoiceChannel | null; yourVoiceChannel: VoiceChannel | null;
/** False when the bot cannot see who is in voice at all — see the picker fallback. */
voicePresenceKnown: boolean;
canControl: boolean; canControl: boolean;
} }