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
@@ -0,0 +1,53 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { config } from "../config.js";
|
||||
|
||||
const secret = new TextEncoder().encode(config.JWT_SECRET);
|
||||
const ISSUER = "stoat-mbot";
|
||||
|
||||
export interface SessionClaims {
|
||||
sub: string;
|
||||
username: string;
|
||||
/** "session" for the panel cookie, "link" for one-time links from chat. */
|
||||
typ: "session" | "link";
|
||||
/** Present on chat links: the server the link was issued for. */
|
||||
srv?: string;
|
||||
}
|
||||
|
||||
export async function signSession(userId: string, username: string): Promise<string> {
|
||||
return new SignJWT({ username, typ: "session" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(userId)
|
||||
.setIssuer(ISSUER)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${config.SESSION_TTL_HOURS}h`)
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
export async function signPanelLink(
|
||||
userId: string,
|
||||
username: string,
|
||||
serverId: string,
|
||||
): Promise<string> {
|
||||
return new SignJWT({ username, typ: "link", srv: serverId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(userId)
|
||||
.setIssuer(ISSUER)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("10m")
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<SessionClaims | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, secret, { issuer: ISSUER });
|
||||
if (!payload.sub || (payload["typ"] !== "session" && payload["typ"] !== "link")) return null;
|
||||
return {
|
||||
sub: payload.sub,
|
||||
username: String(payload["username"] ?? "unknown"),
|
||||
typ: payload["typ"] as "session" | "link",
|
||||
srv: typeof payload["srv"] === "string" ? payload["srv"] : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user