Files
stoat-mbot/web/src/App.tsx
T
Leonid PershinandClaude Opus 5 224d5679a3 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>
2026-09-09 02:34:54 +03:00

239 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "./api";
import { Login } from "./components/Login";
import { NowPlaying } from "./components/NowPlaying";
import { QueueList } from "./components/QueueList";
import { SearchPanel } from "./components/SearchPanel";
import type { Me, PlayerState, ServerStateResponse, VoiceChannel } from "./types";
const SERVER_KEY = "mbot.server";
export function App() {
const [me, setMe] = useState<Me | null>(null);
const [loading, setLoading] = useState(true);
const [serverId, setServerId] = useState<string | null>(null);
const [state, setState] = useState<PlayerState | null>(null);
const [position, setPosition] = useState(0);
const [voiceChannels, setVoiceChannels] = useState<VoiceChannel[]>([]);
const [yourVoiceChannel, setYourVoiceChannel] = useState<VoiceChannel | null>(null);
const [canControl, setCanControl] = useState(false);
const [presenceKnown, setPresenceKnown] = useState(true);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<WebSocket | null>(null);
/** Consumes the one-time link issued by the `!panel` chat command. */
const consumeLinkToken = useCallback(async (): Promise<string | null> => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (!token) return null;
try {
const result = await api.loginWithLink(token);
window.history.replaceState({}, "", "/");
return result.serverId;
} catch (err) {
setError(err instanceof Error ? err.message : "Ссылка недействительна");
window.history.replaceState({}, "", "/");
return null;
}
}, []);
const loadMe = useCallback(
async (preferredServer?: string | null) => {
try {
const profile = await api.me();
setMe(profile);
const stored = preferredServer ?? localStorage.getItem(SERVER_KEY);
const chosen = profile.servers.find((server) => server.id === stored) ?? profile.servers[0];
setServerId(chosen?.id ?? null);
} catch {
setMe(null);
} finally {
setLoading(false);
}
},
[],
);
useEffect(() => {
void (async () => {
const fromLink = await consumeLinkToken();
await loadMe(fromLink);
})();
}, [consumeLinkToken, loadMe]);
const applyServerState = useCallback((payload: ServerStateResponse) => {
setState(payload.state);
setPosition(payload.state.position);
setVoiceChannels(payload.voiceChannels);
setYourVoiceChannel(payload.yourVoiceChannel);
setPresenceKnown(payload.voicePresenceKnown);
setCanControl(payload.canControl);
}, []);
const refresh = useCallback(
async (id: string) => {
try {
applyServerState(await api.state(id));
} catch (err) {
setError(err instanceof Error ? err.message : "Не удалось получить состояние");
}
},
[applyServerState],
);
// Live updates: the socket carries both full snapshots and 1 Hz position ticks.
useEffect(() => {
if (!serverId) return;
localStorage.setItem(SERVER_KEY, serverId);
void refresh(serverId);
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${protocol}://${window.location.host}/ws?server=${serverId}`);
socketRef.current = socket;
socket.onmessage = (event) => {
const payload = JSON.parse(event.data as string) as
| { type: "state"; state: PlayerState }
| { type: "position"; position: number }
| {
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);
}
};
return () => {
socket.close();
socketRef.current = null;
};
}, [serverId, refresh]);
const runAction = useCallback(
async (action: string, payload: Record<string, unknown> = {}) => {
if (!serverId) return;
setError(null);
try {
await api.action(serverId, action, payload);
} catch (err) {
setError(err instanceof Error ? err.message : "Действие не выполнено");
}
},
[serverId],
);
if (loading) return <div className="login-wrap">Загрузка…</div>;
if (!me) return <Login onSuccess={() => void loadMe()} />;
return (
<div className="app">
<header className="topbar">
<div className="brand">
<span className="dot" />
Stoat Music
</div>
{me.servers.length > 0 && (
<select value={serverId ?? ""} onChange={(event) => setServerId(event.target.value)}>
{me.servers.map((server) => (
<option key={server.id} value={server.id}>
{server.name}
</option>
))}
</select>
)}
<div className="spacer" />
<span className="who">{me.user.username}</span>
<button
className="ghost"
onClick={async () => {
await api.logout();
setMe(null);
}}
>
Выйти
</button>
</header>
{error && <div className="error">{error}</div>}
{!serverId || !state ? (
<div className="card empty">
Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу.
</div>
) : (
<div className="layout">
<div>
<SearchPanel
serverId={serverId}
canControl={canControl}
localLibrary={me.features.localLibrary}
onError={setError}
/>
</div>
<div>
<NowPlaying
state={state}
position={position}
canControl={canControl}
voiceChannels={voiceChannels}
yourVoiceChannel={yourVoiceChannel}
requireListener={me.features.requireListener && presenceKnown}
videoAvailable={me.features.video}
onAction={(action, payload) => void runAction(action, payload)}
onSeek={(seconds) => void runAction("seek", { position: seconds })}
onJoin={(channelId) => {
void api
.join(serverId, channelId)
.then(() => refresh(serverId))
.catch((err: Error) => setError(err.message));
}}
/>
<QueueList
tracks={state.queue}
canControl={canControl}
onRemove={(track) => {
void api.removeTrack(serverId, track.id).catch((err: Error) => setError(err.message));
}}
onMove={(track, index) => {
void api.moveTrack(serverId, track.id, index).catch((err: Error) => setError(err.message));
}}
onClear={() => void runAction("clear")}
/>
{state.history.length > 0 && (
<div className="card">
<h2>История</h2>
<ul className="track-list">
{state.history.map((track, index) => (
<li className="track" key={`${track.id}-${index}`}>
<span className="idx">{index + 1}</span>
<div className="info">
<div className="title">{track.title}</div>
<div className="sub">{track.author ?? ""}</div>
</div>
</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
</div>
);
}