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>
239 lines
8.3 KiB
TypeScript
239 lines
8.3 KiB
TypeScript
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>
|
||
);
|
||
}
|