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>
149 lines
4.8 KiB
TypeScript
149 lines
4.8 KiB
TypeScript
import { config } from "../config.js";
|
|
import { logger } from "../logger.js";
|
|
import { UserFacingError } from "../types.js";
|
|
|
|
const log = logger.child({ mod: "stoat-rest" });
|
|
|
|
export interface StoatUser {
|
|
_id: string;
|
|
username: string;
|
|
display_name?: string | null;
|
|
discriminator?: string;
|
|
avatar?: { _id: string } | null;
|
|
bot?: { owner: string } | null;
|
|
}
|
|
|
|
export interface StoatMember {
|
|
_id: { server: string; user: string };
|
|
nickname?: string | null;
|
|
roles?: string[] | null;
|
|
}
|
|
|
|
export interface StoatServer {
|
|
_id: string;
|
|
name: string;
|
|
owner: string;
|
|
roles?: Record<string, { name: string; rank?: number }> | null;
|
|
}
|
|
|
|
export type LoginResult =
|
|
| { kind: "success"; token: string; userId: string }
|
|
| { kind: "mfa"; ticket: string; methods: string[] };
|
|
|
|
async function request<T>(
|
|
path: string,
|
|
init: RequestInit & { token?: { type: "bot" | "session"; value: string } } = {},
|
|
): Promise<T> {
|
|
const { token, headers, ...rest } = init;
|
|
const finalHeaders: Record<string, string> = {
|
|
accept: "application/json",
|
|
...(headers as Record<string, string> | undefined),
|
|
};
|
|
if (token?.type === "bot") finalHeaders["x-bot-token"] = token.value;
|
|
if (token?.type === "session") finalHeaders["x-session-token"] = token.value;
|
|
if (rest.body && !finalHeaders["content-type"]) finalHeaders["content-type"] = "application/json";
|
|
|
|
const res = await fetch(`${config.STOAT_API_URL}${path}`, { ...rest, headers: finalHeaders });
|
|
const text = await res.text();
|
|
const body = text ? (JSON.parse(text) as unknown) : null;
|
|
|
|
if (!res.ok) {
|
|
log.debug({ path, status: res.status, body }, "stoat api error");
|
|
const type = (body as { type?: string } | null)?.type;
|
|
throw new StoatApiError(res.status, type ?? `HTTP ${res.status}`);
|
|
}
|
|
return body as T;
|
|
}
|
|
|
|
export class StoatApiError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly type: string,
|
|
) {
|
|
super(`Stoat API error: ${type}`);
|
|
this.name = "StoatApiError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authenticates a panel user against the very same accounts the Stoat instance
|
|
* uses. We never see or store password material: the credentials go straight to
|
|
* the instance's auth endpoint and the short-lived session is revoked as soon as
|
|
* we have confirmed who the user is.
|
|
*/
|
|
export async function loginWithPassword(
|
|
email: string,
|
|
password: string,
|
|
mfa?: { ticket: string; totpCode?: string; recoveryCode?: string },
|
|
): Promise<LoginResult> {
|
|
const body: Record<string, unknown> = { friendly_name: "stoat-mbot panel" };
|
|
if (mfa) {
|
|
body["mfa_ticket"] = mfa.ticket;
|
|
body["mfa_response"] = mfa.totpCode
|
|
? { totp_code: mfa.totpCode }
|
|
: { recovery_code: mfa.recoveryCode };
|
|
} else {
|
|
body["email"] = email;
|
|
body["password"] = password;
|
|
}
|
|
|
|
let response: { result: string; token?: string; user_id?: string; ticket?: string; allowed_methods?: string[] };
|
|
try {
|
|
response = await request("/auth/session/login", { method: "POST", body: JSON.stringify(body) });
|
|
} catch (err) {
|
|
if (err instanceof StoatApiError && (err.status === 401 || err.status === 400)) {
|
|
throw new UserFacingError("Неверный логин или пароль");
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
if (response.result === "MFA") {
|
|
return {
|
|
kind: "mfa",
|
|
ticket: response.ticket ?? "",
|
|
methods: response.allowed_methods ?? [],
|
|
};
|
|
}
|
|
if (response.result === "Disabled") throw new UserFacingError("Аккаунт отключён");
|
|
if (!response.token || !response.user_id) throw new UserFacingError("Неожиданный ответ сервера авторизации");
|
|
return { kind: "success", token: response.token, userId: response.user_id };
|
|
}
|
|
|
|
export function fetchSelf(sessionToken: string): Promise<StoatUser> {
|
|
return request<StoatUser>("/users/@me", { token: { type: "session", value: sessionToken } });
|
|
}
|
|
|
|
export async function revokeSession(sessionToken: string): Promise<void> {
|
|
try {
|
|
await request("/auth/session/logout", {
|
|
method: "POST",
|
|
token: { type: "session", value: sessionToken },
|
|
});
|
|
} catch (err) {
|
|
log.warn({ err }, "could not revoke temporary session");
|
|
}
|
|
}
|
|
|
|
export function fetchUser(userId: string): Promise<StoatUser> {
|
|
return request<StoatUser>(`/users/${userId}`, {
|
|
token: { type: "bot", value: config.STOAT_BOT_TOKEN },
|
|
});
|
|
}
|
|
|
|
export function fetchServer(serverId: string): Promise<StoatServer> {
|
|
return request<StoatServer>(`/servers/${serverId}`, {
|
|
token: { type: "bot", value: config.STOAT_BOT_TOKEN },
|
|
});
|
|
}
|
|
|
|
export async function fetchMember(serverId: string, userId: string): Promise<StoatMember | null> {
|
|
try {
|
|
return await request<StoatMember>(`/servers/${serverId}/members/${userId}`, {
|
|
token: { type: "bot", value: config.STOAT_BOT_TOKEN },
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof StoatApiError && err.status === 404) return null;
|
|
throw err;
|
|
}
|
|
}
|