Surface Stoat's voice errors and stop leaving orphan connections
Joining a channel failed silently: revoice's join() runs an async executor inside `new Promise`, so a rejected join_call never reaches reject() — the promise hangs forever and the real error escapes as an unhandled rejection. The client wrapper now latches API failures and settles the join itself, translating Stoat's error codes (AlreadyConnected, LiveKitUnavailable, UnknownNode, ...) into messages the chat can show. A failed join also used to leave the connection object alive, which kept the bot registered in the channel and made the next attempt fail with AlreadyConnected; it is now destroyed on any failure. The LiveKit node name is configurable via VOICE_NODE for instances that renamed it, and the README documents how to clear a stuck voice state from Redis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d4880e757e
commit
823a9f1565
+81
-79
@@ -1,79 +1,81 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { z } from "zod";
|
||||
|
||||
// Minimal .env loader so we don't need an extra dependency.
|
||||
function loadDotEnv(path = ".env"): void {
|
||||
if (!existsSync(path)) return;
|
||||
for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadDotEnv();
|
||||
|
||||
const schema = z.object({
|
||||
STOAT_API_URL: z.string().url(),
|
||||
STOAT_BOT_TOKEN: z.string().min(1),
|
||||
COMMAND_PREFIX: z.string().min(1).default("!"),
|
||||
/** Remove the invoking message after a command is recognised. Needs ManageMessages. */
|
||||
DELETE_COMMAND_MESSAGES: z
|
||||
.enum(["true", "false"])
|
||||
.default("true")
|
||||
.transform((value) => value === "true"),
|
||||
|
||||
PORT: z.coerce.number().int().positive().default(3005),
|
||||
HOST: z.string().default("0.0.0.0"),
|
||||
PUBLIC_URL: z.string().url(),
|
||||
JWT_SECRET: z.string().min(16),
|
||||
SESSION_TTL_HOURS: z.coerce.number().positive().default(168),
|
||||
|
||||
YTDLP_PATH: z.string().default("yt-dlp"),
|
||||
YTDLP_COOKIES: z.string().optional(),
|
||||
/** Extra `--extractor-args` values, separated by ";" — e.g. youtube:player_client=default,web_safari */
|
||||
YTDLP_EXTRACTOR_ARGS: z.string().optional(),
|
||||
LOCAL_MEDIA_DIR: z.string().optional(),
|
||||
|
||||
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
|
||||
MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500),
|
||||
SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10),
|
||||
IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(300),
|
||||
DJ_ROLE_NAME: z.string().default("DJ"),
|
||||
REQUIRE_DJ_ROLE: z
|
||||
.enum(["true", "false"])
|
||||
.default("false")
|
||||
.transform((value) => value === "true"),
|
||||
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
NODE_ENV: z.string().default("development"),
|
||||
});
|
||||
|
||||
const parsed = schema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n");
|
||||
console.error(`Invalid configuration, check your .env file:\n${issues}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
...parsed.data,
|
||||
STOAT_API_URL: parsed.data.STOAT_API_URL.replace(/\/+$/, ""),
|
||||
PUBLIC_URL: parsed.data.PUBLIC_URL.replace(/\/+$/, ""),
|
||||
isProduction: parsed.data.NODE_ENV === "production",
|
||||
};
|
||||
|
||||
export type Config = typeof config;
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { z } from "zod";
|
||||
|
||||
// Minimal .env loader so we don't need an extra dependency.
|
||||
function loadDotEnv(path = ".env"): void {
|
||||
if (!existsSync(path)) return;
|
||||
for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadDotEnv();
|
||||
|
||||
const schema = z.object({
|
||||
STOAT_API_URL: z.string().url(),
|
||||
STOAT_BOT_TOKEN: z.string().min(1),
|
||||
COMMAND_PREFIX: z.string().min(1).default("!"),
|
||||
/** LiveKit node name from Revolt.toml ([hosts.livekit]); self-hosted default is "worldwide". */
|
||||
VOICE_NODE: z.string().min(1).default("worldwide"),
|
||||
/** Remove the invoking message after a command is recognised. Needs ManageMessages. */
|
||||
DELETE_COMMAND_MESSAGES: z
|
||||
.enum(["true", "false"])
|
||||
.default("true")
|
||||
.transform((value) => value === "true"),
|
||||
|
||||
PORT: z.coerce.number().int().positive().default(3005),
|
||||
HOST: z.string().default("0.0.0.0"),
|
||||
PUBLIC_URL: z.string().url(),
|
||||
JWT_SECRET: z.string().min(16),
|
||||
SESSION_TTL_HOURS: z.coerce.number().positive().default(168),
|
||||
|
||||
YTDLP_PATH: z.string().default("yt-dlp"),
|
||||
YTDLP_COOKIES: z.string().optional(),
|
||||
/** Extra `--extractor-args` values, separated by ";" — e.g. youtube:player_client=default,web_safari */
|
||||
YTDLP_EXTRACTOR_ARGS: z.string().optional(),
|
||||
LOCAL_MEDIA_DIR: z.string().optional(),
|
||||
|
||||
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
|
||||
MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500),
|
||||
SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10),
|
||||
IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(300),
|
||||
DJ_ROLE_NAME: z.string().default("DJ"),
|
||||
REQUIRE_DJ_ROLE: z
|
||||
.enum(["true", "false"])
|
||||
.default("false")
|
||||
.transform((value) => value === "true"),
|
||||
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
NODE_ENV: z.string().default("development"),
|
||||
});
|
||||
|
||||
const parsed = schema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n");
|
||||
console.error(`Invalid configuration, check your .env file:\n${issues}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
...parsed.data,
|
||||
STOAT_API_URL: parsed.data.STOAT_API_URL.replace(/\/+$/, ""),
|
||||
PUBLIC_URL: parsed.data.PUBLIC_URL.replace(/\/+$/, ""),
|
||||
isProduction: parsed.data.NODE_ENV === "production",
|
||||
};
|
||||
|
||||
export type Config = typeof config;
|
||||
|
||||
Reference in New Issue
Block a user