Files
Leonid PershinandClaude Opus 5 224d5679a3 Stop the listener rule from locking people out on stale presence
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>
2026-09-09 02:34:54 +03:00

107 lines
4.2 KiB
JavaScript

// Smoke test: boots the API layer with a stubbed Stoat context and exercises
// the auth flow + a couple of routes. No real Stoat instance involved.
process.env.STOAT_API_URL = "http://127.0.0.1:9/api";
process.env.STOAT_BOT_TOKEN = "test-token";
process.env.PUBLIC_URL = "http://127.0.0.1:3199";
process.env.JWT_SECRET = "0123456789abcdef0123456789abcdef";
process.env.PORT = "3199";
process.env.LOG_LEVEL = "warn";
process.env.NODE_ENV = "production";
const { startApiServer } = await import("../dist/api/server.js");
const { MusicManager } = await import("../dist/core/manager.js");
const { signPanelLink } = await import("../dist/auth/tokens.js");
const context = {
getServerName: () => "Test Server",
getVoiceChannel: (id) => ({ id, name: "General" }),
listVoiceChannels: () => [{ id: "vc1", name: "General" }],
findUserVoiceChannel: () => ({ id: "vc1", name: "General" }),
hasVoicePresence: () => true,
listServersForUser: async () => [{ id: "srv1", name: "Test Server", iconUrl: null }],
isMember: async (s, u) => s === "srv1" && u === "user1",
canControl: async () => true,
sendMessage: async () => {},
};
const manager = new MusicManager();
manager.attachStoat(context);
const app = await startApiServer({ manager, context });
const base = "http://127.0.0.1:3199";
const results = [];
const check = (name, ok, detail = "") => results.push({ name, ok, detail });
// 1. Unauthenticated access is rejected.
let res = await fetch(`${base}/api/me`);
check("GET /api/me without cookie → 401", res.status === 401, `got ${res.status}`);
// 2. One-time chat link exchanges for a session cookie.
const link = await signPanelLink("user1", "tester", "srv1");
res = await fetch(`${base}/api/auth/link`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: link }),
});
const linkBody = await res.json();
const cookie = (res.headers.getSetCookie?.() ?? [])[0]?.split(";")[0] ?? "";
check("POST /api/auth/link → session", res.ok && linkBody.serverId === "srv1" && cookie.length > 0, JSON.stringify(linkBody));
// 3. Authenticated profile lists the servers we share with the bot.
res = await fetch(`${base}/api/me`, { headers: { cookie } });
const me = await res.json();
check("GET /api/me with cookie", res.ok && me.servers?.[0]?.id === "srv1", JSON.stringify(me));
// 4. Player state for a server the user belongs to.
res = await fetch(`${base}/api/servers/srv1/state`, { headers: { cookie } });
const state = await res.json();
check(
"GET state",
res.ok && state.state.status === "idle" && state.voiceChannels.length === 1,
JSON.stringify(state).slice(0, 160),
);
// 5. A server the user is not a member of stays hidden.
res = await fetch(`${base}/api/servers/other/state`, { headers: { cookie } });
check("GET state for foreign server → 403", res.status === 403, `got ${res.status}`);
// 6. An expired/garbage link is refused.
res = await fetch(`${base}/api/auth/link`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: "not-a-jwt" }),
});
check("POST /api/auth/link with junk → 400", res.status === 400, `got ${res.status}`);
// 7. Actions on a server with no player report a clear error, not a crash.
res = await fetch(`${base}/api/servers/srv1/actions/pause`, {
method: "POST",
headers: { "content-type": "application/json", cookie },
body: "{}",
});
const pause = await res.json();
check("POST pause without player → 400", res.status === 400 && typeof pause.error === "string", JSON.stringify(pause));
// 8. Unknown action.
res = await fetch(`${base}/api/servers/srv1/actions/explode`, {
method: "POST",
headers: { "content-type": "application/json", cookie },
body: "{}",
});
check("POST unknown action → 404", res.status === 404, `got ${res.status}`);
// 9. Static panel bundle is served.
res = await fetch(`${base}/`);
const html = await res.text();
check("GET / serves panel", res.ok && html.includes("<div id=\"root\">"), `status ${res.status}`);
await app.close();
let failed = 0;
for (const r of results) {
if (!r.ok) failed += 1;
console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name}${r.ok ? "" : ` — ${r.detail}`}`);
}
console.log(failed === 0 ? "\nAll checks passed" : `\n${failed} check(s) failed`);
process.exit(failed === 0 ? 0 : 1);