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>
54 lines
1.6 KiB
TypeScript
54 lines
1.6 KiB
TypeScript
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;
|
|
}
|
|
}
|