Add music bot for self-hosted Stoat with web control panel
Plays audio into Stoat voice channels over LiveKit and exposes the same player through both chat commands and a browser panel, so the two never drift apart: everything routes through a single MusicManager. - core: per-server GuildPlayer (queue, loop, shuffle, seek, volume, idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg - sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet radio, optional local library with path-traversal guards - bot: 18 chat commands with aliases, plus !panel one-time login links - api: Fastify REST + WebSocket, sessions authenticated against the instance's own /auth/session/login (TOTP supported), permissions re-checked against Stoat membership and roles on every request - web: React panel with search, queue editing, seek and volume - deploy: Dockerfile, compose.override.yml and Caddyfile snippets for dropping the service into an existing /opt/stoat stack Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs (9 API checks). Voice playback itself needs a live instance to test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9d0e9f6bf
commit
a9b7ccdd16
+223
@@ -0,0 +1,223 @@
|
||||
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 [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);
|
||||
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 };
|
||||
if (payload.type === "state") {
|
||||
setState(payload.state);
|
||||
setPosition(payload.state.position);
|
||||
} 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}
|
||||
/>
|
||||
<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")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NowPlaying
|
||||
state={state}
|
||||
position={position}
|
||||
canControl={canControl}
|
||||
voiceChannels={voiceChannels}
|
||||
yourVoiceChannel={yourVoiceChannel}
|
||||
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));
|
||||
}}
|
||||
/>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user