diff --git a/scripts/smoke-api.mjs b/scripts/smoke-api.mjs index 175f4f9..4e73feb 100644 --- a/scripts/smoke-api.mjs +++ b/scripts/smoke-api.mjs @@ -17,6 +17,7 @@ const context = { getVoiceChannel: (id) => ({ id, name: "General" }), listVoiceChannels: () => [{ id: "vc1", name: "General" }], findUserVoiceChannel: () => ({ id: "vc1", name: "General" }), + hasVoicePresence: () => true, listServersForUser: async () => [{ id: "srv1", name: "Test Server", iconUrl: null }], isMember: async (s, u) => s === "srv1" && u === "user1", canControl: async () => true, diff --git a/src/api/server.ts b/src/api/server.ts index c120a2a..f182ee3 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -203,6 +203,7 @@ export async function startApiServer({ manager, context }: ApiServerOptions) { state: manager.snapshot(id), voiceChannels: context.listVoiceChannels(id), yourVoiceChannel: context.findUserVoiceChannel(id, session.userId), + voicePresenceKnown: context.hasVoicePresence(id), canControl: await context.canControl(id, session.userId), }); }); @@ -358,10 +359,13 @@ export async function startApiServer({ manager, context }: ApiServerOptions) { const sendPresence = () => { const yourVoiceChannel = context.findUserVoiceChannel(serverId, session.userId); 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; lastPresence = fingerprint; - socket.send(JSON.stringify({ type: "presence", yourVoiceChannel, voiceChannels })); + socket.send( + JSON.stringify({ type: "presence", yourVoiceChannel, voiceChannels, voicePresenceKnown }), + ); }; sendPresence(); const presenceTimer = setInterval(sendPresence, 3000); diff --git a/src/bot/context.ts b/src/bot/context.ts index b2d7c51..b497025 100644 --- a/src/bot/context.ts +++ b/src/bot/context.ts @@ -37,6 +37,18 @@ export class BotStoatContext implements StoatContext { .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 { const server = this.client.servers.get(serverId); if (!server) return null; diff --git a/src/core/manager.ts b/src/core/manager.ts index c75dac3..4cee567 100644 --- a/src/core/manager.ts +++ b/src/core/manager.ts @@ -32,6 +32,7 @@ export interface ServerRef { */ export interface StoatContext { findUserVoiceChannel(serverId: string, userId: string): VoiceChannelRef | null; + hasVoicePresence(serverId: string): boolean; getVoiceChannel(channelId: string): VoiceChannelRef | null; listVoiceChannels(serverId: string): VoiceChannelRef[]; getServerName(serverId: string): string | null; @@ -143,16 +144,28 @@ export class MusicManager extends EventEmitter { 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 - // are not sitting in, and you cannot start playback from nowhere. - if (!listening) throw new UserFacingError("Сначала зайдите в голосовой канал"); + // are not sitting in. if (options.voiceChannelId && options.voiceChannelId !== listening.id) { 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 : (options.voiceChannelId ? this.chat.getVoiceChannel(options.voiceChannelId) diff --git a/web/src/App.tsx b/web/src/App.tsx index 2252488..8dc0d42 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,6 +17,7 @@ export function App() { const [voiceChannels, setVoiceChannels] = useState([]); const [yourVoiceChannel, setYourVoiceChannel] = useState(null); const [canControl, setCanControl] = useState(false); + const [presenceKnown, setPresenceKnown] = useState(true); const [error, setError] = useState(null); const socketRef = useRef(null); @@ -65,6 +66,7 @@ export function App() { setPosition(payload.state.position); setVoiceChannels(payload.voiceChannels); setYourVoiceChannel(payload.yourVoiceChannel); + setPresenceKnown(payload.voicePresenceKnown); setCanControl(payload.canControl); }, []); @@ -93,13 +95,19 @@ export function App() { const payload = JSON.parse(event.data as string) as | { type: "state"; state: PlayerState } | { type: "position"; position: number } - | { type: "presence"; yourVoiceChannel: VoiceChannel | null; voiceChannels: VoiceChannel[] }; + | { + type: "presence"; + yourVoiceChannel: VoiceChannel | null; + voiceChannels: VoiceChannel[]; + voicePresenceKnown: boolean; + }; if (payload.type === "state") { setState(payload.state); setPosition(payload.state.position); } else if (payload.type === "presence") { setYourVoiceChannel(payload.yourVoiceChannel); setVoiceChannels(payload.voiceChannels); + setPresenceKnown(payload.voicePresenceKnown); } else { setPosition(payload.position); } @@ -182,7 +190,7 @@ export function App() { canControl={canControl} voiceChannels={voiceChannels} yourVoiceChannel={yourVoiceChannel} - requireListener={me.features.requireListener} + requireListener={me.features.requireListener && presenceKnown} videoAvailable={me.features.video} onAction={(action, payload) => void runAction(action, payload)} onSeek={(seconds) => void runAction("seek", { position: seconds })} diff --git a/web/src/types.ts b/web/src/types.ts index c6ca1e8..8eb9a2c 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -51,5 +51,7 @@ export interface ServerStateResponse { state: PlayerState; voiceChannels: VoiceChannel[]; yourVoiceChannel: VoiceChannel | null; + /** False when the bot cannot see who is in voice at all — see the picker fallback. */ + voicePresenceKnown: boolean; canControl: boolean; }