mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
nettoyage du code
This commit is contained in:
@@ -10,12 +10,21 @@ const LOCK_CLASS_ID = 7813;
|
||||
const LOCK_OBJECT_ID = 4312;
|
||||
|
||||
const parseBoolean = (value, fallback = false) => {
|
||||
if (value === undefined || value === null || String(value).trim().length === 0) {
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
String(value).trim().length === 0
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
return (
|
||||
normalized === "true" ||
|
||||
normalized === "1" ||
|
||||
normalized === "yes" ||
|
||||
normalized === "on"
|
||||
);
|
||||
};
|
||||
|
||||
const sha256 = (content) => createHash("sha256").update(content).digest("hex");
|
||||
@@ -33,7 +42,9 @@ const loadMigrations = async (migrationsDir) => {
|
||||
const files = await listMigrationFiles(migrationsDir);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error(`[migrate] no SQL migration files found in ${migrationsDir}`);
|
||||
throw new Error(
|
||||
`[migrate] no SQL migration files found in ${migrationsDir}`,
|
||||
);
|
||||
}
|
||||
|
||||
const migrations = [];
|
||||
@@ -63,9 +74,13 @@ const ensureMigrationTable = async (client) => {
|
||||
};
|
||||
|
||||
const applyMigrations = async (client, migrations) => {
|
||||
const applied = await client.query("SELECT version, checksum FROM schema_migrations ORDER BY version ASC");
|
||||
const applied = await client.query(
|
||||
"SELECT version, checksum FROM schema_migrations ORDER BY version ASC",
|
||||
);
|
||||
|
||||
const appliedByVersion = new Map(applied.rows.map((row) => [String(row.version), String(row.checksum)]));
|
||||
const appliedByVersion = new Map(
|
||||
applied.rows.map((row) => [String(row.version), String(row.checksum)]),
|
||||
);
|
||||
|
||||
for (const migration of migrations) {
|
||||
const existingChecksum = appliedByVersion.get(migration.fileName);
|
||||
@@ -91,7 +106,9 @@ const applyMigrations = async (client, migrations) => {
|
||||
console.log(`[migrate] applied ${migration.fileName}`);
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw new Error(`[migrate] failed while applying ${migration.fileName}`, { cause: error });
|
||||
throw new Error(`[migrate] failed while applying ${migration.fileName}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -111,8 +128,8 @@ const main = async () => {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: parseBoolean(process.env.DATABASE_SSL, false)
|
||||
? {
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -121,12 +138,20 @@ const main = async () => {
|
||||
try {
|
||||
const migrations = await loadMigrations(migrationsDir);
|
||||
|
||||
await client.query("SELECT pg_advisory_lock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]);
|
||||
await client.query("SELECT pg_advisory_lock($1, $2)", [
|
||||
LOCK_CLASS_ID,
|
||||
LOCK_OBJECT_ID,
|
||||
]);
|
||||
await ensureMigrationTable(client);
|
||||
await applyMigrations(client, migrations);
|
||||
console.log("[migrate] completed");
|
||||
} finally {
|
||||
await client.query("SELECT pg_advisory_unlock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]).catch(() => undefined);
|
||||
await client
|
||||
.query("SELECT pg_advisory_unlock($1, $2)", [
|
||||
LOCK_CLASS_ID,
|
||||
LOCK_OBJECT_ID,
|
||||
])
|
||||
.catch(() => undefined);
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
@@ -53,7 +53,10 @@ export const buildDiscordLoginUrl = (state: string): string => {
|
||||
return `https://discord.com/oauth2/authorize?${params.toString()}`;
|
||||
};
|
||||
|
||||
const createAvatarUrl = (discordUserId: string, avatarHash: string | null): string | null => {
|
||||
const createAvatarUrl = (
|
||||
discordUserId: string,
|
||||
avatarHash: string | null,
|
||||
): string | null => {
|
||||
if (!avatarHash) {
|
||||
return null;
|
||||
}
|
||||
@@ -61,7 +64,9 @@ const createAvatarUrl = (discordUserId: string, avatarHash: string | null): stri
|
||||
return `https://cdn.discordapp.com/avatars/${discordUserId}/${avatarHash}.png`;
|
||||
};
|
||||
|
||||
export const exchangeCodeForDiscordIdentity = async (code: string): Promise<DiscordIdentity> => {
|
||||
export const exchangeCodeForDiscordIdentity = async (
|
||||
code: string,
|
||||
): Promise<DiscordIdentity> => {
|
||||
const payload = new URLSearchParams({
|
||||
client_id: env.DISCORD_CLIENT_ID,
|
||||
client_secret: env.DISCORD_CLIENT_SECRET,
|
||||
@@ -103,7 +108,9 @@ export const exchangeCodeForDiscordIdentity = async (code: string): Promise<Disc
|
||||
};
|
||||
};
|
||||
|
||||
export const validateDiscordBotToken = async (botToken: string): Promise<DiscordBotIdentity> => {
|
||||
export const validateDiscordBotToken = async (
|
||||
botToken: string,
|
||||
): Promise<DiscordBotIdentity> => {
|
||||
const response = await fetch(`${DISCORD_API_BASE}/users/@me`, {
|
||||
headers: {
|
||||
Authorization: `Bot ${botToken}`,
|
||||
|
||||
@@ -12,7 +12,9 @@ interface SessionJwtPayload {
|
||||
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET);
|
||||
|
||||
export const issueSessionToken = async (session: AuthSession): Promise<string> => {
|
||||
export const issueSessionToken = async (
|
||||
session: AuthSession,
|
||||
): Promise<string> => {
|
||||
return new SignJWT({
|
||||
tenantId: session.tenantId,
|
||||
discordUserId: session.discordUserId,
|
||||
@@ -25,7 +27,9 @@ export const issueSessionToken = async (session: AuthSession): Promise<string> =
|
||||
.sign(secret);
|
||||
};
|
||||
|
||||
export const verifySessionToken = async (token: string): Promise<AuthSession> => {
|
||||
export const verifySessionToken = async (
|
||||
token: string,
|
||||
): Promise<AuthSession> => {
|
||||
const verified = await jwtVerify<SessionJwtPayload>(token, secret, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
|
||||
@@ -5,18 +5,27 @@ loadEnv();
|
||||
|
||||
const parseBoolean = (value: string): boolean => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
return (
|
||||
normalized === "true" ||
|
||||
normalized === "1" ||
|
||||
normalized === "yes" ||
|
||||
normalized === "on"
|
||||
);
|
||||
};
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(4000),
|
||||
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
|
||||
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
|
||||
REDIS_URL: z.string().url("REDIS_URL must be a valid URL"),
|
||||
DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"),
|
||||
DISCORD_CLIENT_SECRET: z.string().min(1, "DISCORD_CLIENT_SECRET is required"),
|
||||
DISCORD_REDIRECT_URI: z.string().url("DISCORD_REDIRECT_URI must be a valid URL"),
|
||||
DISCORD_REDIRECT_URI: z
|
||||
.string()
|
||||
.url("DISCORD_REDIRECT_URI must be a valid URL"),
|
||||
WEB_URL: z.string().url("WEB_URL must be a valid URL"),
|
||||
API_BASE_URL: z.string().url("API_BASE_URL must be a valid URL"),
|
||||
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
|
||||
@@ -25,7 +34,11 @@ const envSchema = z.object({
|
||||
COOKIE_SECURE: z.string().default("false").transform(parseBoolean),
|
||||
TOKEN_ENCRYPTION_KEY: z.string().min(10, "TOKEN_ENCRYPTION_KEY is required"),
|
||||
TENANT_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(120),
|
||||
TENANT_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(60),
|
||||
TENANT_RATE_LIMIT_WINDOW_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(60),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
@@ -7,8 +7,8 @@ export const createPgPool = (): Pool => {
|
||||
connectionString: env.DATABASE_URL,
|
||||
ssl: env.DATABASE_SSL
|
||||
? {
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -151,7 +151,10 @@ export const getUserByIdAndTenant = async (
|
||||
return mapUser(result.rows[0] as Record<string, unknown>);
|
||||
};
|
||||
|
||||
export const listBotsForTenant = async (pool: Pool, tenantId: string): Promise<PublicBot[]> => {
|
||||
export const listBotsForTenant = async (
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
): Promise<PublicBot[]> => {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
|
||||
@@ -206,7 +209,10 @@ export const createOrUpdateBotForTenant = async (
|
||||
[input.discordBotId],
|
||||
);
|
||||
|
||||
if (existingByDiscordBot.rows[0] && existingByDiscordBot.rows[0].tenant_id !== input.tenantId) {
|
||||
if (
|
||||
existingByDiscordBot.rows[0] &&
|
||||
existingByDiscordBot.rows[0].tenant_id !== input.tenantId
|
||||
) {
|
||||
throw new Error("BOT_ALREADY_CLAIMED");
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,15 @@ const bootstrap = async (): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await getUserByIdAndTenant(pgPool, req.auth.userId, req.auth.tenantId);
|
||||
const user = await getUserByIdAndTenant(
|
||||
pgPool,
|
||||
req.auth.userId,
|
||||
req.auth.tenantId,
|
||||
);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: "Session does not match an existing user" });
|
||||
res
|
||||
.status(401)
|
||||
.json({ error: "Session does not match an existing user" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,12 +99,10 @@ const bootstrap = async (): Promise<void> => {
|
||||
);
|
||||
|
||||
const server = app.listen(env.PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[api] listening on :${env.PORT}`);
|
||||
});
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[api] shutdown requested (${signal})`);
|
||||
|
||||
server.close(async () => {
|
||||
@@ -119,7 +123,6 @@ const bootstrap = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[api] fatal startup error", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Router } from "express";
|
||||
import { Router as expressRouter } from "express";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
import { buildDiscordLoginUrl, createOauthState, exchangeCodeForDiscordIdentity } from "../auth/discordOAuth.js";
|
||||
import {
|
||||
buildDiscordLoginUrl,
|
||||
createOauthState,
|
||||
exchangeCodeForDiscordIdentity,
|
||||
} from "../auth/discordOAuth.js";
|
||||
import { issueSessionToken } from "../auth/jwt.js";
|
||||
import { env } from "../config/env.js";
|
||||
import { upsertUserFromDiscord } from "../db/repositories.js";
|
||||
@@ -46,7 +50,9 @@ export const createAuthRouter = ({ pool }: AuthRouterDependencies): Router => {
|
||||
const cookieState = req.cookies?.[OAUTH_STATE_COOKIE_NAME];
|
||||
|
||||
if (!code || !state || !cookieState || state !== cookieState) {
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, {
|
||||
path: "/auth/discord/callback",
|
||||
});
|
||||
res.redirect(`${env.WEB_URL}/login?error=oauth_state_invalid`);
|
||||
return;
|
||||
}
|
||||
@@ -66,11 +72,15 @@ export const createAuthRouter = ({ pool }: AuthRouterDependencies): Router => {
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, {
|
||||
path: "/auth/discord/callback",
|
||||
});
|
||||
res.cookie(env.SESSION_COOKIE_NAME, sessionToken, sessionCookieConfig);
|
||||
res.redirect(`${env.WEB_URL}/dashboard`);
|
||||
} catch {
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, {
|
||||
path: "/auth/discord/callback",
|
||||
});
|
||||
res.redirect(`${env.WEB_URL}/login?error=oauth_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,13 +50,23 @@ const queueBotAction = (
|
||||
return;
|
||||
}
|
||||
|
||||
const bot = await getBotForTenant(deps.pool, req.auth.tenantId, parsedParams.data.botId);
|
||||
const bot = await getBotForTenant(
|
||||
deps.pool,
|
||||
req.auth.tenantId,
|
||||
parsedParams.data.botId,
|
||||
);
|
||||
if (!bot) {
|
||||
res.status(404).json({ error: "Bot not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await setBotStatusForTenant(deps.pool, req.auth.tenantId, parsedParams.data.botId, statusDuringAction, null);
|
||||
await setBotStatusForTenant(
|
||||
deps.pool,
|
||||
req.auth.tenantId,
|
||||
parsedParams.data.botId,
|
||||
statusDuringAction,
|
||||
null,
|
||||
);
|
||||
|
||||
await insertBotRuntimeEvent(deps.pool, {
|
||||
tenantId: req.auth.tenantId,
|
||||
@@ -105,13 +115,18 @@ export const createBotRouter = (deps: BotRouterDependencies): Router => {
|
||||
|
||||
const parsedBody = addBotSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
res.status(400).json({ error: parsedBody.error.issues[0]?.message ?? "Invalid body" });
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: parsedBody.error.issues[0]?.message ?? "Invalid body" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const botIdentity = await validateDiscordBotToken(parsedBody.data.token);
|
||||
const encryptedToken = encryptToken(parsedBody.data.token, deps.tokenEncryptionKey);
|
||||
const encryptedToken = encryptToken(
|
||||
parsedBody.data.token,
|
||||
deps.tokenEncryptionKey,
|
||||
);
|
||||
|
||||
const bot = await createOrUpdateBotForTenant(deps.pool, {
|
||||
tenantId: req.auth.tenantId,
|
||||
@@ -135,11 +150,16 @@ export const createBotRouter = (deps: BotRouterDependencies): Router => {
|
||||
res.status(201).json({ bot });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "BOT_ALREADY_CLAIMED") {
|
||||
res.status(409).json({ error: "This Discord bot is already claimed by another tenant" });
|
||||
res.status(409).json({
|
||||
error: "This Discord bot is already claimed by another tenant",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === "Invalid Discord bot token") {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === "Invalid Discord bot token"
|
||||
) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
@@ -148,9 +168,21 @@ export const createBotRouter = (deps: BotRouterDependencies): Router => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/start", deps.tenantControlRateLimit, queueBotAction("start", "starting", deps));
|
||||
router.post("/:botId/stop", deps.tenantControlRateLimit, queueBotAction("stop", "stopping", deps));
|
||||
router.post("/:botId/restart", deps.tenantControlRateLimit, queueBotAction("restart", "starting", deps));
|
||||
router.post(
|
||||
"/:botId/start",
|
||||
deps.tenantControlRateLimit,
|
||||
queueBotAction("start", "starting", deps),
|
||||
);
|
||||
router.post(
|
||||
"/:botId/stop",
|
||||
deps.tenantControlRateLimit,
|
||||
queueBotAction("stop", "stopping", deps),
|
||||
);
|
||||
router.post(
|
||||
"/:botId/restart",
|
||||
deps.tenantControlRateLimit,
|
||||
queueBotAction("restart", "starting", deps),
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
"types": ["node"],
|
||||
"declaration": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviteur={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "zapraszający={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "convidador={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,4 +465,4 @@
|
||||
"inviter": "inviter={{value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,24 +6,27 @@ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BOT_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||
const SOURCE_DIR = path.join(BOT_DIR, "locales");
|
||||
const TARGET_DIRECTORIES = [
|
||||
path.join(BOT_DIR, "dist", "locales"),
|
||||
path.join(BOT_DIR, "dist", "legacy", "i18n"),
|
||||
path.join(BOT_DIR, "dist", "locales"),
|
||||
path.join(BOT_DIR, "dist", "legacy", "i18n"),
|
||||
];
|
||||
|
||||
if (!existsSync(SOURCE_DIR)) {
|
||||
throw new Error(`[i18n] source locale directory not found: ${SOURCE_DIR}`);
|
||||
throw new Error(`[i18n] source locale directory not found: ${SOURCE_DIR}`);
|
||||
}
|
||||
|
||||
for (const targetDirectory of TARGET_DIRECTORIES) {
|
||||
mkdirSync(targetDirectory, { recursive: true });
|
||||
mkdirSync(targetDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
for (const fileName of readdirSync(SOURCE_DIR)) {
|
||||
if (!fileName.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
if (!fileName.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const targetDirectory of TARGET_DIRECTORIES) {
|
||||
copyFileSync(path.join(SOURCE_DIR, fileName), path.join(targetDirectory, fileName));
|
||||
}
|
||||
}
|
||||
for (const targetDirectory of TARGET_DIRECTORIES) {
|
||||
copyFileSync(
|
||||
path.join(SOURCE_DIR, fileName),
|
||||
path.join(targetDirectory, fileName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ loadEnv();
|
||||
|
||||
const parseBoolean = (value: string): boolean => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
return (
|
||||
normalized === "true" ||
|
||||
normalized === "1" ||
|
||||
normalized === "yes" ||
|
||||
normalized === "on"
|
||||
);
|
||||
};
|
||||
|
||||
const optionalString = (value?: string): string | undefined => {
|
||||
@@ -33,7 +38,9 @@ const parseOptionalUrl = (value: unknown): string | undefined => {
|
||||
};
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(4100),
|
||||
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
|
||||
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
|
||||
@@ -58,7 +65,10 @@ const envSchema = z.object({
|
||||
.optional()
|
||||
.default("false")
|
||||
.transform(parseBoolean),
|
||||
REDIS_URL: z.preprocess(parseOptionalUrl, z.string().url("REDIS_URL must be a valid URL").optional()),
|
||||
REDIS_URL: z.preprocess(
|
||||
parseOptionalUrl,
|
||||
z.string().url("REDIS_URL must be a valid URL").optional(),
|
||||
),
|
||||
TOKEN_ENCRYPTION_KEY: z.string().min(10, "TOKEN_ENCRYPTION_KEY is required"),
|
||||
PRESENCE_STREAM_URL: z
|
||||
.string()
|
||||
@@ -67,7 +77,10 @@ const envSchema = z.object({
|
||||
.default("https://twitch.tv/discord"),
|
||||
PREFIX: z.string().min(1).max(5).default("+"),
|
||||
DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"),
|
||||
DEV_GUILD_ID: z.string().optional().transform((value) => value && value.length > 0 ? value : undefined),
|
||||
DEV_GUILD_ID: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value && value.length > 0 ? value : undefined)),
|
||||
AUTO_DEPLOY_SLASH: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -76,9 +89,21 @@ const envSchema = z.object({
|
||||
LOG_LEVEL: z.string().trim().min(1).default("info"),
|
||||
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
|
||||
COMMAND_DISPATCH_MODE: z.enum(["local", "worker"]).default("local"),
|
||||
COMMAND_QUEUE_NAME: z.string().trim().min(1).default("bot:${botId}:command-jobs"),
|
||||
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(20),
|
||||
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(10),
|
||||
COMMAND_QUEUE_NAME: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.default("bot:${botId}:command-jobs"),
|
||||
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(20),
|
||||
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(10),
|
||||
RATE_LIMIT_FAIL_OPEN: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -93,7 +118,11 @@ const envSchema = z.object({
|
||||
|
||||
const parsed = envSchema.parse(process.env);
|
||||
|
||||
if (parsed.DATABASE_SSL && !parsed.DATABASE_SSL_REJECT_UNAUTHORIZED && !parsed.ALLOW_INSECURE_DB_SSL) {
|
||||
if (
|
||||
parsed.DATABASE_SSL &&
|
||||
!parsed.DATABASE_SSL_REJECT_UNAUTHORIZED &&
|
||||
!parsed.ALLOW_INSECURE_DB_SSL
|
||||
) {
|
||||
throw new Error(
|
||||
"Insecure DATABASE_SSL configuration detected: rejectUnauthorized=false is blocked by default. Set DATABASE_SSL_REJECT_UNAUTHORIZED=true or explicitly set ALLOW_INSECURE_DB_SSL=true for non-production environments.",
|
||||
);
|
||||
|
||||
@@ -5,9 +5,9 @@ import { env } from "../config/env.js";
|
||||
export const createPgPool = (): Pool => {
|
||||
const ssl = env.DATABASE_SSL
|
||||
? {
|
||||
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
|
||||
ca: env.DATABASE_SSL_CA,
|
||||
}
|
||||
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
|
||||
ca: env.DATABASE_SSL_CA,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return new Pool({
|
||||
|
||||
@@ -13,7 +13,9 @@ export interface StoredBotCredentials {
|
||||
status: BotStatus;
|
||||
}
|
||||
|
||||
const mapBotCredentials = (row: Record<string, unknown>): StoredBotCredentials => {
|
||||
const mapBotCredentials = (
|
||||
row: Record<string, unknown>,
|
||||
): StoredBotCredentials => {
|
||||
return {
|
||||
id: String(row.id),
|
||||
tenantId: String(row.tenant_id),
|
||||
@@ -27,7 +29,9 @@ const mapBotCredentials = (row: Record<string, unknown>): StoredBotCredentials =
|
||||
};
|
||||
};
|
||||
|
||||
export const listBotsToRestore = async (pool: Pool): Promise<StoredBotCredentials[]> => {
|
||||
export const listBotsToRestore = async (
|
||||
pool: Pool,
|
||||
): Promise<StoredBotCredentials[]> => {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT id, tenant_id, owner_user_id, discord_bot_id, display_name, token_ciphertext, token_iv, token_tag, status
|
||||
@@ -37,7 +41,9 @@ export const listBotsToRestore = async (pool: Pool): Promise<StoredBotCredential
|
||||
`,
|
||||
);
|
||||
|
||||
return result.rows.map((row) => mapBotCredentials(row as Record<string, unknown>));
|
||||
return result.rows.map((row) =>
|
||||
mapBotCredentials(row as Record<string, unknown>),
|
||||
);
|
||||
};
|
||||
|
||||
export const getBotCredentialsById = async (
|
||||
|
||||
+20
-14
@@ -13,9 +13,9 @@ const bootstrap = async (): Promise<void> => {
|
||||
|
||||
const redis = env.REDIS_URL
|
||||
? new Redis(env.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: true,
|
||||
})
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
await pgPool.query("SELECT 1");
|
||||
@@ -23,24 +23,33 @@ const bootstrap = async (): Promise<void> => {
|
||||
await redis.ping();
|
||||
}
|
||||
|
||||
const manager = new BotManager(pgPool, parseTokenEncryptionKey(env.TOKEN_ENCRYPTION_KEY));
|
||||
const manager = new BotManager(
|
||||
pgPool,
|
||||
parseTokenEncryptionKey(env.TOKEN_ENCRYPTION_KEY),
|
||||
);
|
||||
await manager.loadAndStartPersistedBots();
|
||||
|
||||
const worker = redis ? createBotControlWorker(redis.duplicate(), manager) : null;
|
||||
const worker = redis
|
||||
? createBotControlWorker(redis.duplicate(), manager)
|
||||
: null;
|
||||
|
||||
if (worker) {
|
||||
worker.on("completed", (job) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[bot] completed ${job.data.action} for bot ${job.data.botId}`);
|
||||
console.log(
|
||||
`[bot] completed ${job.data.action} for bot ${job.data.botId}`,
|
||||
);
|
||||
});
|
||||
|
||||
worker.on("failed", (job, error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[bot] failed ${job?.data.action ?? "unknown"} for bot ${job?.data.botId ?? "unknown"}`, error);
|
||||
console.error(
|
||||
`[bot] failed ${job?.data.action ?? "unknown"} for bot ${job?.data.botId ?? "unknown"}`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[bot] REDIS_URL is not configured, bot control queue worker is disabled");
|
||||
console.warn(
|
||||
"[bot] REDIS_URL is not configured, bot control queue worker is disabled",
|
||||
);
|
||||
}
|
||||
|
||||
const healthServer = createServer((req, res) => {
|
||||
@@ -65,12 +74,10 @@ const bootstrap = async (): Promise<void> => {
|
||||
});
|
||||
|
||||
healthServer.listen(env.PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[bot] health endpoint available on :${env.PORT}`);
|
||||
});
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[bot] shutdown requested (${signal})`);
|
||||
|
||||
healthServer.close(async () => {
|
||||
@@ -92,7 +99,6 @@ const bootstrap = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[bot] fatal startup error", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -32,20 +32,16 @@ import { registerEvents } from "../events/index.js";
|
||||
import { createPrefixHandler } from "../handlers/prefixHandler.js";
|
||||
import { createSlashHandler } from "../handlers/slashHandler.js";
|
||||
import { I18nService } from "../i18n/index.js";
|
||||
import {
|
||||
LogEventService,
|
||||
} from "../modules/logs/index.js";
|
||||
import {
|
||||
MemberMessageService,
|
||||
} from "../modules/memberMessages/index.js";
|
||||
import {
|
||||
PresenceService,
|
||||
} from "../modules/presence/index.js";
|
||||
import { LogEventService } from "../modules/logs/index.js";
|
||||
import { MemberMessageService } from "../modules/memberMessages/index.js";
|
||||
import { PresenceService } from "../modules/presence/index.js";
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 10_000;
|
||||
const log = createScopedLogger("bootstrap");
|
||||
|
||||
const bindGracefulShutdown = (shutdown: (signal: string) => Promise<void>): void => {
|
||||
const bindGracefulShutdown = (
|
||||
shutdown: (signal: string) => Promise<void>,
|
||||
): void => {
|
||||
process.once("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
@@ -56,7 +52,11 @@ const bindGracefulShutdown = (shutdown: (signal: string) => Promise<void>): void
|
||||
};
|
||||
|
||||
const bindFatalProcessHandlers = (
|
||||
shutdown: (signal: string, exitCode?: number, error?: unknown) => Promise<void>,
|
||||
shutdown: (
|
||||
signal: string,
|
||||
exitCode?: number,
|
||||
error?: unknown,
|
||||
) => Promise<void>,
|
||||
): void => {
|
||||
process.once("uncaughtException", (error) => {
|
||||
log.error({ err: error }, "process uncaught exception");
|
||||
@@ -72,16 +72,18 @@ const bindFatalProcessHandlers = (
|
||||
export const bootstrap = async (): Promise<void> => {
|
||||
const dispatchMode: CommandDispatchMode = env.COMMAND_DISPATCH_MODE;
|
||||
if (dispatchMode === "worker") {
|
||||
throw new Error("Worker mode is not implemented and must not be used in production");
|
||||
throw new Error(
|
||||
"Worker mode is not implemented and must not be used in production",
|
||||
);
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
ssl: env.DATABASE_SSL
|
||||
? {
|
||||
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
|
||||
...(env.DATABASE_SSL_CA ? { ca: env.DATABASE_SSL_CA } : {}),
|
||||
}
|
||||
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
|
||||
...(env.DATABASE_SSL_CA ? { ca: env.DATABASE_SSL_CA } : {}),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -129,18 +131,23 @@ export const bootstrap = async (): Promise<void> => {
|
||||
);
|
||||
|
||||
const services: AppFeatureServices = {
|
||||
presenceService: new PresenceService(presenceStore, env.PRESENCE_STREAM_URL),
|
||||
presenceService: new PresenceService(
|
||||
presenceStore,
|
||||
env.PRESENCE_STREAM_URL,
|
||||
),
|
||||
memberMessageService: new MemberMessageService(memberMessageStore),
|
||||
logEventService: new LogEventService(logEventStore),
|
||||
};
|
||||
|
||||
const cooldownStore = env.STATE_BACKEND === "redis" && redis
|
||||
? new RedisCooldownStore(redis)
|
||||
: new MemoryCooldownStore();
|
||||
const cooldownStore =
|
||||
env.STATE_BACKEND === "redis" && redis
|
||||
? new RedisCooldownStore(redis)
|
||||
: new MemoryCooldownStore();
|
||||
|
||||
const globalRateLimitStore = env.STATE_BACKEND === "redis" && redis
|
||||
? new RedisGlobalRateLimitStore(redis)
|
||||
: new MemoryGlobalRateLimitStore();
|
||||
const globalRateLimitStore =
|
||||
env.STATE_BACKEND === "redis" && redis
|
||||
? new RedisGlobalRateLimitStore(redis)
|
||||
: new MemoryGlobalRateLimitStore();
|
||||
|
||||
const executor = new CommandExecutor({
|
||||
cooldownStore,
|
||||
@@ -162,7 +169,11 @@ export const bootstrap = async (): Promise<void> => {
|
||||
let shuttingDown = false;
|
||||
let client: Client | null = null;
|
||||
|
||||
const shutdown = async (signal: string, exitCode = 0, error?: unknown): Promise<void> => {
|
||||
const shutdown = async (
|
||||
signal: string,
|
||||
exitCode = 0,
|
||||
error?: unknown,
|
||||
): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
@@ -176,7 +187,10 @@ export const bootstrap = async (): Promise<void> => {
|
||||
|
||||
const forcedExitCode = exitCode === 0 ? 1 : exitCode;
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
log.error({ signal, forcedExitCode, timeoutMs: SHUTDOWN_TIMEOUT_MS }, "forcing process exit");
|
||||
log.error(
|
||||
{ signal, forcedExitCode, timeoutMs: SHUTDOWN_TIMEOUT_MS },
|
||||
"forcing process exit",
|
||||
);
|
||||
process.exit(forcedExitCode);
|
||||
}, SHUTDOWN_TIMEOUT_MS);
|
||||
|
||||
@@ -224,7 +238,10 @@ export const bootstrap = async (): Promise<void> => {
|
||||
});
|
||||
|
||||
const i18n = new I18nService(env.DEFAULT_LANG);
|
||||
const registry = new CommandRegistry(createCommandList(services, i18n), i18n);
|
||||
const registry = new CommandRegistry(
|
||||
createCommandList(services, i18n),
|
||||
i18n,
|
||||
);
|
||||
|
||||
const onPrefixMessage = createPrefixHandler({
|
||||
registry,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type {
|
||||
MemberMessageService,
|
||||
} from "../modules/memberMessages/index.js";
|
||||
import type {
|
||||
LogEventService,
|
||||
} from "../modules/logs/index.js";
|
||||
import type {
|
||||
PresenceService,
|
||||
} from "../modules/presence/index.js";
|
||||
import type { MemberMessageService } from "../modules/memberMessages/index.js";
|
||||
import type { LogEventService } from "../modules/logs/index.js";
|
||||
import type { PresenceService } from "../modules/presence/index.js";
|
||||
|
||||
export interface AppFeatureServices {
|
||||
presenceService: PresenceService;
|
||||
|
||||
@@ -14,18 +14,26 @@ import {
|
||||
} from "../modules/memberMessages/index.js";
|
||||
|
||||
/** Commande `goodbye` — ouvre le panneau de configuration des messages 'goodbye'. */
|
||||
export const createGoodbyeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
|
||||
meta: {
|
||||
name: "goodbye",
|
||||
category: "utility",
|
||||
},
|
||||
permissions: [PermissionFlagsBits.ManageGuild],
|
||||
sensitive: true,
|
||||
examples: [
|
||||
{
|
||||
source: "slash",
|
||||
descriptionKey: "examples.slash",
|
||||
export const createGoodbyeCommand = (
|
||||
memberMessageService: MemberMessageService,
|
||||
i18n: I18nService,
|
||||
) =>
|
||||
defineCommand({
|
||||
meta: {
|
||||
name: "goodbye",
|
||||
category: "utility",
|
||||
},
|
||||
],
|
||||
execute: createMemberMessagePanelExecute("goodbye", memberMessageService, i18n),
|
||||
});
|
||||
permissions: [PermissionFlagsBits.ManageGuild],
|
||||
sensitive: true,
|
||||
examples: [
|
||||
{
|
||||
source: "slash",
|
||||
descriptionKey: "examples.slash",
|
||||
},
|
||||
],
|
||||
execute: createMemberMessagePanelExecute(
|
||||
"goodbye",
|
||||
memberMessageService,
|
||||
i18n,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -17,7 +17,10 @@ import type { I18nService } from "../i18n/index.js";
|
||||
import type { BotCommand } from "../types/command.js";
|
||||
|
||||
/** CommandList: tableau ordonné des commandes disponibles. */
|
||||
export const createCommandList = (services: AppFeatureServices, i18n: I18nService): BotCommand[] => [
|
||||
export const createCommandList = (
|
||||
services: AppFeatureServices,
|
||||
i18n: I18nService,
|
||||
): BotCommand[] => [
|
||||
kissCommand,
|
||||
pingCommand,
|
||||
createWelcomeCommand(services.memberMessageService, i18n),
|
||||
|
||||
@@ -29,7 +29,9 @@ export const kissCommand = defineCommand({
|
||||
execute: async (ctx) => {
|
||||
const target = ctx.args.user;
|
||||
if (!target || typeof target !== "object" || !("id" in target)) {
|
||||
await ctx.reply(ctx.t("errors.args.invalidUser", { value: String(target) }));
|
||||
await ctx.reply(
|
||||
ctx.t("errors.args.invalidUser", { value: String(target) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,18 +6,19 @@ import {
|
||||
type LogEventService,
|
||||
} from "../modules/logs/index.js";
|
||||
|
||||
export const createLogsCommand = (logEventService: LogEventService) => defineCommand({
|
||||
meta: {
|
||||
name: "logs",
|
||||
category: "utility",
|
||||
},
|
||||
permissions: [PermissionFlagsBits.ManageGuild],
|
||||
sensitive: true,
|
||||
examples: [
|
||||
{
|
||||
source: "slash",
|
||||
descriptionKey: "examples.slash",
|
||||
export const createLogsCommand = (logEventService: LogEventService) =>
|
||||
defineCommand({
|
||||
meta: {
|
||||
name: "logs",
|
||||
category: "utility",
|
||||
},
|
||||
],
|
||||
execute: createLogsCommandExecute(logEventService),
|
||||
});
|
||||
permissions: [PermissionFlagsBits.ManageGuild],
|
||||
sensitive: true,
|
||||
examples: [
|
||||
{
|
||||
source: "slash",
|
||||
descriptionKey: "examples.slash",
|
||||
},
|
||||
],
|
||||
execute: createLogsCommandExecute(logEventService),
|
||||
});
|
||||
|
||||
@@ -6,18 +6,19 @@ import {
|
||||
type PresenceService,
|
||||
} from "../modules/presence/index.js";
|
||||
|
||||
export const createPresenceCommand = (presenceService: PresenceService) => defineCommand({
|
||||
meta: {
|
||||
name: "presence",
|
||||
category: "utility",
|
||||
},
|
||||
permissions: [PermissionFlagsBits.ManageGuild],
|
||||
sensitive: true,
|
||||
examples: [
|
||||
{
|
||||
source: "slash",
|
||||
descriptionKey: "examples.slash",
|
||||
export const createPresenceCommand = (presenceService: PresenceService) =>
|
||||
defineCommand({
|
||||
meta: {
|
||||
name: "presence",
|
||||
category: "utility",
|
||||
},
|
||||
],
|
||||
execute: createPresenceCommandExecute(presenceService),
|
||||
});
|
||||
permissions: [PermissionFlagsBits.ManageGuild],
|
||||
sensitive: true,
|
||||
examples: [
|
||||
{
|
||||
source: "slash",
|
||||
descriptionKey: "examples.slash",
|
||||
},
|
||||
],
|
||||
execute: createPresenceCommandExecute(presenceService),
|
||||
});
|
||||
|
||||
@@ -14,18 +14,26 @@ import {
|
||||
} from "../modules/memberMessages/index.js";
|
||||
|
||||
/** Commande `welcome` — ouvre le panneau de configuration des messages 'welcome'. */
|
||||
export const createWelcomeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
|
||||
meta: {
|
||||
name: "welcome",
|
||||
category: "utility",
|
||||
},
|
||||
permissions: [PermissionFlagsBits.ManageGuild],
|
||||
sensitive: true,
|
||||
examples: [
|
||||
{
|
||||
source: "slash",
|
||||
descriptionKey: "examples.slash",
|
||||
export const createWelcomeCommand = (
|
||||
memberMessageService: MemberMessageService,
|
||||
i18n: I18nService,
|
||||
) =>
|
||||
defineCommand({
|
||||
meta: {
|
||||
name: "welcome",
|
||||
category: "utility",
|
||||
},
|
||||
],
|
||||
execute: createMemberMessagePanelExecute("welcome", memberMessageService, i18n),
|
||||
});
|
||||
permissions: [PermissionFlagsBits.ManageGuild],
|
||||
sensitive: true,
|
||||
examples: [
|
||||
{
|
||||
source: "slash",
|
||||
descriptionKey: "examples.slash",
|
||||
},
|
||||
],
|
||||
execute: createMemberMessagePanelExecute(
|
||||
"welcome",
|
||||
memberMessageService,
|
||||
i18n,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,12 @@ loadEnv();
|
||||
|
||||
const toBoolean = (value: string): boolean => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
return (
|
||||
normalized === "true" ||
|
||||
normalized === "1" ||
|
||||
normalized === "yes" ||
|
||||
normalized === "on"
|
||||
);
|
||||
};
|
||||
|
||||
const optionalString = (value?: string): string | undefined => {
|
||||
@@ -40,11 +45,7 @@ const envSchema = z.object({
|
||||
.trim()
|
||||
.min(1, "DATABASE_URL is required")
|
||||
.url("DATABASE_URL must be a valid URL"),
|
||||
DATABASE_SSL: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("false")
|
||||
.transform(toBoolean),
|
||||
DATABASE_SSL: z.string().optional().default("false").transform(toBoolean),
|
||||
DATABASE_SSL_REJECT_UNAUTHORIZED: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -73,7 +74,10 @@ const envSchema = z.object({
|
||||
.default("https://twitch.tv/discord"),
|
||||
PREFIX: z.string().min(1).max(5).default("+"),
|
||||
DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"),
|
||||
DEV_GUILD_ID: z.string().optional().transform((value) => value && value.length > 0 ? value : undefined),
|
||||
DEV_GUILD_ID: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value && value.length > 0 ? value : undefined)),
|
||||
AUTO_DEPLOY_SLASH: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -81,11 +85,26 @@ const envSchema = z.object({
|
||||
.transform(toBoolean),
|
||||
LOG_LEVEL: z.string().trim().min(1).default("info"),
|
||||
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
|
||||
REDIS_URL: z.preprocess(parseOptionalUrl, z.string().url("REDIS_URL must be a valid URL").optional()),
|
||||
REDIS_URL: z.preprocess(
|
||||
parseOptionalUrl,
|
||||
z.string().url("REDIS_URL must be a valid URL").optional(),
|
||||
),
|
||||
COMMAND_DISPATCH_MODE: z.enum(["local", "worker"]).default("local"),
|
||||
COMMAND_QUEUE_NAME: z.string().trim().min(1).default("bot:${botId}:command-jobs"),
|
||||
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(20),
|
||||
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(10),
|
||||
COMMAND_QUEUE_NAME: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.default("bot:${botId}:command-jobs"),
|
||||
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(20),
|
||||
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(10),
|
||||
RATE_LIMIT_FAIL_OPEN: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -100,16 +119,18 @@ const envSchema = z.object({
|
||||
|
||||
const parsed = envSchema.parse(process.env);
|
||||
|
||||
if (parsed.DATABASE_SSL && !parsed.DATABASE_SSL_REJECT_UNAUTHORIZED && !parsed.ALLOW_INSECURE_DB_SSL) {
|
||||
if (
|
||||
parsed.DATABASE_SSL &&
|
||||
!parsed.DATABASE_SSL_REJECT_UNAUTHORIZED &&
|
||||
!parsed.ALLOW_INSECURE_DB_SSL
|
||||
) {
|
||||
throw new Error(
|
||||
"Insecure DATABASE_SSL configuration detected: rejectUnauthorized=false is blocked by default. Set DATABASE_SSL_REJECT_UNAUTHORIZED=true or explicitly set ALLOW_INSECURE_DB_SSL=true for non-production environments.",
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.STATE_BACKEND === "redis" && !parsed.REDIS_URL) {
|
||||
throw new Error(
|
||||
"REDIS_URL is required when STATE_BACKEND=redis.",
|
||||
);
|
||||
throw new Error("REDIS_URL is required when STATE_BACKEND=redis.");
|
||||
}
|
||||
|
||||
export const env = parsed;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { ChatInputCommandInteraction, GuildBasedChannel, Message } from "discord.js";
|
||||
import type {
|
||||
ChatInputCommandInteraction,
|
||||
GuildBasedChannel,
|
||||
Message,
|
||||
} from "discord.js";
|
||||
|
||||
import type { ArgumentParseError, ParsedArgumentsResult } from "../../types/argParser.js";
|
||||
import type {
|
||||
ArgumentParseError,
|
||||
ParsedArgumentsResult,
|
||||
} from "../../types/argParser.js";
|
||||
import type { CommandArgValue, CommandArgument } from "../../types/command.js";
|
||||
|
||||
const USER_MENTION_PATTERN = /^<@!?(\d{16,22})>$/;
|
||||
@@ -12,7 +19,11 @@ const BOOLEAN_TRUE = new Set(["true", "1", "yes", "y", "on"]);
|
||||
const BOOLEAN_FALSE = new Set(["false", "0", "no", "n", "off"]);
|
||||
|
||||
const isGuildBasedChannel = (value: unknown): value is GuildBasedChannel => {
|
||||
return Boolean(value) && typeof value === "object" && "guild" in (value as Record<string, unknown>);
|
||||
return (
|
||||
Boolean(value) &&
|
||||
typeof value === "object" &&
|
||||
"guild" in (value as Record<string, unknown>)
|
||||
);
|
||||
};
|
||||
|
||||
export const tokenizePrefixInput = (raw: string): string[] => {
|
||||
@@ -21,7 +32,9 @@ export const tokenizePrefixInput = (raw: string): string[] => {
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(raw)) !== null) {
|
||||
tokens.push((match[1] ?? match[2] ?? match[3] ?? "").replace(/\\(["'])/g, "$1"));
|
||||
tokens.push(
|
||||
(match[1] ?? match[2] ?? match[3] ?? "").replace(/\\(["'])/g, "$1"),
|
||||
);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
@@ -41,7 +54,10 @@ export const parsePrefixArgs = async (
|
||||
|
||||
if (!token) {
|
||||
if (definition.required) {
|
||||
errors.push({ key: "errors.args.missing", vars: { arg: definition.name } });
|
||||
errors.push({
|
||||
key: "errors.args.missing",
|
||||
vars: { arg: definition.name },
|
||||
});
|
||||
}
|
||||
values[definition.name] = undefined;
|
||||
continue;
|
||||
@@ -81,7 +97,10 @@ export const parseSlashArgs = (
|
||||
values[definition.name] = parseByTypeFromSlash(interaction, definition);
|
||||
} catch {
|
||||
if (definition.required) {
|
||||
errors.push({ key: "errors.args.missing", vars: { arg: definition.name } });
|
||||
errors.push({
|
||||
key: "errors.args.missing",
|
||||
vars: { arg: definition.name },
|
||||
});
|
||||
}
|
||||
values[definition.name] = undefined;
|
||||
}
|
||||
@@ -94,14 +113,20 @@ const parseByTypeFromPrefix = async (
|
||||
message: Message,
|
||||
type: CommandArgument["type"],
|
||||
token: string,
|
||||
): Promise<{ ok: true; value: CommandArgValue } | { ok: false; error: ArgumentParseError }> => {
|
||||
): Promise<
|
||||
| { ok: true; value: CommandArgValue }
|
||||
| { ok: false; error: ArgumentParseError }
|
||||
> => {
|
||||
switch (type) {
|
||||
case "string":
|
||||
return { ok: true, value: token };
|
||||
|
||||
case "int": {
|
||||
if (!/^-?\d+$/.test(token)) {
|
||||
return { ok: false, error: { key: "errors.args.invalidInt", vars: { value: token } } };
|
||||
return {
|
||||
ok: false,
|
||||
error: { key: "errors.args.invalidInt", vars: { value: token } },
|
||||
};
|
||||
}
|
||||
|
||||
const value = Number.parseInt(token, 10);
|
||||
@@ -111,7 +136,10 @@ const parseByTypeFromPrefix = async (
|
||||
case "number": {
|
||||
const value = Number(token);
|
||||
if (!Number.isFinite(value)) {
|
||||
return { ok: false, error: { key: "errors.args.invalidNumber", vars: { value: token } } };
|
||||
return {
|
||||
ok: false,
|
||||
error: { key: "errors.args.invalidNumber", vars: { value: token } },
|
||||
};
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
@@ -124,7 +152,10 @@ const parseByTypeFromPrefix = async (
|
||||
if (BOOLEAN_FALSE.has(normalized)) {
|
||||
return { ok: true, value: false };
|
||||
}
|
||||
return { ok: false, error: { key: "errors.args.invalidBoolean", vars: { value: token } } };
|
||||
return {
|
||||
ok: false,
|
||||
error: { key: "errors.args.invalidBoolean", vars: { value: token } },
|
||||
};
|
||||
}
|
||||
|
||||
case "user": {
|
||||
@@ -133,7 +164,10 @@ const parseByTypeFromPrefix = async (
|
||||
const userId = mentionMatch?.[1] ?? idMatch?.[1];
|
||||
|
||||
if (!userId) {
|
||||
return { ok: false, error: { key: "errors.args.invalidUser", vars: { value: token } } };
|
||||
return {
|
||||
ok: false,
|
||||
error: { key: "errors.args.invalidUser", vars: { value: token } },
|
||||
};
|
||||
}
|
||||
|
||||
const fromMention = message.mentions.users.get(userId);
|
||||
@@ -145,7 +179,10 @@ const parseByTypeFromPrefix = async (
|
||||
const fetched = await message.client.users.fetch(userId);
|
||||
return { ok: true, value: fetched };
|
||||
} catch {
|
||||
return { ok: false, error: { key: "errors.args.invalidUser", vars: { value: token } } };
|
||||
return {
|
||||
ok: false,
|
||||
error: { key: "errors.args.invalidUser", vars: { value: token } },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +192,10 @@ const parseByTypeFromPrefix = async (
|
||||
const channelId = mentionMatch?.[1] ?? idMatch?.[1];
|
||||
|
||||
if (!channelId) {
|
||||
return { ok: false, error: { key: "errors.args.invalidChannel", vars: { value: token } } };
|
||||
return {
|
||||
ok: false,
|
||||
error: { key: "errors.args.invalidChannel", vars: { value: token } },
|
||||
};
|
||||
}
|
||||
|
||||
const fromMention = message.mentions.channels.get(channelId);
|
||||
@@ -186,7 +226,10 @@ const parseByTypeFromPrefix = async (
|
||||
// Falls through to invalid channel error.
|
||||
}
|
||||
|
||||
return { ok: false, error: { key: "errors.args.invalidChannel", vars: { value: token } } };
|
||||
return {
|
||||
ok: false,
|
||||
error: { key: "errors.args.invalidChannel", vars: { value: token } },
|
||||
};
|
||||
}
|
||||
|
||||
case "role": {
|
||||
@@ -195,7 +238,10 @@ const parseByTypeFromPrefix = async (
|
||||
const roleId = mentionMatch?.[1] ?? idMatch?.[1];
|
||||
|
||||
if (!roleId || !message.guild) {
|
||||
return { ok: false, error: { key: "errors.args.invalidRole", vars: { value: token } } };
|
||||
return {
|
||||
ok: false,
|
||||
error: { key: "errors.args.invalidRole", vars: { value: token } },
|
||||
};
|
||||
}
|
||||
|
||||
const fromMention = message.mentions.roles.get(roleId);
|
||||
@@ -217,7 +263,10 @@ const parseByTypeFromPrefix = async (
|
||||
// Falls through to invalid role error.
|
||||
}
|
||||
|
||||
return { ok: false, error: { key: "errors.args.invalidRole", vars: { value: token } } };
|
||||
return {
|
||||
ok: false,
|
||||
error: { key: "errors.args.invalidRole", vars: { value: token } },
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -231,27 +280,51 @@ const parseByTypeFromSlash = (
|
||||
): CommandArgValue => {
|
||||
switch (definition.type) {
|
||||
case "string":
|
||||
return interaction.options.getString(definition.name, definition.required) ?? undefined;
|
||||
return (
|
||||
interaction.options.getString(definition.name, definition.required) ??
|
||||
undefined
|
||||
);
|
||||
|
||||
case "int":
|
||||
return interaction.options.getInteger(definition.name, definition.required) ?? undefined;
|
||||
return (
|
||||
interaction.options.getInteger(definition.name, definition.required) ??
|
||||
undefined
|
||||
);
|
||||
|
||||
case "number":
|
||||
return interaction.options.getNumber(definition.name, definition.required) ?? undefined;
|
||||
return (
|
||||
interaction.options.getNumber(definition.name, definition.required) ??
|
||||
undefined
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
return interaction.options.getBoolean(definition.name, definition.required) ?? undefined;
|
||||
return (
|
||||
interaction.options.getBoolean(definition.name, definition.required) ??
|
||||
undefined
|
||||
);
|
||||
|
||||
case "user":
|
||||
return interaction.options.getUser(definition.name, definition.required) ?? undefined;
|
||||
return (
|
||||
interaction.options.getUser(definition.name, definition.required) ??
|
||||
undefined
|
||||
);
|
||||
|
||||
case "channel":
|
||||
return (interaction.options.getChannel(definition.name, definition.required) ?? undefined) as CommandArgValue;
|
||||
return (interaction.options.getChannel(
|
||||
definition.name,
|
||||
definition.required,
|
||||
) ?? undefined) as CommandArgValue;
|
||||
|
||||
case "role":
|
||||
return (interaction.options.getRole(definition.name, definition.required) ?? undefined) as CommandArgValue;
|
||||
return (interaction.options.getRole(
|
||||
definition.name,
|
||||
definition.required,
|
||||
) ?? undefined) as CommandArgValue;
|
||||
|
||||
default:
|
||||
return interaction.options.getString(definition.name, definition.required) ?? undefined;
|
||||
return (
|
||||
interaction.options.getString(definition.name, definition.required) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,7 +32,10 @@ const normalizeCooldown = (input: BotCommandInput): number | undefined => {
|
||||
return input.cooldown;
|
||||
};
|
||||
|
||||
const resolveSensitive = (input: BotCommandInput, permissions: readonly unknown[]): boolean => {
|
||||
const resolveSensitive = (
|
||||
input: BotCommandInput,
|
||||
permissions: readonly unknown[],
|
||||
): boolean => {
|
||||
if (input.sensitive === true && permissions.length === 0) {
|
||||
throw new Error(
|
||||
`Invalid security config for command "${input.meta.name}": sensitive commands must declare at least one required permission.`,
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { REST, Routes } from "discord.js";
|
||||
|
||||
import { buildSlashPayload } from "./slashBuilder.js";
|
||||
import type { DeployCommandsOptions, DeployCommandsResult } from "../../types/deploy.js";
|
||||
import type {
|
||||
DeployCommandsOptions,
|
||||
DeployCommandsResult,
|
||||
} from "../../types/deploy.js";
|
||||
|
||||
export const deployApplicationCommands = async (options: DeployCommandsOptions): Promise<DeployCommandsResult> => {
|
||||
export const deployApplicationCommands = async (
|
||||
options: DeployCommandsOptions,
|
||||
): Promise<DeployCommandsResult> => {
|
||||
const body = buildSlashPayload(options.registry.getAll(), options.i18n);
|
||||
const rest = new REST({ version: "10" }).setToken(options.token);
|
||||
|
||||
if (options.guildId) {
|
||||
await rest.put(Routes.applicationGuildCommands(options.clientId, options.guildId), { body });
|
||||
await rest.put(
|
||||
Routes.applicationGuildCommands(options.clientId, options.guildId),
|
||||
{ body },
|
||||
);
|
||||
return { scope: "guild", count: body.length };
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ export class CommandRegistry {
|
||||
return this.commandsByName.get(normalize(name));
|
||||
}
|
||||
|
||||
public findByAnyPrefixTrigger(trigger: string): PrefixTriggerMatch | undefined {
|
||||
public findByAnyPrefixTrigger(
|
||||
trigger: string,
|
||||
): PrefixTriggerMatch | undefined {
|
||||
return this.prefixTriggers.get(normalize(trigger));
|
||||
}
|
||||
|
||||
@@ -43,7 +45,9 @@ export class CommandRegistry {
|
||||
this.commandsByName.set(name, command);
|
||||
|
||||
for (const lang of SUPPORTED_LANGS) {
|
||||
const trigger = normalize(this.i18n.commandTrigger(lang, command.meta.name));
|
||||
const trigger = normalize(
|
||||
this.i18n.commandTrigger(lang, command.meta.name),
|
||||
);
|
||||
const existing = this.prefixTriggers.get(trigger);
|
||||
|
||||
if (existing) {
|
||||
@@ -60,7 +64,12 @@ export class CommandRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
const slashKeys = [command.meta.name, ...SUPPORTED_LANGS.map((lang) => this.i18n.commandName(lang, command.meta.name))];
|
||||
const slashKeys = [
|
||||
command.meta.name,
|
||||
...SUPPORTED_LANGS.map((lang) =>
|
||||
this.i18n.commandName(lang, command.meta.name),
|
||||
),
|
||||
];
|
||||
|
||||
for (const keyRaw of slashKeys) {
|
||||
const key = normalize(keyRaw);
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
type RESTPostAPIChatInputApplicationCommandsJSONBody,
|
||||
} from "discord.js";
|
||||
|
||||
import { SUPPORTED_LANGS, type BotCommand, type CommandArgument, type SupportedLang } from "../../types/command.js";
|
||||
import {
|
||||
SUPPORTED_LANGS,
|
||||
type BotCommand,
|
||||
type CommandArgument,
|
||||
type SupportedLang,
|
||||
} from "../../types/command.js";
|
||||
import type { I18nService } from "../../i18n/index.js";
|
||||
|
||||
const LANG_TO_DISCORD_LOCALE: Partial<Record<SupportedLang, string>> = {
|
||||
@@ -24,7 +29,9 @@ const LANG_TO_DISCORD_LOCALE: Partial<Record<SupportedLang, string>> = {
|
||||
tr: "tr",
|
||||
};
|
||||
|
||||
const toLocalizationMap = (source: Partial<Record<SupportedLang, string>>): Record<string, string> => {
|
||||
const toLocalizationMap = (
|
||||
source: Partial<Record<SupportedLang, string>>,
|
||||
): Record<string, string> => {
|
||||
const entries = Object.entries(source)
|
||||
.map(([lang, value]) => {
|
||||
const discordLocale = LANG_TO_DISCORD_LOCALE[lang as SupportedLang];
|
||||
@@ -39,7 +46,10 @@ const toLocalizationMap = (source: Partial<Record<SupportedLang, string>>): Reco
|
||||
return Object.fromEntries(entries);
|
||||
};
|
||||
|
||||
const buildCommandNameLocalizationSource = (command: BotCommand, i18n: I18nService): Partial<Record<SupportedLang, string>> => {
|
||||
const buildCommandNameLocalizationSource = (
|
||||
command: BotCommand,
|
||||
i18n: I18nService,
|
||||
): Partial<Record<SupportedLang, string>> => {
|
||||
const localizations: Partial<Record<SupportedLang, string>> = {};
|
||||
|
||||
for (const lang of SUPPORTED_LANGS) {
|
||||
@@ -53,7 +63,10 @@ const buildCommandNameLocalizationSource = (command: BotCommand, i18n: I18nServi
|
||||
return localizations;
|
||||
};
|
||||
|
||||
const buildDescriptionLocalizationSource = (descriptionKey: string, i18n: I18nService): Partial<Record<SupportedLang, string>> => {
|
||||
const buildDescriptionLocalizationSource = (
|
||||
descriptionKey: string,
|
||||
i18n: I18nService,
|
||||
): Partial<Record<SupportedLang, string>> => {
|
||||
const localizations: Partial<Record<SupportedLang, string>> = {};
|
||||
|
||||
for (const lang of SUPPORTED_LANGS) {
|
||||
@@ -67,7 +80,10 @@ const buildDescriptionLocalizationSource = (descriptionKey: string, i18n: I18nSe
|
||||
return localizations;
|
||||
};
|
||||
|
||||
const argDescriptionKey = (command: BotCommand, arg: CommandArgument): string => {
|
||||
const argDescriptionKey = (
|
||||
command: BotCommand,
|
||||
arg: CommandArgument,
|
||||
): string => {
|
||||
return `commands.${command.meta.name}.${arg.descriptionKey}`;
|
||||
};
|
||||
|
||||
@@ -75,7 +91,10 @@ const commandDescriptionKey = (command: BotCommand): string => {
|
||||
return `commands.${command.meta.name}.description`;
|
||||
};
|
||||
|
||||
const buildHelpCommandChoices = (commands: readonly BotCommand[], i18n: I18nService): Array<{ name: string; value: string }> => {
|
||||
const buildHelpCommandChoices = (
|
||||
commands: readonly BotCommand[],
|
||||
i18n: I18nService,
|
||||
): Array<{ name: string; value: string }> => {
|
||||
return commands
|
||||
.slice()
|
||||
.sort((a, b) => a.meta.name.localeCompare(b.meta.name))
|
||||
@@ -95,7 +114,9 @@ const applyOption = (
|
||||
): void => {
|
||||
const descriptionKey = argDescriptionKey(command, arg);
|
||||
const descriptionEn = i18n.t("en", descriptionKey);
|
||||
const descriptionLocalizations = toLocalizationMap(buildDescriptionLocalizationSource(descriptionKey, i18n));
|
||||
const descriptionLocalizations = toLocalizationMap(
|
||||
buildDescriptionLocalizationSource(descriptionKey, i18n),
|
||||
);
|
||||
|
||||
if (arg.type === "user") {
|
||||
builder.addUserOption((opt) =>
|
||||
@@ -163,24 +184,22 @@ const applyOption = (
|
||||
return;
|
||||
}
|
||||
|
||||
builder.addStringOption((opt) =>
|
||||
{
|
||||
const configured = opt
|
||||
builder.addStringOption((opt) => {
|
||||
const configured = opt
|
||||
.setName(arg.name)
|
||||
.setDescription(descriptionEn)
|
||||
.setDescriptionLocalizations(descriptionLocalizations)
|
||||
.setRequired(arg.required);
|
||||
|
||||
if (command.meta.name === "help" && arg.name === "command") {
|
||||
const choices = buildHelpCommandChoices(allCommands, i18n);
|
||||
if (choices.length > 0) {
|
||||
configured.addChoices(...choices);
|
||||
}
|
||||
if (command.meta.name === "help" && arg.name === "command") {
|
||||
const choices = buildHelpCommandChoices(allCommands, i18n);
|
||||
if (choices.length > 0) {
|
||||
configured.addChoices(...choices);
|
||||
}
|
||||
}
|
||||
|
||||
return configured;
|
||||
},
|
||||
);
|
||||
return configured;
|
||||
});
|
||||
};
|
||||
|
||||
export const buildSlashPayload = (
|
||||
@@ -190,8 +209,12 @@ export const buildSlashPayload = (
|
||||
return commands.map((command) => {
|
||||
const descriptionKey = commandDescriptionKey(command);
|
||||
|
||||
const slashLocalizations = toLocalizationMap(buildCommandNameLocalizationSource(command, i18n));
|
||||
const descriptionLocalizations = toLocalizationMap(buildDescriptionLocalizationSource(descriptionKey, i18n));
|
||||
const slashLocalizations = toLocalizationMap(
|
||||
buildCommandNameLocalizationSource(command, i18n),
|
||||
);
|
||||
const descriptionLocalizations = toLocalizationMap(
|
||||
buildDescriptionLocalizationSource(descriptionKey, i18n),
|
||||
);
|
||||
|
||||
const slashBuilder = new SlashCommandBuilder()
|
||||
.setName(command.meta.name)
|
||||
@@ -207,7 +230,9 @@ export const buildSlashPayload = (
|
||||
});
|
||||
};
|
||||
|
||||
export const commandOptionType = (type: CommandArgument["type"]): ApplicationCommandOptionType => {
|
||||
export const commandOptionType = (
|
||||
type: CommandArgument["type"],
|
||||
): ApplicationCommandOptionType => {
|
||||
switch (type) {
|
||||
case "user":
|
||||
return ApplicationCommandOptionType.User;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { BotCommand, CommandI18nTools, SupportedLang } from "../../types/command.js";
|
||||
import type {
|
||||
BotCommand,
|
||||
CommandI18nTools,
|
||||
SupportedLang,
|
||||
} from "../../types/command.js";
|
||||
|
||||
const formatArgToken = (name: string, required: boolean): string => required ? `<${name}>` : `[${name}]`;
|
||||
const formatArgToken = (name: string, required: boolean): string =>
|
||||
required ? `<${name}>` : `[${name}]`;
|
||||
|
||||
export const resolvePrefixTrigger = (
|
||||
command: BotCommand,
|
||||
@@ -21,7 +26,11 @@ export const resolvePrefixTrigger = (
|
||||
return command.meta.name;
|
||||
};
|
||||
|
||||
export const resolveSlashName = (command: BotCommand, lang: SupportedLang, i18n: CommandI18nTools): string => {
|
||||
export const resolveSlashName = (
|
||||
command: BotCommand,
|
||||
lang: SupportedLang,
|
||||
i18n: CommandI18nTools,
|
||||
): string => {
|
||||
return i18n.commandName(lang, command.meta.name);
|
||||
};
|
||||
|
||||
@@ -33,12 +42,20 @@ export const buildPrefixUsage = (
|
||||
i18n: CommandI18nTools,
|
||||
): string => {
|
||||
const trigger = resolvePrefixTrigger(command, lang, defaultLang, i18n);
|
||||
const args = command.args.map((arg) => formatArgToken(arg.name, arg.required)).join(" ");
|
||||
const args = command.args
|
||||
.map((arg) => formatArgToken(arg.name, arg.required))
|
||||
.join(" ");
|
||||
return `${prefix}${trigger}${args.length > 0 ? ` ${args}` : ""}`;
|
||||
};
|
||||
|
||||
export const buildSlashUsage = (command: BotCommand, lang: SupportedLang, i18n: CommandI18nTools): string => {
|
||||
export const buildSlashUsage = (
|
||||
command: BotCommand,
|
||||
lang: SupportedLang,
|
||||
i18n: CommandI18nTools,
|
||||
): string => {
|
||||
const slashName = resolveSlashName(command, lang, i18n);
|
||||
const args = command.args.map((arg) => formatArgToken(arg.name, arg.required)).join(" ");
|
||||
const args = command.args
|
||||
.map((arg) => formatArgToken(arg.name, arg.required))
|
||||
.join(" ");
|
||||
return `/${slashName}${args.length > 0 ? ` ${args}` : ""}`;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
PermissionsBitField,
|
||||
type PermissionResolvable,
|
||||
} from "discord.js";
|
||||
import { PermissionsBitField, type PermissionResolvable } from "discord.js";
|
||||
|
||||
import type { BotCommand, CommandExecutionContext } from "../../types/command.js";
|
||||
import type {
|
||||
BotCommand,
|
||||
CommandExecutionContext,
|
||||
} from "../../types/command.js";
|
||||
import type { AppLogger } from "../logging/logger.js";
|
||||
import type { CooldownStore } from "./cooldownStore.js";
|
||||
import type {
|
||||
@@ -22,7 +22,10 @@ export interface CommandExecutorDeps {
|
||||
export class CommandExecutor {
|
||||
public constructor(private readonly deps: CommandExecutorDeps) {}
|
||||
|
||||
public async run(command: BotCommand, ctx: CommandExecutionContext): Promise<void> {
|
||||
public async run(
|
||||
command: BotCommand,
|
||||
ctx: CommandExecutionContext,
|
||||
): Promise<void> {
|
||||
if (this.isSensitiveCommand(command) && !ctx.transport.guild) {
|
||||
await ctx.reply(
|
||||
ctx.t("errors.permissions.user", {
|
||||
@@ -34,20 +37,31 @@ export class CommandExecutor {
|
||||
|
||||
const rateLimitRetryAfterSeconds = await this.consumeGlobalRateLimit(ctx);
|
||||
if (rateLimitRetryAfterSeconds > 0) {
|
||||
await ctx.reply(ctx.t("errors.rateLimit", { seconds: rateLimitRetryAfterSeconds }));
|
||||
await ctx.reply(
|
||||
ctx.t("errors.rateLimit", { seconds: rateLimitRetryAfterSeconds }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const availablePermissions = await this.resolveMemberPermissions(ctx);
|
||||
const missingUserPermissions = this.getMissingPermissions(command.permissions, availablePermissions);
|
||||
const missingUserPermissions = this.getMissingPermissions(
|
||||
command.permissions,
|
||||
availablePermissions,
|
||||
);
|
||||
if (missingUserPermissions.length > 0) {
|
||||
await ctx.reply(ctx.t("errors.permissions.user", { permissions: missingUserPermissions.join(", ") }));
|
||||
await ctx.reply(
|
||||
ctx.t("errors.permissions.user", {
|
||||
permissions: missingUserPermissions.join(", "),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingCooldownSeconds = await this.consumeCooldown(command, ctx);
|
||||
if (remainingCooldownSeconds > 0) {
|
||||
await ctx.reply(ctx.t("errors.cooldown", { seconds: remainingCooldownSeconds }));
|
||||
await ctx.reply(
|
||||
ctx.t("errors.cooldown", { seconds: remainingCooldownSeconds }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,7 +110,11 @@ export class CommandExecutor {
|
||||
}
|
||||
|
||||
if (!available) {
|
||||
return [...new Set(required.flatMap((permission) => this.permissionToLabels(permission)))];
|
||||
return [
|
||||
...new Set(
|
||||
required.flatMap((permission) => this.permissionToLabels(permission)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -111,7 +129,9 @@ export class CommandExecutor {
|
||||
private permissionToLabels(permission: PermissionResolvable): string[] {
|
||||
try {
|
||||
const resolved = PermissionsBitField.resolve(permission);
|
||||
const labels = new PermissionsBitField(resolved).toArray().map(this.formatPermissionLabel);
|
||||
const labels = new PermissionsBitField(resolved)
|
||||
.toArray()
|
||||
.map(this.formatPermissionLabel);
|
||||
if (labels.length > 0) {
|
||||
return labels;
|
||||
}
|
||||
@@ -129,7 +149,10 @@ export class CommandExecutor {
|
||||
.trim();
|
||||
}
|
||||
|
||||
private async consumeCooldown(command: BotCommand, ctx: CommandExecutionContext): Promise<number> {
|
||||
private async consumeCooldown(
|
||||
command: BotCommand,
|
||||
ctx: CommandExecutionContext,
|
||||
): Promise<number> {
|
||||
if (command.cooldown === undefined || command.cooldown <= 0) {
|
||||
return 0;
|
||||
}
|
||||
@@ -142,7 +165,12 @@ export class CommandExecutor {
|
||||
const userId = ctx.execution.actor.userId;
|
||||
|
||||
try {
|
||||
const result = await this.deps.cooldownStore.consume(botId, command.meta.name, userId, command.cooldown);
|
||||
const result = await this.deps.cooldownStore.consume(
|
||||
botId,
|
||||
command.meta.name,
|
||||
userId,
|
||||
command.cooldown,
|
||||
);
|
||||
return result.allowed ? 0 : result.retryAfterSeconds;
|
||||
} catch (error) {
|
||||
if (this.deps.rateLimitFailOpen) {
|
||||
@@ -174,10 +202,14 @@ export class CommandExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
private async consumeGlobalRateLimit(ctx: CommandExecutionContext): Promise<number> {
|
||||
private async consumeGlobalRateLimit(
|
||||
ctx: CommandExecutionContext,
|
||||
): Promise<number> {
|
||||
const botId = this.resolveBotId(ctx);
|
||||
if (!botId) {
|
||||
return this.deps.rateLimitFailOpen ? 0 : Math.max(1, this.deps.globalRateLimitPolicy.windowSeconds);
|
||||
return this.deps.rateLimitFailOpen
|
||||
? 0
|
||||
: Math.max(1, this.deps.globalRateLimitPolicy.windowSeconds);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,12 @@ export interface CooldownConsumeResult {
|
||||
}
|
||||
|
||||
export interface CooldownStore {
|
||||
consume(botId: string, commandName: string, subjectId: string, cooldownSeconds: number): Promise<CooldownConsumeResult>;
|
||||
consume(
|
||||
botId: string,
|
||||
commandName: string,
|
||||
subjectId: string,
|
||||
cooldownSeconds: number,
|
||||
): Promise<CooldownConsumeResult>;
|
||||
}
|
||||
|
||||
export class MemoryCooldownStore implements CooldownStore {
|
||||
@@ -52,7 +57,8 @@ export class MemoryCooldownStore implements CooldownStore {
|
||||
}
|
||||
|
||||
const shouldSweepBySize = this.cooldowns.size >= COOLDOWN_SWEEP_MIN_ENTRIES;
|
||||
const shouldSweepByTime = now - this.lastSweepAt >= COOLDOWN_SWEEP_INTERVAL_MS;
|
||||
const shouldSweepByTime =
|
||||
now - this.lastSweepAt >= COOLDOWN_SWEEP_INTERVAL_MS;
|
||||
if (!shouldSweepBySize && !shouldSweepByTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ export interface CommandJobPublisher {
|
||||
export class LocalCommandDispatchPort implements CommandDispatchPort {
|
||||
public constructor(private readonly executor: CommandExecutor) {}
|
||||
|
||||
public async dispatch(command: BotCommand, ctx: CommandExecutionContext): Promise<void> {
|
||||
public async dispatch(
|
||||
command: BotCommand,
|
||||
ctx: CommandExecutionContext,
|
||||
): Promise<void> {
|
||||
await this.executor.run(command, ctx);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +50,10 @@ export class WorkerCommandDispatchPort implements CommandDispatchPort {
|
||||
private readonly logger: AppLogger,
|
||||
) {}
|
||||
|
||||
public async dispatch(command: BotCommand, ctx: CommandExecutionContext): Promise<void> {
|
||||
public async dispatch(
|
||||
command: BotCommand,
|
||||
ctx: CommandExecutionContext,
|
||||
): Promise<void> {
|
||||
const job = toExecutionJob(command, ctx);
|
||||
|
||||
await this.publisher.publish(job);
|
||||
@@ -65,14 +71,20 @@ export class WorkerCommandDispatchPort implements CommandDispatchPort {
|
||||
}
|
||||
}
|
||||
|
||||
const toExecutionJob = (command: BotCommand, ctx: CommandExecutionContext): CommandExecutionJob => {
|
||||
const toExecutionJob = (
|
||||
command: BotCommand,
|
||||
ctx: CommandExecutionContext,
|
||||
): CommandExecutionJob => {
|
||||
const botId = ctx.transport.client.user?.id;
|
||||
if (!botId) {
|
||||
throw new Error("runtime bot id unavailable for worker command dispatch");
|
||||
}
|
||||
|
||||
const serializedArgs = Object.fromEntries(
|
||||
Object.entries(ctx.execution.args).map(([key, value]) => [key, serializeArgValue(value)]),
|
||||
Object.entries(ctx.execution.args).map(([key, value]) => [
|
||||
key,
|
||||
serializeArgValue(value),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -96,11 +108,19 @@ const serializeArgValue = (value: CommandArgValue): unknown => {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "object" && "id" in value && typeof value.id === "string") {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
"id" in value &&
|
||||
typeof value.id === "string"
|
||||
) {
|
||||
return {
|
||||
id: value.id,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,11 @@ export interface GlobalRateLimitDecision {
|
||||
}
|
||||
|
||||
export interface GlobalRateLimitStore {
|
||||
consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision>;
|
||||
consume(
|
||||
botId: string,
|
||||
userId: string,
|
||||
policy: GlobalRateLimitPolicy,
|
||||
): Promise<GlobalRateLimitDecision>;
|
||||
}
|
||||
|
||||
interface WindowCounterEntry {
|
||||
@@ -24,7 +28,11 @@ interface WindowCounterEntry {
|
||||
export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore {
|
||||
private readonly counters = new Map<string, WindowCounterEntry>();
|
||||
|
||||
public async consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision> {
|
||||
public async consume(
|
||||
botId: string,
|
||||
userId: string,
|
||||
policy: GlobalRateLimitPolicy,
|
||||
): Promise<GlobalRateLimitDecision> {
|
||||
const sanitized = sanitizePolicy(policy);
|
||||
const now = Date.now();
|
||||
const key = this.key(botId, userId, sanitized.windowSeconds);
|
||||
@@ -47,7 +55,10 @@ export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore {
|
||||
|
||||
existing.count += 1;
|
||||
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((existing.resetAt - now) / 1000));
|
||||
const retryAfterSeconds = Math.max(
|
||||
1,
|
||||
Math.ceil((existing.resetAt - now) / 1000),
|
||||
);
|
||||
const allowed = existing.count <= sanitized.limit;
|
||||
|
||||
return {
|
||||
@@ -92,7 +103,11 @@ export class RedisGlobalRateLimitStore implements GlobalRateLimitStore {
|
||||
private readonly keyPrefix = "bot",
|
||||
) {}
|
||||
|
||||
public async consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision> {
|
||||
public async consume(
|
||||
botId: string,
|
||||
userId: string,
|
||||
policy: GlobalRateLimitPolicy,
|
||||
): Promise<GlobalRateLimitDecision> {
|
||||
const sanitized = sanitizePolicy(policy);
|
||||
const key = this.key(botId, userId);
|
||||
|
||||
@@ -102,7 +117,10 @@ export class RedisGlobalRateLimitStore implements GlobalRateLimitStore {
|
||||
key,
|
||||
String(sanitized.windowSeconds),
|
||||
);
|
||||
const { count, retryAfterSeconds } = parseScriptResult(rawResult, sanitized.windowSeconds);
|
||||
const { count, retryAfterSeconds } = parseScriptResult(
|
||||
rawResult,
|
||||
sanitized.windowSeconds,
|
||||
);
|
||||
const allowed = count <= sanitized.limit;
|
||||
|
||||
return {
|
||||
@@ -118,11 +136,17 @@ export class RedisGlobalRateLimitStore implements GlobalRateLimitStore {
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizePolicy = (policy: GlobalRateLimitPolicy): GlobalRateLimitPolicy => {
|
||||
const limit = Number.isFinite(policy.limit) && policy.limit > 0 ? Math.floor(policy.limit) : 1;
|
||||
const windowSeconds = Number.isFinite(policy.windowSeconds) && policy.windowSeconds > 0
|
||||
? Math.floor(policy.windowSeconds)
|
||||
: 1;
|
||||
const sanitizePolicy = (
|
||||
policy: GlobalRateLimitPolicy,
|
||||
): GlobalRateLimitPolicy => {
|
||||
const limit =
|
||||
Number.isFinite(policy.limit) && policy.limit > 0
|
||||
? Math.floor(policy.limit)
|
||||
: 1;
|
||||
const windowSeconds =
|
||||
Number.isFinite(policy.windowSeconds) && policy.windowSeconds > 0
|
||||
? Math.floor(policy.windowSeconds)
|
||||
: 1;
|
||||
|
||||
return {
|
||||
limit,
|
||||
@@ -130,14 +154,19 @@ const sanitizePolicy = (policy: GlobalRateLimitPolicy): GlobalRateLimitPolicy =>
|
||||
};
|
||||
};
|
||||
|
||||
const parseScriptResult = (rawResult: unknown, fallbackWindowSeconds: number): { count: number; retryAfterSeconds: number } => {
|
||||
const parseScriptResult = (
|
||||
rawResult: unknown,
|
||||
fallbackWindowSeconds: number,
|
||||
): { count: number; retryAfterSeconds: number } => {
|
||||
if (!Array.isArray(rawResult) || rawResult.length < 2) {
|
||||
throw new Error("Redis rate limit script returned an unexpected payload");
|
||||
}
|
||||
|
||||
const count = toPositiveInt(rawResult[0]);
|
||||
if (!count) {
|
||||
throw new Error("Redis rate limit script returned an invalid counter value");
|
||||
throw new Error(
|
||||
"Redis rate limit script returned an invalid counter value",
|
||||
);
|
||||
}
|
||||
|
||||
const ttl = toPositiveInt(rawResult[1]) ?? fallbackWindowSeconds;
|
||||
@@ -149,11 +178,12 @@ const parseScriptResult = (rawResult: unknown, fallbackWindowSeconds: number): {
|
||||
};
|
||||
|
||||
const toPositiveInt = (value: unknown): number | null => {
|
||||
const numeric = typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
const numeric =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return null;
|
||||
|
||||
@@ -9,7 +9,10 @@ export class RedisCommandJobPublisher implements CommandJobPublisher {
|
||||
) {}
|
||||
|
||||
public async publish(job: CommandExecutionJob): Promise<void> {
|
||||
await this.redis.lpush(this.resolveQueueName(job.botId), JSON.stringify(job));
|
||||
await this.redis.lpush(
|
||||
this.resolveQueueName(job.botId),
|
||||
JSON.stringify(job),
|
||||
);
|
||||
}
|
||||
|
||||
private resolveQueueName(botId: string): string {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { Pool } from "pg";
|
||||
|
||||
export interface LeaderCoordinator {
|
||||
runIfLeader(lockName: string, botId: string, task: () => Promise<void>): Promise<boolean>;
|
||||
runIfLeader(
|
||||
lockName: string,
|
||||
botId: string,
|
||||
task: () => Promise<void>,
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class LocalLeaderCoordinator implements LeaderCoordinator {
|
||||
public async runIfLeader(_lockName: string, _botId: string, task: () => Promise<void>): Promise<boolean> {
|
||||
public async runIfLeader(
|
||||
_lockName: string,
|
||||
_botId: string,
|
||||
task: () => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
await task();
|
||||
return true;
|
||||
}
|
||||
@@ -17,8 +25,14 @@ export class PostgresLeaderCoordinator implements LeaderCoordinator {
|
||||
private readonly namespace = "discord-bot",
|
||||
) {}
|
||||
|
||||
public async runIfLeader(lockName: string, botId: string, task: () => Promise<void>): Promise<boolean> {
|
||||
const advisoryLockKey = hashToInt32(`${this.namespace}:bot:${botId}:leader:${lockName}`);
|
||||
public async runIfLeader(
|
||||
lockName: string,
|
||||
botId: string,
|
||||
task: () => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
const advisoryLockKey = hashToInt32(
|
||||
`${this.namespace}:bot:${botId}:leader:${lockName}`,
|
||||
);
|
||||
const client = await this.pool.connect();
|
||||
|
||||
try {
|
||||
|
||||
@@ -28,13 +28,16 @@ export class PostgresLogEventStore implements LogEventRepository {
|
||||
await this.pool.query(logEventSchemaProbeSql);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"[db:init] missing or incompatible table \"bot_log_event_configs\". Run migrations with \"npm run migrate\".",
|
||||
'[db:init] missing or incompatible table "bot_log_event_configs". Run migrations with "npm run migrate".',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async listByBotGuild(botId: string, guildId: string): Promise<LogEventRow[]> {
|
||||
public async listByBotGuild(
|
||||
botId: string,
|
||||
guildId: string,
|
||||
): Promise<LogEventRow[]> {
|
||||
const result = await this.pool.query<LogEventRow>(
|
||||
`
|
||||
SELECT event_key, enabled, channel_id
|
||||
@@ -107,7 +110,13 @@ export class PostgresLogEventStore implements LogEventRepository {
|
||||
channel_id = EXCLUDED.channel_id,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[botId, guildId, entry.eventKey, entry.config.enabled, entry.config.channelId],
|
||||
[
|
||||
botId,
|
||||
guildId,
|
||||
entry.eventKey,
|
||||
entry.config.enabled,
|
||||
entry.config.channelId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,4 +135,4 @@ export class PostgresLogEventStore implements LogEventRepository {
|
||||
[botId, guildId],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ const parseRoleIds = (serialized: string | null): string[] => {
|
||||
return [];
|
||||
}
|
||||
|
||||
const roleIds = parsed.filter((value): value is string => typeof value === "string");
|
||||
const roleIds = parsed.filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
);
|
||||
return sanitizeMemberMessageRoleIds(roleIds);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -50,7 +52,9 @@ const toConfig = (row: MemberMessageRow): MemberMessageConfig => {
|
||||
return {
|
||||
enabled: row.enabled,
|
||||
channelId: row.channel_id,
|
||||
messageType: isMemberMessageRenderTypeValue(row.message_type) ? row.message_type : fallback.messageType,
|
||||
messageType: isMemberMessageRenderTypeValue(row.message_type)
|
||||
? row.message_type
|
||||
: fallback.messageType,
|
||||
autoRoleIds: parseRoleIds(row.auto_role_ids),
|
||||
};
|
||||
};
|
||||
@@ -63,13 +67,17 @@ export class PostgresMemberMessageStore implements MemberMessageRepository {
|
||||
await this.pool.query(memberMessageSchemaProbeSql);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"[db:init] missing or incompatible table \"bot_member_message_configs\". Run migrations with \"npm run migrate\".",
|
||||
'[db:init] missing or incompatible table "bot_member_message_configs". Run migrations with "npm run migrate".',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise<MemberMessageConfig> {
|
||||
public async getByBotGuildKind(
|
||||
botId: string,
|
||||
guildId: string,
|
||||
kind: MemberMessageKind,
|
||||
): Promise<MemberMessageConfig> {
|
||||
const result = await this.pool.query<MemberMessageRow>(
|
||||
`
|
||||
SELECT enabled, channel_id, message_type, auto_role_ids
|
||||
@@ -117,7 +125,15 @@ export class PostgresMemberMessageStore implements MemberMessageRepository {
|
||||
auto_role_ids = EXCLUDED.auto_role_ids,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[botId, guildId, kind, config.enabled, config.channelId, config.messageType, JSON.stringify(autoRoleIds)],
|
||||
[
|
||||
botId,
|
||||
guildId,
|
||||
kind,
|
||||
config.enabled,
|
||||
config.channelId,
|
||||
config.messageType,
|
||||
JSON.stringify(autoRoleIds),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,10 @@ FROM bot_presence_states
|
||||
LIMIT 0;
|
||||
`;
|
||||
|
||||
const parseStoredTexts = (rawTexts: string | null, fallbackText: string): string[] => {
|
||||
const parseStoredTexts = (
|
||||
rawTexts: string | null,
|
||||
fallbackText: string,
|
||||
): string[] => {
|
||||
if (typeof rawTexts === "string" && rawTexts.trim().length > 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawTexts) as unknown;
|
||||
@@ -53,7 +56,10 @@ const parseStoredTexts = (rawTexts: string | null, fallbackText: string): string
|
||||
};
|
||||
|
||||
const toPresenceState = (row: PresenceRow): PresenceState | null => {
|
||||
if (!isPresenceStatusValue(row.status) || !isPresenceActivityTypeValue(row.activity_type)) {
|
||||
if (
|
||||
!isPresenceStatusValue(row.status) ||
|
||||
!isPresenceActivityTypeValue(row.activity_type)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -81,7 +87,7 @@ export class PostgresPresenceStore implements PresenceRepository {
|
||||
await this.pool.query(presenceSchemaProbeSql);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"[db:init] missing or incompatible table \"bot_presence_states\". Run migrations with \"npm run migrate\".",
|
||||
'[db:init] missing or incompatible table "bot_presence_states". Run migrations with "npm run migrate".',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
@@ -101,10 +107,16 @@ export class PostgresPresenceStore implements PresenceRepository {
|
||||
return toPresenceState(row) ?? createDefaultPresenceState();
|
||||
}
|
||||
|
||||
public async upsertByBotId(botId: string, state: PresenceState): Promise<void> {
|
||||
public async upsertByBotId(
|
||||
botId: string,
|
||||
state: PresenceState,
|
||||
): Promise<void> {
|
||||
const activityTexts = sanitizeActivityTexts(state.activity.texts);
|
||||
const primaryText = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
|
||||
const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
|
||||
const primaryText =
|
||||
activityTexts[0] ?? sanitizeActivityText(state.activity.text);
|
||||
const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(
|
||||
state.activity.rotationIntervalSeconds,
|
||||
);
|
||||
|
||||
await this.pool.query(
|
||||
`
|
||||
|
||||
@@ -23,7 +23,10 @@ export const registerGuildDelete = (
|
||||
memberMessageService.cleanupGuild(botId, guild.id),
|
||||
logEventService.cleanupGuild(botId, guild.id),
|
||||
]).catch((error) => {
|
||||
log.error({ guildId: guild.id, botId, err: error }, "failed to cleanup guild config");
|
||||
log.error(
|
||||
{ guildId: guild.id, botId, err: error },
|
||||
"failed to cleanup guild config",
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -12,50 +12,58 @@ export const registerGuildMemberAdd = (
|
||||
memberMessageService: MemberMessageService,
|
||||
): void => {
|
||||
client.on(Events.GuildMemberAdd, (member) => {
|
||||
void memberMessageService.assignWelcomeAutoRoles({ client, member }).then((result) => {
|
||||
if (result.assigned) {
|
||||
return;
|
||||
}
|
||||
void memberMessageService
|
||||
.assignWelcomeAutoRoles({ client, member })
|
||||
.then((result) => {
|
||||
if (result.assigned) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.reason === "no_roles_configured" || result.reason === "no_assignable_roles") {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
result.reason === "no_roles_configured" ||
|
||||
result.reason === "no_assignable_roles"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.warn(
|
||||
{
|
||||
guildId: member.guild.id,
|
||||
userId: member.user.id,
|
||||
reason: result.reason,
|
||||
configuredRoleIds: result.configuredRoleIds,
|
||||
},
|
||||
"failed to assign welcome auto roles",
|
||||
);
|
||||
}).catch((error) => {
|
||||
log.error(
|
||||
{
|
||||
guildId: member.guild.id,
|
||||
userId: member.user.id,
|
||||
err: error,
|
||||
},
|
||||
"welcome auto-role dispatch crashed",
|
||||
);
|
||||
});
|
||||
log.warn(
|
||||
{
|
||||
guildId: member.guild.id,
|
||||
userId: member.user.id,
|
||||
reason: result.reason,
|
||||
configuredRoleIds: result.configuredRoleIds,
|
||||
},
|
||||
"failed to assign welcome auto roles",
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error(
|
||||
{
|
||||
guildId: member.guild.id,
|
||||
userId: member.user.id,
|
||||
err: error,
|
||||
},
|
||||
"welcome auto-role dispatch crashed",
|
||||
);
|
||||
});
|
||||
|
||||
void memberMessageService.dispatch({
|
||||
client,
|
||||
i18n,
|
||||
guild: member.guild,
|
||||
user: member.user,
|
||||
kind: "welcome",
|
||||
}).catch((error) => {
|
||||
log.error(
|
||||
{
|
||||
guildId: member.guild.id,
|
||||
userId: member.user.id,
|
||||
err: error,
|
||||
},
|
||||
"failed to send welcome message",
|
||||
);
|
||||
});
|
||||
void memberMessageService
|
||||
.dispatch({
|
||||
client,
|
||||
i18n,
|
||||
guild: member.guild,
|
||||
user: member.user,
|
||||
kind: "welcome",
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error(
|
||||
{
|
||||
guildId: member.guild.id,
|
||||
userId: member.user.id,
|
||||
err: error,
|
||||
},
|
||||
"failed to send welcome message",
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -12,21 +12,23 @@ export const registerGuildMemberRemove = (
|
||||
memberMessageService: MemberMessageService,
|
||||
): void => {
|
||||
client.on(Events.GuildMemberRemove, (member) => {
|
||||
void memberMessageService.dispatch({
|
||||
client,
|
||||
i18n,
|
||||
guild: member.guild,
|
||||
user: member.user,
|
||||
kind: "goodbye",
|
||||
}).catch((error) => {
|
||||
log.error(
|
||||
{
|
||||
guildId: member.guild.id,
|
||||
userId: member.user.id,
|
||||
err: error,
|
||||
},
|
||||
"failed to send goodbye message",
|
||||
);
|
||||
});
|
||||
void memberMessageService
|
||||
.dispatch({
|
||||
client,
|
||||
i18n,
|
||||
guild: member.guild,
|
||||
user: member.user,
|
||||
kind: "goodbye",
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error(
|
||||
{
|
||||
guildId: member.guild.id,
|
||||
userId: member.user.id,
|
||||
err: error,
|
||||
},
|
||||
"failed to send goodbye message",
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -34,7 +34,18 @@ export const registerEvents = (
|
||||
registerGuildMemberRemove(client, i18n, services.memberMessageService);
|
||||
|
||||
registerGuildCreate(client);
|
||||
registerGuildDelete(client, services.memberMessageService, services.logEventService);
|
||||
registerGuildDelete(
|
||||
client,
|
||||
services.memberMessageService,
|
||||
services.logEventService,
|
||||
);
|
||||
|
||||
registerClientReady(client, registry, i18n, services.presenceService, leaderCoordinator, runtimeAuth);
|
||||
registerClientReady(
|
||||
client,
|
||||
registry,
|
||||
i18n,
|
||||
services.presenceService,
|
||||
leaderCoordinator,
|
||||
runtimeAuth,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Events, type Client, type ChatInputCommandInteraction } from "discord.js";
|
||||
import {
|
||||
Events,
|
||||
type Client,
|
||||
type ChatInputCommandInteraction,
|
||||
} from "discord.js";
|
||||
import { createScopedLogger } from "../core/logging/logger.js";
|
||||
|
||||
const log = createScopedLogger("event:interactionCreate");
|
||||
@@ -11,7 +15,9 @@ const log = createScopedLogger("event:interactionCreate");
|
||||
*/
|
||||
export const registerInteractionCreate = (
|
||||
client: Client,
|
||||
onSlashInteraction: (interaction: ChatInputCommandInteraction) => Promise<void>,
|
||||
onSlashInteraction: (
|
||||
interaction: ChatInputCommandInteraction,
|
||||
) => Promise<void>,
|
||||
): void => {
|
||||
client.on(Events.InteractionCreate, (interaction) => {
|
||||
if (!interaction.isChatInputCommand()) {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Events,
|
||||
type Client,
|
||||
type Guild,
|
||||
type InviteGuild,
|
||||
} from "discord.js";
|
||||
import { Events, type Client, type Guild, type InviteGuild } from "discord.js";
|
||||
|
||||
import { createScopedLogger } from "../core/logging/logger.js";
|
||||
import type { I18nService } from "../i18n/index.js";
|
||||
@@ -26,7 +21,10 @@ const tForGuild = (
|
||||
key: string,
|
||||
vars: Record<string, string | number> = {},
|
||||
): string => {
|
||||
const preferredLocale = guild && "preferredLocale" in guild ? (guild.preferredLocale ?? null) : null;
|
||||
const preferredLocale =
|
||||
guild && "preferredLocale" in guild
|
||||
? (guild.preferredLocale ?? null)
|
||||
: null;
|
||||
const lang = i18n.resolveLang(preferredLocale);
|
||||
return i18n.t(lang, key, vars);
|
||||
};
|
||||
@@ -45,7 +43,11 @@ const formatContent = (
|
||||
guild: Guild | InviteGuild | null | undefined,
|
||||
content: string | null | undefined,
|
||||
): string => {
|
||||
const noContent = tForGuild(i18n, guild, "logsRuntime.placeholders.noContent");
|
||||
const noContent = tForGuild(
|
||||
i18n,
|
||||
guild,
|
||||
"logsRuntime.placeholders.noContent",
|
||||
);
|
||||
|
||||
if (typeof content !== "string") {
|
||||
return noContent;
|
||||
@@ -82,19 +84,30 @@ const emitRuntimeLog = (
|
||||
const title = tForGuild(i18n, input.guild, "logsRuntime.embed.title", {
|
||||
event: input.eventKey,
|
||||
});
|
||||
const detailsTitle = tForGuild(i18n, input.guild, "logsRuntime.embed.detailsField");
|
||||
const detailsTitle = tForGuild(
|
||||
i18n,
|
||||
input.guild,
|
||||
"logsRuntime.embed.detailsField",
|
||||
);
|
||||
|
||||
void logEventService.dispatchEvent(client, {
|
||||
eventKey: input.eventKey,
|
||||
guildId,
|
||||
summary: input.summary,
|
||||
...(input.details && input.details.length > 0 ? { details: input.details } : {}),
|
||||
...(input.color !== undefined ? { color: input.color } : {}),
|
||||
title,
|
||||
detailsTitle,
|
||||
}).catch((error) => {
|
||||
logger.error({ eventKey: input.eventKey, guildId, err: error }, "failed to dispatch runtime log event");
|
||||
});
|
||||
void logEventService
|
||||
.dispatchEvent(client, {
|
||||
eventKey: input.eventKey,
|
||||
guildId,
|
||||
summary: input.summary,
|
||||
...(input.details && input.details.length > 0
|
||||
? { details: input.details }
|
||||
: {}),
|
||||
...(input.color !== undefined ? { color: input.color } : {}),
|
||||
title,
|
||||
detailsTitle,
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
{ eventKey: input.eventKey, guildId, err: error },
|
||||
"failed to dispatch runtime log event",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const registerLogRuntimeEvents = (
|
||||
@@ -110,10 +123,15 @@ export const registerLogRuntimeEvents = (
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "messageCreate",
|
||||
guild: message.guild,
|
||||
summary: tForGuild(i18n, message.guild, "logsRuntime.summaries.messageCreate", {
|
||||
user: `<@${message.author.id}>`,
|
||||
channel: `<#${message.channelId}>`,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
message.guild,
|
||||
"logsRuntime.summaries.messageCreate",
|
||||
{
|
||||
user: `<@${message.author.id}>`,
|
||||
channel: `<#${message.channelId}>`,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, message.guild, "messageId", { value: message.id }),
|
||||
detailLine(i18n, message.guild, "author", {
|
||||
@@ -187,9 +205,14 @@ export const registerLogRuntimeEvents = (
|
||||
eventKey: "messageBulkDelete",
|
||||
guild,
|
||||
guildId: guild?.id,
|
||||
summary: tForGuild(i18n, guild, "logsRuntime.summaries.messageBulkDelete", {
|
||||
count: messages.size,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
guild,
|
||||
"logsRuntime.summaries.messageBulkDelete",
|
||||
{
|
||||
count: messages.size,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, guild, "channelId", {
|
||||
value: first?.channelId ?? unknown,
|
||||
@@ -204,9 +227,14 @@ export const registerLogRuntimeEvents = (
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "guildMemberAdd",
|
||||
guild: member.guild,
|
||||
summary: tForGuild(i18n, member.guild, "logsRuntime.summaries.guildMemberAdd", {
|
||||
user: `<@${member.user.id}>`,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
member.guild,
|
||||
"logsRuntime.summaries.guildMemberAdd",
|
||||
{
|
||||
user: `<@${member.user.id}>`,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, member.guild, "user", {
|
||||
tag: member.user.tag,
|
||||
@@ -221,9 +249,14 @@ export const registerLogRuntimeEvents = (
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "guildMemberRemove",
|
||||
guild: member.guild,
|
||||
summary: tForGuild(i18n, member.guild, "logsRuntime.summaries.guildMemberRemove", {
|
||||
user: `<@${member.user.id}>`,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
member.guild,
|
||||
"logsRuntime.summaries.guildMemberRemove",
|
||||
{
|
||||
user: `<@${member.user.id}>`,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, member.guild, "user", {
|
||||
tag: member.user.tag,
|
||||
@@ -235,14 +268,23 @@ export const registerLogRuntimeEvents = (
|
||||
});
|
||||
|
||||
client.on(Events.GuildMemberUpdate, (oldMember, newMember) => {
|
||||
const none = tForGuild(i18n, newMember.guild, "logsRuntime.placeholders.none");
|
||||
const none = tForGuild(
|
||||
i18n,
|
||||
newMember.guild,
|
||||
"logsRuntime.placeholders.none",
|
||||
);
|
||||
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "guildMemberUpdate",
|
||||
guild: newMember.guild,
|
||||
summary: tForGuild(i18n, newMember.guild, "logsRuntime.summaries.guildMemberUpdate", {
|
||||
user: `<@${newMember.user.id}>`,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
newMember.guild,
|
||||
"logsRuntime.summaries.guildMemberUpdate",
|
||||
{
|
||||
user: `<@${newMember.user.id}>`,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, newMember.guild, "user", {
|
||||
tag: newMember.user.tag,
|
||||
@@ -267,27 +309,52 @@ export const registerLogRuntimeEvents = (
|
||||
const guild = interaction.guild;
|
||||
const unknown = tForGuild(i18n, guild, "logsRuntime.placeholders.unknown");
|
||||
|
||||
let summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionGeneric", {
|
||||
user: `<@${interaction.user.id}>`,
|
||||
});
|
||||
let summary = tForGuild(
|
||||
i18n,
|
||||
guild,
|
||||
"logsRuntime.summaries.interactionGeneric",
|
||||
{
|
||||
user: `<@${interaction.user.id}>`,
|
||||
},
|
||||
);
|
||||
|
||||
if (interaction.isChatInputCommand()) {
|
||||
summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionSlash", {
|
||||
command: interaction.commandName,
|
||||
user: `<@${interaction.user.id}>`,
|
||||
});
|
||||
summary = tForGuild(
|
||||
i18n,
|
||||
guild,
|
||||
"logsRuntime.summaries.interactionSlash",
|
||||
{
|
||||
command: interaction.commandName,
|
||||
user: `<@${interaction.user.id}>`,
|
||||
},
|
||||
);
|
||||
} else if (interaction.isButton()) {
|
||||
summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionButton", {
|
||||
user: `<@${interaction.user.id}>`,
|
||||
});
|
||||
summary = tForGuild(
|
||||
i18n,
|
||||
guild,
|
||||
"logsRuntime.summaries.interactionButton",
|
||||
{
|
||||
user: `<@${interaction.user.id}>`,
|
||||
},
|
||||
);
|
||||
} else if (interaction.isStringSelectMenu()) {
|
||||
summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionSelect", {
|
||||
user: `<@${interaction.user.id}>`,
|
||||
});
|
||||
summary = tForGuild(
|
||||
i18n,
|
||||
guild,
|
||||
"logsRuntime.summaries.interactionSelect",
|
||||
{
|
||||
user: `<@${interaction.user.id}>`,
|
||||
},
|
||||
);
|
||||
} else if (interaction.isModalSubmit()) {
|
||||
summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionModal", {
|
||||
user: `<@${interaction.user.id}>`,
|
||||
});
|
||||
summary = tForGuild(
|
||||
i18n,
|
||||
guild,
|
||||
"logsRuntime.summaries.interactionModal",
|
||||
{
|
||||
user: `<@${interaction.user.id}>`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
@@ -313,9 +380,14 @@ export const registerLogRuntimeEvents = (
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "channelCreate",
|
||||
guild: channel.guild,
|
||||
summary: tForGuild(i18n, channel.guild, "logsRuntime.summaries.channelCreate", {
|
||||
channel: `#${channel.name}`,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
channel.guild,
|
||||
"logsRuntime.summaries.channelCreate",
|
||||
{
|
||||
channel: `#${channel.name}`,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, channel.guild, "channelId", { value: channel.id }),
|
||||
detailLine(i18n, channel.guild, "type", { value: channel.type }),
|
||||
@@ -332,9 +404,14 @@ export const registerLogRuntimeEvents = (
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "channelDelete",
|
||||
guild: channel.guild,
|
||||
summary: tForGuild(i18n, channel.guild, "logsRuntime.summaries.channelDelete", {
|
||||
channel: `#${channel.name}`,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
channel.guild,
|
||||
"logsRuntime.summaries.channelDelete",
|
||||
{
|
||||
channel: `#${channel.name}`,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, channel.guild, "channelId", { value: channel.id }),
|
||||
detailLine(i18n, channel.guild, "type", { value: channel.type }),
|
||||
@@ -348,19 +425,32 @@ export const registerLogRuntimeEvents = (
|
||||
return;
|
||||
}
|
||||
|
||||
const unknown = tForGuild(i18n, newChannel.guild, "logsRuntime.placeholders.unknown");
|
||||
const unknown = tForGuild(
|
||||
i18n,
|
||||
newChannel.guild,
|
||||
"logsRuntime.placeholders.unknown",
|
||||
);
|
||||
const oldName = "name" in oldChannel ? oldChannel.name : unknown;
|
||||
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "channelUpdate",
|
||||
guild: newChannel.guild,
|
||||
summary: tForGuild(i18n, newChannel.guild, "logsRuntime.summaries.channelUpdate", {
|
||||
channel: `#${newChannel.name}`,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
newChannel.guild,
|
||||
"logsRuntime.summaries.channelUpdate",
|
||||
{
|
||||
channel: `#${newChannel.name}`,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, newChannel.guild, "channelId", { value: newChannel.id }),
|
||||
detailLine(i18n, newChannel.guild, "channelId", {
|
||||
value: newChannel.id,
|
||||
}),
|
||||
detailLine(i18n, newChannel.guild, "oldName", { value: oldName }),
|
||||
detailLine(i18n, newChannel.guild, "newName", { value: newChannel.name }),
|
||||
detailLine(i18n, newChannel.guild, "newName", {
|
||||
value: newChannel.name,
|
||||
}),
|
||||
],
|
||||
color: 0xfee75c,
|
||||
});
|
||||
@@ -394,9 +484,14 @@ export const registerLogRuntimeEvents = (
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "roleUpdate",
|
||||
guild: newRole.guild,
|
||||
summary: tForGuild(i18n, newRole.guild, "logsRuntime.summaries.roleUpdate", {
|
||||
role: `@${newRole.name}`,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
newRole.guild,
|
||||
"logsRuntime.summaries.roleUpdate",
|
||||
{
|
||||
role: `@${newRole.name}`,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, newRole.guild, "roleId", { value: newRole.id }),
|
||||
detailLine(i18n, newRole.guild, "oldName", { value: oldRole.name }),
|
||||
@@ -411,10 +506,19 @@ export const registerLogRuntimeEvents = (
|
||||
eventKey: "threadCreate",
|
||||
guild: thread.guild ?? null,
|
||||
guildId: thread.guild?.id,
|
||||
summary: tForGuild(i18n, thread.guild ?? null, "logsRuntime.summaries.threadCreate", {
|
||||
thread: thread.name,
|
||||
}),
|
||||
details: [detailLine(i18n, thread.guild ?? null, "threadId", { value: thread.id })],
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
thread.guild ?? null,
|
||||
"logsRuntime.summaries.threadCreate",
|
||||
{
|
||||
thread: thread.name,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, thread.guild ?? null, "threadId", {
|
||||
value: thread.id,
|
||||
}),
|
||||
],
|
||||
color: 0x57f287,
|
||||
});
|
||||
});
|
||||
@@ -424,10 +528,19 @@ export const registerLogRuntimeEvents = (
|
||||
eventKey: "threadDelete",
|
||||
guild: thread.guild ?? null,
|
||||
guildId: thread.guild?.id,
|
||||
summary: tForGuild(i18n, thread.guild ?? null, "logsRuntime.summaries.threadDelete", {
|
||||
thread: thread.name,
|
||||
}),
|
||||
details: [detailLine(i18n, thread.guild ?? null, "threadId", { value: thread.id })],
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
thread.guild ?? null,
|
||||
"logsRuntime.summaries.threadDelete",
|
||||
{
|
||||
thread: thread.name,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, thread.guild ?? null, "threadId", {
|
||||
value: thread.id,
|
||||
}),
|
||||
],
|
||||
color: 0xed4245,
|
||||
});
|
||||
});
|
||||
@@ -437,28 +550,48 @@ export const registerLogRuntimeEvents = (
|
||||
eventKey: "threadUpdate",
|
||||
guild: newThread.guild ?? null,
|
||||
guildId: newThread.guild?.id,
|
||||
summary: tForGuild(i18n, newThread.guild ?? null, "logsRuntime.summaries.threadUpdate", {
|
||||
thread: newThread.name,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
newThread.guild ?? null,
|
||||
"logsRuntime.summaries.threadUpdate",
|
||||
{
|
||||
thread: newThread.name,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, newThread.guild ?? null, "threadId", { value: newThread.id }),
|
||||
detailLine(i18n, newThread.guild ?? null, "oldName", { value: oldThread.name }),
|
||||
detailLine(i18n, newThread.guild ?? null, "newName", { value: newThread.name }),
|
||||
detailLine(i18n, newThread.guild ?? null, "threadId", {
|
||||
value: newThread.id,
|
||||
}),
|
||||
detailLine(i18n, newThread.guild ?? null, "oldName", {
|
||||
value: oldThread.name,
|
||||
}),
|
||||
detailLine(i18n, newThread.guild ?? null, "newName", {
|
||||
value: newThread.name,
|
||||
}),
|
||||
],
|
||||
color: 0xfee75c,
|
||||
});
|
||||
});
|
||||
|
||||
client.on(Events.GuildEmojiCreate, (emoji) => {
|
||||
const unknown = tForGuild(i18n, emoji.guild ?? null, "logsRuntime.placeholders.unknown");
|
||||
const unknown = tForGuild(
|
||||
i18n,
|
||||
emoji.guild ?? null,
|
||||
"logsRuntime.placeholders.unknown",
|
||||
);
|
||||
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "emojiCreate",
|
||||
guild: emoji.guild ?? null,
|
||||
guildId: emoji.guild?.id,
|
||||
summary: tForGuild(i18n, emoji.guild ?? null, "logsRuntime.summaries.emojiCreate", {
|
||||
emoji: emoji.name ?? unknown,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
emoji.guild ?? null,
|
||||
"logsRuntime.summaries.emojiCreate",
|
||||
{
|
||||
emoji: emoji.name ?? unknown,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, emoji.guild ?? null, "emojiId", {
|
||||
value: emoji.id ?? unknown,
|
||||
@@ -469,15 +602,24 @@ export const registerLogRuntimeEvents = (
|
||||
});
|
||||
|
||||
client.on(Events.GuildEmojiDelete, (emoji) => {
|
||||
const unknown = tForGuild(i18n, emoji.guild ?? null, "logsRuntime.placeholders.unknown");
|
||||
const unknown = tForGuild(
|
||||
i18n,
|
||||
emoji.guild ?? null,
|
||||
"logsRuntime.placeholders.unknown",
|
||||
);
|
||||
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "emojiDelete",
|
||||
guild: emoji.guild ?? null,
|
||||
guildId: emoji.guild?.id,
|
||||
summary: tForGuild(i18n, emoji.guild ?? null, "logsRuntime.summaries.emojiDelete", {
|
||||
emoji: emoji.name ?? unknown,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
emoji.guild ?? null,
|
||||
"logsRuntime.summaries.emojiDelete",
|
||||
{
|
||||
emoji: emoji.name ?? unknown,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, emoji.guild ?? null, "emojiId", {
|
||||
value: emoji.id ?? unknown,
|
||||
@@ -488,15 +630,24 @@ export const registerLogRuntimeEvents = (
|
||||
});
|
||||
|
||||
client.on(Events.GuildEmojiUpdate, (oldEmoji, newEmoji) => {
|
||||
const unknown = tForGuild(i18n, newEmoji.guild ?? null, "logsRuntime.placeholders.unknown");
|
||||
const unknown = tForGuild(
|
||||
i18n,
|
||||
newEmoji.guild ?? null,
|
||||
"logsRuntime.placeholders.unknown",
|
||||
);
|
||||
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "emojiUpdate",
|
||||
guild: newEmoji.guild ?? null,
|
||||
guildId: newEmoji.guild?.id,
|
||||
summary: tForGuild(i18n, newEmoji.guild ?? null, "logsRuntime.summaries.emojiUpdate", {
|
||||
emoji: newEmoji.name ?? unknown,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
newEmoji.guild ?? null,
|
||||
"logsRuntime.summaries.emojiUpdate",
|
||||
{
|
||||
emoji: newEmoji.name ?? unknown,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, newEmoji.guild ?? null, "emojiId", {
|
||||
value: newEmoji.id ?? unknown,
|
||||
@@ -532,9 +683,14 @@ export const registerLogRuntimeEvents = (
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "guildUnavailable",
|
||||
guild,
|
||||
summary: tForGuild(i18n, guild, "logsRuntime.summaries.guildUnavailable", {
|
||||
guild: guild.name,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
guild,
|
||||
"logsRuntime.summaries.guildUnavailable",
|
||||
{
|
||||
guild: guild.name,
|
||||
},
|
||||
),
|
||||
details: [detailLine(i18n, guild, "guildId", { value: guild.id })],
|
||||
color: 0xed4245,
|
||||
});
|
||||
@@ -562,9 +718,14 @@ export const registerLogRuntimeEvents = (
|
||||
emitRuntimeLog(client, logEventService, i18n, {
|
||||
eventKey: "guildBanRemove",
|
||||
guild: ban.guild,
|
||||
summary: tForGuild(i18n, ban.guild, "logsRuntime.summaries.guildBanRemove", {
|
||||
user: `<@${ban.user.id}>`,
|
||||
}),
|
||||
summary: tForGuild(
|
||||
i18n,
|
||||
ban.guild,
|
||||
"logsRuntime.summaries.guildBanRemove",
|
||||
{
|
||||
user: `<@${ban.user.id}>`,
|
||||
},
|
||||
),
|
||||
details: [
|
||||
detailLine(i18n, ban.guild, "user", {
|
||||
tag: ban.user.tag,
|
||||
|
||||
@@ -9,7 +9,10 @@ const log = createScopedLogger("event:messageCreate");
|
||||
* @param client - instance du Client Discord
|
||||
* @param onPrefixMessage - fonction à appeler pour traiter les messages (préfixe)
|
||||
*/
|
||||
export const registerMessageCreate = (client: Client, onPrefixMessage: (message: Message) => Promise<void>): void => {
|
||||
export const registerMessageCreate = (
|
||||
client: Client,
|
||||
onPrefixMessage: (message: Message) => Promise<void>,
|
||||
): void => {
|
||||
client.on(Events.MessageCreate, (message: Message) => {
|
||||
void onPrefixMessage(message).catch((error) => {
|
||||
log.error({ err: error }, "handler failed");
|
||||
|
||||
@@ -28,17 +28,25 @@ export const registerClientReady = (
|
||||
|
||||
const botId = client.user?.id;
|
||||
if (!botId) {
|
||||
log.error("client ready event received without bot id, leader-only tasks skipped");
|
||||
log.error(
|
||||
"client ready event received without bot id, leader-only tasks skipped",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const restoredByLeader = await leaderCoordinator.runIfLeader("presence-restore", botId, async () => {
|
||||
await presenceService.restoreFromStorage(client);
|
||||
});
|
||||
const restoredByLeader = await leaderCoordinator.runIfLeader(
|
||||
"presence-restore",
|
||||
botId,
|
||||
async () => {
|
||||
await presenceService.restoreFromStorage(client);
|
||||
},
|
||||
);
|
||||
|
||||
if (!restoredByLeader) {
|
||||
log.info("presence restore skipped: leader lock already held by another instance");
|
||||
log.info(
|
||||
"presence restore skipped: leader lock already held by another instance",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error({ err: error }, "failed to restore bot presence");
|
||||
@@ -46,31 +54,39 @@ export const registerClientReady = (
|
||||
|
||||
if (env.AUTO_DEPLOY_SLASH) {
|
||||
if (!runtimeAuth) {
|
||||
log.warn("slash sync skipped: runtime auth is missing for this bot instance");
|
||||
log.warn(
|
||||
"slash sync skipped: runtime auth is missing for this bot instance",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", botId, async () => {
|
||||
const result = await deployApplicationCommands({
|
||||
token: runtimeAuth.token,
|
||||
clientId: runtimeAuth.clientId,
|
||||
registry,
|
||||
i18n,
|
||||
...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}),
|
||||
});
|
||||
const deployedByLeader = await leaderCoordinator.runIfLeader(
|
||||
"slash-deploy",
|
||||
botId,
|
||||
async () => {
|
||||
const result = await deployApplicationCommands({
|
||||
token: runtimeAuth.token,
|
||||
clientId: runtimeAuth.clientId,
|
||||
registry,
|
||||
i18n,
|
||||
...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}),
|
||||
});
|
||||
|
||||
log.info(
|
||||
{
|
||||
scope: result.scope,
|
||||
count: result.count,
|
||||
},
|
||||
"slash sync completed",
|
||||
);
|
||||
});
|
||||
log.info(
|
||||
{
|
||||
scope: result.scope,
|
||||
count: result.count,
|
||||
},
|
||||
"slash sync completed",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!deployedByLeader) {
|
||||
log.info("slash sync skipped: leader lock already held by another instance");
|
||||
log.info(
|
||||
"slash sync skipped: leader lock already held by another instance",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error({ err: error }, "slash sync failed");
|
||||
|
||||
@@ -32,23 +32,28 @@ export const LOG_EVENT_DEFINITIONS: readonly LogEventDefinition[] = [
|
||||
{ key: "inviteDelete", category: "invite" },
|
||||
];
|
||||
|
||||
export const LOG_EVENT_KEYS = LOG_EVENT_DEFINITIONS.map((definition) => definition.key);
|
||||
export const LOG_EVENT_KEYS = LOG_EVENT_DEFINITIONS.map(
|
||||
(definition) => definition.key,
|
||||
);
|
||||
|
||||
export const LOG_EVENT_KEYS_SET = new Set(LOG_EVENT_KEYS);
|
||||
|
||||
export const LOG_CHANNEL_NAME_BY_CATEGORY: Record<LogEventCategoryKey, string> = {
|
||||
message: "logs-message",
|
||||
member: "logs-member",
|
||||
interaction: "logs-interaction",
|
||||
channel: "logs-channel",
|
||||
role: "logs-role",
|
||||
thread: "logs-thread",
|
||||
emoji: "logs-emoji",
|
||||
guild: "logs-guild",
|
||||
moderation: "logs-moderation",
|
||||
invite: "logs-invite",
|
||||
};
|
||||
export const LOG_CHANNEL_NAME_BY_CATEGORY: Record<LogEventCategoryKey, string> =
|
||||
{
|
||||
message: "logs-message",
|
||||
member: "logs-member",
|
||||
interaction: "logs-interaction",
|
||||
channel: "logs-channel",
|
||||
role: "logs-role",
|
||||
thread: "logs-thread",
|
||||
emoji: "logs-emoji",
|
||||
guild: "logs-guild",
|
||||
moderation: "logs-moderation",
|
||||
invite: "logs-invite",
|
||||
};
|
||||
|
||||
export const LOG_EVENT_CATEGORY_KEYS = Object.keys(LOG_CHANNEL_NAME_BY_CATEGORY) as LogEventCategoryKey[];
|
||||
export const LOG_EVENT_CATEGORY_KEYS = Object.keys(
|
||||
LOG_CHANNEL_NAME_BY_CATEGORY,
|
||||
) as LogEventCategoryKey[];
|
||||
|
||||
export const LOGS_PANEL_EVENTS_PER_PAGE = 15;
|
||||
export const LOGS_PANEL_EVENTS_PER_PAGE = 15;
|
||||
|
||||
@@ -70,7 +70,11 @@ const clampPageIndex = (index: number, pageCount: number): number => {
|
||||
return index;
|
||||
};
|
||||
|
||||
const pageCount = (): number => Math.max(1, Math.ceil(LOG_EVENT_DEFINITIONS.length / LOGS_PANEL_EVENTS_PER_PAGE));
|
||||
const pageCount = (): number =>
|
||||
Math.max(
|
||||
1,
|
||||
Math.ceil(LOG_EVENT_DEFINITIONS.length / LOGS_PANEL_EVENTS_PER_PAGE),
|
||||
);
|
||||
|
||||
const pageEvents = (uiState: LogPanelUiState): LogEventDefinition[] => {
|
||||
const totalPages = pageCount();
|
||||
@@ -79,11 +83,17 @@ const pageEvents = (uiState: LogPanelUiState): LogEventDefinition[] => {
|
||||
return LOG_EVENT_DEFINITIONS.slice(start, start + LOGS_PANEL_EVENTS_PER_PAGE);
|
||||
};
|
||||
|
||||
const eventStatusLabel = (ctx: CommandExecutionContext, enabled: boolean): string => {
|
||||
const eventStatusLabel = (
|
||||
ctx: CommandExecutionContext,
|
||||
enabled: boolean,
|
||||
): string => {
|
||||
return enabled ? ctx.ct("ui.status.enabled") : ctx.ct("ui.status.disabled");
|
||||
};
|
||||
|
||||
const eventLabel = (ctx: CommandExecutionContext, eventKey: LogEventKey): string => {
|
||||
const eventLabel = (
|
||||
ctx: CommandExecutionContext,
|
||||
eventKey: LogEventKey,
|
||||
): string => {
|
||||
return ctx.ct(`ui.events.${eventKey}`);
|
||||
};
|
||||
|
||||
@@ -94,22 +104,34 @@ const panelContent = (
|
||||
): string => {
|
||||
const totalPages = pageCount();
|
||||
const currentEvents = pageEvents(uiState);
|
||||
const enabledCount = LOG_EVENT_KEYS.filter((eventKey) => state[eventKey].enabled).length;
|
||||
const enabledCount = LOG_EVENT_KEYS.filter(
|
||||
(eventKey) => state[eventKey].enabled,
|
||||
).length;
|
||||
|
||||
const lines = [
|
||||
`## ${ctx.ct("ui.embed.title")}`,
|
||||
ctx.ct("ui.embed.description"),
|
||||
"",
|
||||
ctx.ct("ui.pageLabel", { page: uiState.pageIndex + 1, pageCount: totalPages }),
|
||||
ctx.ct("ui.enabledCountLabel", { enabledCount, totalCount: LOG_EVENT_KEYS.length }),
|
||||
ctx.ct("ui.pageLabel", {
|
||||
page: uiState.pageIndex + 1,
|
||||
pageCount: totalPages,
|
||||
}),
|
||||
ctx.ct("ui.enabledCountLabel", {
|
||||
enabledCount,
|
||||
totalCount: LOG_EVENT_KEYS.length,
|
||||
}),
|
||||
"",
|
||||
`${ctx.ct("ui.eventsHeader")}:`,
|
||||
];
|
||||
|
||||
for (const definition of currentEvents) {
|
||||
const eventConfig = state[definition.key];
|
||||
const channelDisplay = eventConfig.channelId ? `<#${eventConfig.channelId}>` : ctx.ct("ui.channelNotConfigured");
|
||||
lines.push(`- ${eventLabel(ctx, definition.key)} | ${eventStatusLabel(ctx, eventConfig.enabled)} | ${ctx.ct("ui.channelLabel")}: ${channelDisplay}`);
|
||||
const channelDisplay = eventConfig.channelId
|
||||
? `<#${eventConfig.channelId}>`
|
||||
: ctx.ct("ui.channelNotConfigured");
|
||||
lines.push(
|
||||
`- ${eventLabel(ctx, definition.key)} | ${eventStatusLabel(ctx, eventConfig.enabled)} | ${ctx.ct("ui.channelLabel")}: ${channelDisplay}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.feedback) {
|
||||
@@ -160,9 +182,15 @@ const buildContainer = (
|
||||
.setDisabled(disabled || uiState.pageIndex >= totalPages - 1);
|
||||
|
||||
const container = new ContainerBuilder();
|
||||
container.addTextDisplayComponents(new TextDisplayBuilder().setContent(panelContent(ctx, state, uiState)));
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(panelContent(ctx, state, uiState)),
|
||||
);
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(enableAllButton, disableAllButton, createChannelsButton),
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
enableAllButton,
|
||||
disableAllButton,
|
||||
createChannelsButton,
|
||||
),
|
||||
);
|
||||
|
||||
for (const definitionChunk of chunk(currentEvents, 5)) {
|
||||
@@ -176,15 +204,22 @@ const buildContainer = (
|
||||
return new ButtonBuilder()
|
||||
.setCustomId(customIds.toggleButtonsByEvent[definition.key])
|
||||
.setLabel(label)
|
||||
.setStyle(eventConfig.enabled ? ButtonStyle.Danger : ButtonStyle.Success)
|
||||
.setStyle(
|
||||
eventConfig.enabled ? ButtonStyle.Danger : ButtonStyle.Success,
|
||||
)
|
||||
.setDisabled(disabled);
|
||||
});
|
||||
|
||||
container.addActionRowComponents(new ActionRowBuilder<ButtonBuilder>().addComponents(...rowButtons));
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(...rowButtons),
|
||||
);
|
||||
}
|
||||
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(previousPageButton, nextPageButton),
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
previousPageButton,
|
||||
nextPageButton,
|
||||
),
|
||||
);
|
||||
|
||||
return container;
|
||||
@@ -196,15 +231,19 @@ const applyAllEnabled = (state: LogEventStateByKey, enabled: boolean): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const toToggleLookup = (customIds: LogPanelCustomIds): Map<string, LogEventKey> => {
|
||||
const toToggleLookup = (
|
||||
customIds: LogPanelCustomIds,
|
||||
): Map<string, LogEventKey> => {
|
||||
return new Map(
|
||||
LOG_EVENT_KEYS.map((eventKey) => [customIds.toggleButtonsByEvent[eventKey], eventKey]),
|
||||
LOG_EVENT_KEYS.map((eventKey) => [
|
||||
customIds.toggleButtonsByEvent[eventKey],
|
||||
eventKey,
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
||||
return async (ctx: CommandExecutionContext): Promise<void> => {
|
||||
|
||||
if (!ctx.guild) {
|
||||
await ctx.reply(ctx.ct("responses.guildOnly"));
|
||||
return;
|
||||
@@ -232,7 +271,11 @@ export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
||||
}
|
||||
|
||||
const ownerId = ctx.user.id;
|
||||
const sessionKey = logEventService.panelSessionKey(ctx.client, guild.id, ownerId);
|
||||
const sessionKey = logEventService.panelSessionKey(
|
||||
ctx.client,
|
||||
guild.id,
|
||||
ownerId,
|
||||
);
|
||||
|
||||
const disablePanel = async (): Promise<void> => {
|
||||
await replyMessage
|
||||
@@ -243,8 +286,13 @@ export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
|
||||
await logEventService.replacePanelSession(sessionKey, { collector, disable: disablePanel });
|
||||
const collector = replyMessage.createMessageComponentCollector({
|
||||
time: 15 * 60_000,
|
||||
});
|
||||
await logEventService.replacePanelSession(sessionKey, {
|
||||
collector,
|
||||
disable: disablePanel,
|
||||
});
|
||||
|
||||
collector.on("collect", async (interaction) => {
|
||||
if (interaction.user.id !== ownerId) {
|
||||
@@ -289,7 +337,11 @@ export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
||||
if (interaction.customId === customIds.createChannelsButton) {
|
||||
await interaction.deferUpdate();
|
||||
|
||||
const result = await logEventService.createCategoryChannels(ctx.client, guild, state);
|
||||
const result = await logEventService.createCategoryChannels(
|
||||
ctx.client,
|
||||
guild,
|
||||
state,
|
||||
);
|
||||
if (result.failedCategories.length === 0) {
|
||||
uiState.feedback = ctx.ct("responses.channelsCreated", {
|
||||
created: result.createdCount,
|
||||
@@ -356,17 +408,21 @@ export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
||||
logger.error({ err: error }, "interaction failed");
|
||||
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({
|
||||
content: ctx.t("errors.execution"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
}).catch(() => undefined);
|
||||
await interaction
|
||||
.reply({
|
||||
content: ctx.t("errors.execution"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.followUp({
|
||||
content: ctx.t("errors.execution"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
}).catch(() => undefined);
|
||||
await interaction
|
||||
.followUp({
|
||||
content: ctx.t("errors.execution"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -375,4 +431,4 @@ export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
||||
await disablePanel();
|
||||
});
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,16 @@ import type {
|
||||
|
||||
export interface LogEventRepository {
|
||||
listByBotGuild(botId: string, guildId: string): Promise<LogEventRow[]>;
|
||||
upsertByBotGuildEvent(botId: string, guildId: string, eventKey: LogEventKey, config: LogEventConfig): Promise<void>;
|
||||
upsertManyByBotGuildEvents(botId: string, guildId: string, entries: readonly LogEventRepositoryEntry[]): Promise<void>;
|
||||
upsertByBotGuildEvent(
|
||||
botId: string,
|
||||
guildId: string,
|
||||
eventKey: LogEventKey,
|
||||
config: LogEventConfig,
|
||||
): Promise<void>;
|
||||
upsertManyByBotGuildEvents(
|
||||
botId: string,
|
||||
guildId: string,
|
||||
entries: readonly LogEventRepositoryEntry[],
|
||||
): Promise<void>;
|
||||
deleteByBotGuild(botId: string, guildId: string): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,22 +34,33 @@ const LOG_CHANNEL_CATEGORY_NAME = "logs";
|
||||
const LOG_CHANNEL_NAME_PREFIX = "";
|
||||
const LEGACY_LOG_CHANNEL_NAME_PREFIX = "📁・";
|
||||
|
||||
const hasSendMethod = (value: unknown): value is { send: (payload: unknown) => Promise<unknown> } => {
|
||||
const hasSendMethod = (
|
||||
value: unknown,
|
||||
): value is { send: (payload: unknown) => Promise<unknown> } => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return "send" in value && typeof (value as { send?: unknown }).send === "function";
|
||||
return (
|
||||
"send" in value && typeof (value as { send?: unknown }).send === "function"
|
||||
);
|
||||
};
|
||||
|
||||
const hasPermissionsFor = (value: unknown): value is {
|
||||
permissionsFor: (member: unknown) => { has: (permission: unknown) => boolean } | null;
|
||||
const hasPermissionsFor = (
|
||||
value: unknown,
|
||||
): value is {
|
||||
permissionsFor: (
|
||||
member: unknown,
|
||||
) => { has: (permission: unknown) => boolean } | null;
|
||||
} => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return "permissionsFor" in value && typeof (value as { permissionsFor?: unknown }).permissionsFor === "function";
|
||||
return (
|
||||
"permissionsFor" in value &&
|
||||
typeof (value as { permissionsFor?: unknown }).permissionsFor === "function"
|
||||
);
|
||||
};
|
||||
|
||||
const clampField = (value: string, maxLength: number): string => {
|
||||
@@ -66,7 +77,8 @@ const buildChannelName = (category: LogEventCategoryKey): string => {
|
||||
|
||||
export class LogEventService {
|
||||
private readonly stateCache = new Map<string, LogEventStateByKey>();
|
||||
private readonly panelSessions = new ComponentSessionRegistry<LogPanelSession>();
|
||||
private readonly panelSessions =
|
||||
new ComponentSessionRegistry<LogPanelSession>();
|
||||
|
||||
public constructor(private readonly repository: LogEventRepository) {}
|
||||
|
||||
@@ -74,19 +86,32 @@ export class LogEventService {
|
||||
return client.user?.id ?? null;
|
||||
}
|
||||
|
||||
public panelSessionKey(client: Client, guildId: string, userId: string): string {
|
||||
public panelSessionKey(
|
||||
client: Client,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
): string {
|
||||
return `${this.resolveBotId(client) ?? "unbound"}:${guildId}:${userId}`;
|
||||
}
|
||||
|
||||
public async replacePanelSession(key: string, session: LogPanelSession): Promise<void> {
|
||||
public async replacePanelSession(
|
||||
key: string,
|
||||
session: LogPanelSession,
|
||||
): Promise<void> {
|
||||
await this.panelSessions.replace(key, session);
|
||||
}
|
||||
|
||||
public deletePanelSessionIfCollectorMatch(key: string, collector: LogPanelSession["collector"]): void {
|
||||
public deletePanelSessionIfCollectorMatch(
|
||||
key: string,
|
||||
collector: LogPanelSession["collector"],
|
||||
): void {
|
||||
this.panelSessions.deleteIfCollectorMatch(key, collector);
|
||||
}
|
||||
|
||||
public async loadGuildState(client: Client, guildId: string): Promise<LogEventStateByKey> {
|
||||
public async loadGuildState(
|
||||
client: Client,
|
||||
guildId: string,
|
||||
): Promise<LogEventStateByKey> {
|
||||
const botId = this.resolveBotId(client);
|
||||
if (!botId) {
|
||||
return createDefaultLogEventState();
|
||||
@@ -105,22 +130,31 @@ export class LogEventService {
|
||||
return cloneLogEventState(state);
|
||||
}
|
||||
|
||||
public async persistGuildState(client: Client, guildId: string, state: LogEventStateByKey): Promise<void> {
|
||||
public async persistGuildState(
|
||||
client: Client,
|
||||
guildId: string,
|
||||
state: LogEventStateByKey,
|
||||
): Promise<void> {
|
||||
const botId = this.resolveBotId(client);
|
||||
if (!botId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries: LogEventRepositoryEntry[] = LOG_EVENT_KEYS.map((eventKey) => ({
|
||||
eventKey,
|
||||
config: {
|
||||
enabled: state[eventKey].enabled,
|
||||
channelId: state[eventKey].channelId,
|
||||
},
|
||||
}));
|
||||
const entries: LogEventRepositoryEntry[] = LOG_EVENT_KEYS.map(
|
||||
(eventKey) => ({
|
||||
eventKey,
|
||||
config: {
|
||||
enabled: state[eventKey].enabled,
|
||||
channelId: state[eventKey].channelId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await this.repository.upsertManyByBotGuildEvents(botId, guildId, entries);
|
||||
this.stateCache.set(this.cacheKey(botId, guildId), cloneLogEventState(state));
|
||||
this.stateCache.set(
|
||||
this.cacheKey(botId, guildId),
|
||||
cloneLogEventState(state),
|
||||
);
|
||||
}
|
||||
|
||||
public async createCategoryChannels(
|
||||
@@ -139,7 +173,10 @@ export class LogEventService {
|
||||
return false;
|
||||
}
|
||||
|
||||
return channel.type === ChannelType.GuildCategory && channel.name === LOG_CHANNEL_CATEGORY_NAME;
|
||||
return (
|
||||
channel.type === ChannelType.GuildCategory &&
|
||||
channel.name === LOG_CHANNEL_CATEGORY_NAME
|
||||
);
|
||||
});
|
||||
|
||||
if (!logCategory) {
|
||||
@@ -149,7 +186,10 @@ export class LogEventService {
|
||||
type: ChannelType.GuildCategory,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ guildId: guild.id, err: error }, "failed to create logs category");
|
||||
logger.warn(
|
||||
{ guildId: guild.id, err: error },
|
||||
"failed to create logs category",
|
||||
);
|
||||
return {
|
||||
createdCount,
|
||||
reusedCount,
|
||||
@@ -167,34 +207,45 @@ export class LogEventService {
|
||||
return false;
|
||||
}
|
||||
|
||||
return channel.type === ChannelType.GuildText && channel.name === expectedName;
|
||||
return (
|
||||
channel.type === ChannelType.GuildText &&
|
||||
channel.name === expectedName
|
||||
);
|
||||
});
|
||||
|
||||
const existingByLegacyName = existingByExpectedName
|
||||
? null
|
||||
: existingChannels.find((channel) => {
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return channel.type === ChannelType.GuildText && (
|
||||
channel.name === legacyName
|
||||
|| channel.name === `${LEGACY_LOG_CHANNEL_NAME_PREFIX}${legacyName}`
|
||||
);
|
||||
});
|
||||
return (
|
||||
channel.type === ChannelType.GuildText &&
|
||||
(channel.name === legacyName ||
|
||||
channel.name ===
|
||||
`${LEGACY_LOG_CHANNEL_NAME_PREFIX}${legacyName}`)
|
||||
);
|
||||
});
|
||||
|
||||
const existing = existingByExpectedName ?? existingByLegacyName;
|
||||
|
||||
if (existing) {
|
||||
if (existing.name !== expectedName) {
|
||||
await existing.setName(expectedName).catch((error) => {
|
||||
logger.warn({ guildId: guild.id, category, err: error }, "failed to rename logs channel");
|
||||
logger.warn(
|
||||
{ guildId: guild.id, category, err: error },
|
||||
"failed to rename logs channel",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (existing.parentId !== logCategory.id) {
|
||||
await existing.setParent(logCategory.id).catch((error) => {
|
||||
logger.warn({ guildId: guild.id, category, err: error }, "failed to move logs channel to category");
|
||||
logger.warn(
|
||||
{ guildId: guild.id, category, err: error },
|
||||
"failed to move logs channel to category",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,7 +285,10 @@ export class LogEventService {
|
||||
categoryToChannelId.set(category, created.id);
|
||||
createdCount += 1;
|
||||
} catch (error) {
|
||||
logger.warn({ guildId: guild.id, category, err: error }, "failed to create logs channel");
|
||||
logger.warn(
|
||||
{ guildId: guild.id, category, err: error },
|
||||
"failed to create logs channel",
|
||||
);
|
||||
failedCategories.push(category);
|
||||
}
|
||||
}
|
||||
@@ -257,7 +311,10 @@ export class LogEventService {
|
||||
};
|
||||
}
|
||||
|
||||
public async dispatchEvent(client: Client, input: LogRuntimeDispatchInput): Promise<void> {
|
||||
public async dispatchEvent(
|
||||
client: Client,
|
||||
input: LogRuntimeDispatchInput,
|
||||
): Promise<void> {
|
||||
const botId = this.resolveBotId(client);
|
||||
if (!botId) {
|
||||
return;
|
||||
@@ -276,13 +333,16 @@ export class LogEventService {
|
||||
return;
|
||||
}
|
||||
|
||||
const guild = client.guilds.cache.get(input.guildId) ?? await client.guilds.fetch(input.guildId).catch(() => null);
|
||||
const guild =
|
||||
client.guilds.cache.get(input.guildId) ??
|
||||
(await client.guilds.fetch(input.guildId).catch(() => null));
|
||||
if (!guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = guild.channels.cache.get(eventConfig.channelId)
|
||||
?? await guild.channels.fetch(eventConfig.channelId).catch(() => null);
|
||||
const channel =
|
||||
guild.channels.cache.get(eventConfig.channelId) ??
|
||||
(await guild.channels.fetch(eventConfig.channelId).catch(() => null));
|
||||
|
||||
if (!channel || !hasSendMethod(channel)) {
|
||||
return;
|
||||
@@ -291,7 +351,11 @@ export class LogEventService {
|
||||
const me = guild.members.me;
|
||||
if (me && hasPermissionsFor(channel)) {
|
||||
const permissions = channel.permissionsFor(me);
|
||||
if (!permissions || !permissions.has(PermissionFlagsBits.ViewChannel) || !permissions.has(PermissionFlagsBits.SendMessages)) {
|
||||
if (
|
||||
!permissions ||
|
||||
!permissions.has(PermissionFlagsBits.ViewChannel) ||
|
||||
!permissions.has(PermissionFlagsBits.SendMessages)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -303,7 +367,10 @@ export class LogEventService {
|
||||
.setTimestamp(new Date());
|
||||
|
||||
if (input.details && input.details.length > 0) {
|
||||
const detailsValue = clampField(input.details.map((line) => `- ${line}`).join("\n"), 1024);
|
||||
const detailsValue = clampField(
|
||||
input.details.map((line) => `- ${line}`).join("\n"),
|
||||
1024,
|
||||
);
|
||||
if (detailsValue.length > 0) {
|
||||
embed.addFields({
|
||||
name: input.detailsTitle ?? "details",
|
||||
@@ -331,4 +398,4 @@ export class LogEventService {
|
||||
private cacheKey(botId: string, guildId: string): string {
|
||||
return `${botId}:${guildId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,12 @@ const log = createScopedLogger("command:memberMessagesPanel");
|
||||
|
||||
const panelSessions = new ComponentSessionRegistry<MemberMessagePanelSession>();
|
||||
|
||||
const panelSessionKey = (kind: MemberMessageKind, botId: string, guildId: string, userId: string): string => {
|
||||
const panelSessionKey = (
|
||||
kind: MemberMessageKind,
|
||||
botId: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
): string => {
|
||||
return `${kind}:${botId}:${guildId}:${userId}`;
|
||||
};
|
||||
|
||||
@@ -56,15 +61,24 @@ const createCustomIds = (kind: MemberMessageKind): MemberMessageCustomIds => {
|
||||
};
|
||||
};
|
||||
|
||||
const statusLabel = (ctx: CommandExecutionContext, enabled: boolean): string => {
|
||||
const statusLabel = (
|
||||
ctx: CommandExecutionContext,
|
||||
enabled: boolean,
|
||||
): string => {
|
||||
return enabled ? ctx.ct("ui.status.enabled") : ctx.ct("ui.status.disabled");
|
||||
};
|
||||
|
||||
const messageTypeLabel = (ctx: CommandExecutionContext, messageType: MemberMessageRenderType): string => {
|
||||
const messageTypeLabel = (
|
||||
ctx: CommandExecutionContext,
|
||||
messageType: MemberMessageRenderType,
|
||||
): string => {
|
||||
return ctx.ct(`ui.type.options.${messageType}.label`);
|
||||
};
|
||||
|
||||
const autoRolesLabel = (ctx: CommandExecutionContext, config: MemberMessageConfig): string => {
|
||||
const autoRolesLabel = (
|
||||
ctx: CommandExecutionContext,
|
||||
config: MemberMessageConfig,
|
||||
): string => {
|
||||
const roleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds);
|
||||
if (roleIds.length === 0) {
|
||||
return ctx.ct("ui.autoRolesNotConfigured");
|
||||
@@ -79,7 +93,9 @@ const panelContent = (
|
||||
config: MemberMessageConfig,
|
||||
uiState: MemberMessagePanelUiState,
|
||||
): string => {
|
||||
const channelDisplay = config.channelId ? `<#${config.channelId}>` : ctx.ct("ui.channelNotConfigured");
|
||||
const channelDisplay = config.channelId
|
||||
? `<#${config.channelId}>`
|
||||
: ctx.ct("ui.channelNotConfigured");
|
||||
const lines = [
|
||||
`## ${ctx.ct("ui.embed.title")}`,
|
||||
ctx.ct("ui.embed.description"),
|
||||
@@ -90,15 +106,23 @@ const panelContent = (
|
||||
];
|
||||
|
||||
if (kind === "welcome") {
|
||||
lines.push(`${ctx.ct("ui.embed.fields.autoRoles")}: ${autoRolesLabel(ctx, config)}`);
|
||||
lines.push(
|
||||
`${ctx.ct("ui.embed.fields.autoRoles")}: ${autoRolesLabel(ctx, config)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.channelPickerOpen) {
|
||||
lines.push("", `${ctx.ct("ui.embed.fields.channelPicker")}: ${ctx.ct("ui.channelPickerHint")}`);
|
||||
lines.push(
|
||||
"",
|
||||
`${ctx.ct("ui.embed.fields.channelPicker")}: ${ctx.ct("ui.channelPickerHint")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "welcome" && uiState.rolePickerOpen) {
|
||||
lines.push("", `${ctx.ct("ui.embed.fields.autoRoles")}: ${ctx.ct("ui.rolePickerHint")}`);
|
||||
lines.push(
|
||||
"",
|
||||
`${ctx.ct("ui.embed.fields.autoRoles")}: ${ctx.ct("ui.rolePickerHint")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
@@ -119,14 +143,30 @@ const buildContainer = (
|
||||
.setDisabled(disabled);
|
||||
|
||||
const channelButton = new ButtonBuilder()
|
||||
.setCustomId(uiState.channelPickerOpen ? customIds.channelCancelButton : customIds.channelButton)
|
||||
.setLabel(uiState.channelPickerOpen ? ctx.ct("ui.buttons.channelCancel") : ctx.ct("ui.buttons.channel"))
|
||||
.setCustomId(
|
||||
uiState.channelPickerOpen
|
||||
? customIds.channelCancelButton
|
||||
: customIds.channelButton,
|
||||
)
|
||||
.setLabel(
|
||||
uiState.channelPickerOpen
|
||||
? ctx.ct("ui.buttons.channelCancel")
|
||||
: ctx.ct("ui.buttons.channel"),
|
||||
)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(disabled);
|
||||
|
||||
const roleButton = new ButtonBuilder()
|
||||
.setCustomId(uiState.rolePickerOpen ? customIds.roleCancelButton : customIds.roleButton)
|
||||
.setLabel(uiState.rolePickerOpen ? ctx.ct("ui.buttons.rolesCancel") : ctx.ct("ui.buttons.roles"))
|
||||
.setCustomId(
|
||||
uiState.rolePickerOpen
|
||||
? customIds.roleCancelButton
|
||||
: customIds.roleButton,
|
||||
)
|
||||
.setLabel(
|
||||
uiState.rolePickerOpen
|
||||
? ctx.ct("ui.buttons.rolesCancel")
|
||||
: ctx.ct("ui.buttons.roles"),
|
||||
)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(disabled);
|
||||
|
||||
@@ -159,7 +199,9 @@ const buildContainer = (
|
||||
|
||||
const container = new ContainerBuilder();
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(panelContent(ctx, kind, config, uiState)),
|
||||
new TextDisplayBuilder().setContent(
|
||||
panelContent(ctx, kind, config, uiState),
|
||||
),
|
||||
);
|
||||
|
||||
if (kind === "welcome") {
|
||||
@@ -174,7 +216,11 @@ const buildContainer = (
|
||||
);
|
||||
} else {
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(toggleButton, channelButton, testButton),
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
toggleButton,
|
||||
channelButton,
|
||||
testButton,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,7 +238,9 @@ const buildContainer = (
|
||||
.setChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement);
|
||||
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(channelSelect),
|
||||
new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(
|
||||
channelSelect,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -275,12 +323,16 @@ export const createMemberMessagePanelExecute = (
|
||||
await replyMessage
|
||||
.edit({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, kind, customIds, config, uiState, true)],
|
||||
components: [
|
||||
buildContainer(ctx, kind, customIds, config, uiState, true),
|
||||
],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
|
||||
const collector = replyMessage.createMessageComponentCollector({
|
||||
time: 15 * 60_000,
|
||||
});
|
||||
await panelSessions.replace(sessionKey, {
|
||||
collector,
|
||||
disable: disablePanel,
|
||||
@@ -302,7 +354,9 @@ export const createMemberMessagePanelExecute = (
|
||||
await saveConfig();
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
||||
components: [
|
||||
buildContainer(ctx, kind, customIds, config, uiState),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -312,7 +366,9 @@ export const createMemberMessagePanelExecute = (
|
||||
uiState.rolePickerOpen = false;
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
||||
components: [
|
||||
buildContainer(ctx, kind, customIds, config, uiState),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -321,36 +377,53 @@ export const createMemberMessagePanelExecute = (
|
||||
uiState.channelPickerOpen = false;
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
||||
components: [
|
||||
buildContainer(ctx, kind, customIds, config, uiState),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "welcome" && interaction.customId === customIds.roleButton) {
|
||||
if (
|
||||
kind === "welcome" &&
|
||||
interaction.customId === customIds.roleButton
|
||||
) {
|
||||
uiState.rolePickerOpen = true;
|
||||
uiState.channelPickerOpen = false;
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
||||
components: [
|
||||
buildContainer(ctx, kind, customIds, config, uiState),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "welcome" && interaction.customId === customIds.roleCancelButton) {
|
||||
if (
|
||||
kind === "welcome" &&
|
||||
interaction.customId === customIds.roleCancelButton
|
||||
) {
|
||||
uiState.rolePickerOpen = false;
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
||||
components: [
|
||||
buildContainer(ctx, kind, customIds, config, uiState),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "welcome" && interaction.customId === customIds.roleClearButton) {
|
||||
if (
|
||||
kind === "welcome" &&
|
||||
interaction.customId === customIds.roleClearButton
|
||||
) {
|
||||
config.autoRoleIds = [];
|
||||
await saveConfig();
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
||||
components: [
|
||||
buildContainer(ctx, kind, customIds, config, uiState),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -369,7 +442,9 @@ export const createMemberMessagePanelExecute = (
|
||||
if (testResult.sent) {
|
||||
await interaction.editReply({
|
||||
content: ctx.ct("responses.testSuccess", {
|
||||
channel: testResult.channelId ? `<#${testResult.channelId}>` : ctx.ct("ui.channelNotConfigured"),
|
||||
channel: testResult.channelId
|
||||
? `<#${testResult.channelId}>`
|
||||
: ctx.ct("ui.channelNotConfigured"),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
@@ -388,7 +463,10 @@ export const createMemberMessagePanelExecute = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === customIds.typeSelect) {
|
||||
if (
|
||||
interaction.isStringSelectMenu() &&
|
||||
interaction.customId === customIds.typeSelect
|
||||
) {
|
||||
const nextType = interaction.values[0];
|
||||
if (!nextType || !isMemberMessageRenderTypeValue(nextType)) {
|
||||
await interaction.reply({
|
||||
@@ -407,8 +485,15 @@ export const createMemberMessagePanelExecute = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "welcome" && interaction.isRoleSelectMenu() && interaction.customId === customIds.roleSelect) {
|
||||
config.autoRoleIds = sanitizeMemberMessageRoleIds([...config.autoRoleIds, ...interaction.values]);
|
||||
if (
|
||||
kind === "welcome" &&
|
||||
interaction.isRoleSelectMenu() &&
|
||||
interaction.customId === customIds.roleSelect
|
||||
) {
|
||||
config.autoRoleIds = sanitizeMemberMessageRoleIds([
|
||||
...config.autoRoleIds,
|
||||
...interaction.values,
|
||||
]);
|
||||
uiState.rolePickerOpen = false;
|
||||
await saveConfig();
|
||||
await interaction.update({
|
||||
@@ -418,7 +503,10 @@ export const createMemberMessagePanelExecute = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isChannelSelectMenu() && interaction.customId === customIds.channelSelect) {
|
||||
if (
|
||||
interaction.isChannelSelectMenu() &&
|
||||
interaction.customId === customIds.channelSelect
|
||||
) {
|
||||
const channelId = interaction.values[0];
|
||||
if (!channelId) {
|
||||
await interaction.reply({
|
||||
@@ -446,17 +534,21 @@ export const createMemberMessagePanelExecute = (
|
||||
log.error({ kind, err: error }, "interaction failed");
|
||||
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({
|
||||
content: ctx.t("errors.execution"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
}).catch(() => undefined);
|
||||
await interaction
|
||||
.reply({
|
||||
content: ctx.t("errors.execution"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.followUp({
|
||||
content: ctx.t("errors.execution"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
}).catch(() => undefined);
|
||||
await interaction
|
||||
.followUp({
|
||||
content: ctx.t("errors.execution"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,10 @@ const wrapText = (
|
||||
maxWidth: number,
|
||||
maxLines: number,
|
||||
): string[] => {
|
||||
const words = text.trim().split(/\s+/g).filter((value) => value.length > 0);
|
||||
const words = text
|
||||
.trim()
|
||||
.split(/\s+/g)
|
||||
.filter((value) => value.length > 0);
|
||||
if (words.length === 0) {
|
||||
return [""];
|
||||
}
|
||||
@@ -78,11 +81,15 @@ const wrapText = (
|
||||
const lastLine = lines[maxLines - 1];
|
||||
if (lastLine && lines.length === maxLines) {
|
||||
let value = lastLine;
|
||||
while (context.measureText(`${value}...`).width > maxWidth && value.length > 0) {
|
||||
while (
|
||||
context.measureText(`${value}...`).width > maxWidth &&
|
||||
value.length > 0
|
||||
) {
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
|
||||
lines[maxLines - 1] = value.length === lastLine.length ? value : `${value}...`;
|
||||
lines[maxLines - 1] =
|
||||
value.length === lastLine.length ? value : `${value}...`;
|
||||
}
|
||||
|
||||
return lines;
|
||||
@@ -95,7 +102,13 @@ const drawAvatar = async (
|
||||
): Promise<void> => {
|
||||
context.fillStyle = "#1f2937";
|
||||
context.beginPath();
|
||||
context.arc(AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2, AVATAR_SIZE / 2, 0, Math.PI * 2);
|
||||
context.arc(
|
||||
AVATAR_X + AVATAR_SIZE / 2,
|
||||
AVATAR_Y + AVATAR_SIZE / 2,
|
||||
AVATAR_SIZE / 2,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
|
||||
@@ -103,7 +116,13 @@ const drawAvatar = async (
|
||||
const avatar = await loadImage(avatarUrl);
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.arc(AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2, AVATAR_SIZE / 2, 0, Math.PI * 2);
|
||||
context.arc(
|
||||
AVATAR_X + AVATAR_SIZE / 2,
|
||||
AVATAR_Y + AVATAR_SIZE / 2,
|
||||
AVATAR_SIZE / 2,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.closePath();
|
||||
context.clip();
|
||||
context.drawImage(avatar, AVATAR_X, AVATAR_Y, AVATAR_SIZE, AVATAR_SIZE);
|
||||
@@ -114,7 +133,11 @@ const drawAvatar = async (
|
||||
context.font = `700 92px ${FONT_FAMILY}`;
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText(initial, AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2);
|
||||
context.fillText(
|
||||
initial,
|
||||
AVATAR_X + AVATAR_SIZE / 2,
|
||||
AVATAR_Y + AVATAR_SIZE / 2,
|
||||
);
|
||||
context.textAlign = "left";
|
||||
context.textBaseline = "alphabetic";
|
||||
}
|
||||
@@ -122,12 +145,20 @@ const drawAvatar = async (
|
||||
context.strokeStyle = "rgba(248, 250, 252, 0.9)";
|
||||
context.lineWidth = 6;
|
||||
context.beginPath();
|
||||
context.arc(AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2, AVATAR_SIZE / 2 - 3, 0, Math.PI * 2);
|
||||
context.arc(
|
||||
AVATAR_X + AVATAR_SIZE / 2,
|
||||
AVATAR_Y + AVATAR_SIZE / 2,
|
||||
AVATAR_SIZE / 2 - 3,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.closePath();
|
||||
context.stroke();
|
||||
};
|
||||
|
||||
export const renderMemberMessageImage = async (input: MemberMessageImageInput): Promise<Buffer> => {
|
||||
export const renderMemberMessageImage = async (
|
||||
input: MemberMessageImageInput,
|
||||
): Promise<Buffer> => {
|
||||
const canvas = createCanvas(CARD_WIDTH, CARD_HEIGHT);
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
@@ -153,12 +184,26 @@ export const renderMemberMessageImage = async (input: MemberMessageImageInput):
|
||||
|
||||
await drawAvatar(context, input.username, input.avatarUrl);
|
||||
|
||||
const headlineSize = fitFontSize(context, input.title, 84, 52, TEXT_MAX_WIDTH, 700);
|
||||
const headlineSize = fitFontSize(
|
||||
context,
|
||||
input.title,
|
||||
84,
|
||||
52,
|
||||
TEXT_MAX_WIDTH,
|
||||
700,
|
||||
);
|
||||
context.font = `700 ${headlineSize}px ${FONT_FAMILY}`;
|
||||
context.fillStyle = "#f8fafc";
|
||||
context.fillText(input.title, TEXT_X, 116);
|
||||
|
||||
const subtitleSize = fitFontSize(context, input.subtitle, 58, 30, TEXT_MAX_WIDTH, 500);
|
||||
const subtitleSize = fitFontSize(
|
||||
context,
|
||||
input.subtitle,
|
||||
58,
|
||||
30,
|
||||
TEXT_MAX_WIDTH,
|
||||
500,
|
||||
);
|
||||
context.font = `500 ${subtitleSize}px ${FONT_FAMILY}`;
|
||||
context.fillStyle = "#e5e7eb";
|
||||
|
||||
@@ -169,7 +214,14 @@ export const renderMemberMessageImage = async (input: MemberMessageImageInput):
|
||||
context.fillText(line, TEXT_X, subtitleStartY + index * subtitleLineHeight);
|
||||
});
|
||||
|
||||
const usernameSize = fitFontSize(context, input.username, 54, 28, TEXT_MAX_WIDTH, 700);
|
||||
const usernameSize = fitFontSize(
|
||||
context,
|
||||
input.username,
|
||||
54,
|
||||
28,
|
||||
TEXT_MAX_WIDTH,
|
||||
700,
|
||||
);
|
||||
context.font = `700 ${usernameSize}px ${FONT_FAMILY}`;
|
||||
context.fillStyle = input.kind === "welcome" ? "#93c5fd" : "#fda4af";
|
||||
context.fillText(input.username, TEXT_X, CARD_HEIGHT - 34);
|
||||
|
||||
@@ -4,7 +4,16 @@ import type {
|
||||
} from "../../types/memberMessages.js";
|
||||
|
||||
export interface MemberMessageRepository {
|
||||
getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise<MemberMessageConfig>;
|
||||
upsertByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind, config: MemberMessageConfig): Promise<void>;
|
||||
getByBotGuildKind(
|
||||
botId: string,
|
||||
guildId: string,
|
||||
kind: MemberMessageKind,
|
||||
): Promise<MemberMessageConfig>;
|
||||
upsertByBotGuildKind(
|
||||
botId: string,
|
||||
guildId: string,
|
||||
kind: MemberMessageKind,
|
||||
config: MemberMessageConfig,
|
||||
): Promise<void>;
|
||||
deleteByBotGuild(botId: string, guildId: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ const hasSendMethod = (value: unknown): value is SendableChannel => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return "send" in value && typeof (value as { send?: unknown }).send === "function";
|
||||
return (
|
||||
"send" in value && typeof (value as { send?: unknown }).send === "function"
|
||||
);
|
||||
};
|
||||
|
||||
const hasCode = (error: unknown): error is { code: number } => {
|
||||
@@ -46,10 +48,15 @@ const hasCode = (error: unknown): error is { code: number } => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return "code" in error && typeof (error as { code?: unknown }).code === "number";
|
||||
return (
|
||||
"code" in error && typeof (error as { code?: unknown }).code === "number"
|
||||
);
|
||||
};
|
||||
|
||||
const messageTemplateVars = (guild: Guild, user: User): Record<string, string> => ({
|
||||
const messageTemplateVars = (
|
||||
guild: Guild,
|
||||
user: User,
|
||||
): Record<string, string> => ({
|
||||
user: `<@${user.id}>`,
|
||||
username: user.username,
|
||||
guild: guild.name,
|
||||
@@ -87,12 +94,17 @@ const resolveNonEmptyTemplate = (
|
||||
return fallback.length > 0 ? fallback : key;
|
||||
};
|
||||
|
||||
const resolveAssignableRoleId = async (member: GuildMember, roleId: string): Promise<string | null> => {
|
||||
const resolveAssignableRoleId = async (
|
||||
member: GuildMember,
|
||||
roleId: string,
|
||||
): Promise<string | null> => {
|
||||
if (roleId === member.guild.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role = member.guild.roles.cache.get(roleId) ?? await member.guild.roles.fetch(roleId).catch(() => null);
|
||||
const role =
|
||||
member.guild.roles.cache.get(roleId) ??
|
||||
(await member.guild.roles.fetch(roleId).catch(() => null));
|
||||
if (!role || !role.editable) {
|
||||
return null;
|
||||
}
|
||||
@@ -109,10 +121,11 @@ const buildMemberMessagePayload = async (
|
||||
user: User,
|
||||
): Promise<MessageCreateOptions> => {
|
||||
const vars = messageTemplateVars(guild, user);
|
||||
const allowedMentions: NonNullable<MessageCreateOptions["allowedMentions"]> = {
|
||||
parse: [],
|
||||
users: [user.id],
|
||||
};
|
||||
const allowedMentions: NonNullable<MessageCreateOptions["allowedMentions"]> =
|
||||
{
|
||||
parse: [],
|
||||
users: [user.id],
|
||||
};
|
||||
|
||||
if (renderType === "simple") {
|
||||
return {
|
||||
@@ -128,7 +141,9 @@ const buildMemberMessagePayload = async (
|
||||
new EmbedBuilder()
|
||||
.setColor(messageColor(kind))
|
||||
.setTitle(resolveTemplate(i18n, lang, kind, "embedTitle", vars))
|
||||
.setDescription(resolveTemplate(i18n, lang, kind, "embedDescription", vars)),
|
||||
.setDescription(
|
||||
resolveTemplate(i18n, lang, kind, "embedDescription", vars),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -148,8 +163,20 @@ const buildMemberMessagePayload = async (
|
||||
};
|
||||
}
|
||||
|
||||
const imageTitle = resolveNonEmptyTemplate(i18n, lang, kind, "imageTitle", vars);
|
||||
const imageSubtitle = resolveNonEmptyTemplate(i18n, lang, kind, "imageDescription", vars);
|
||||
const imageTitle = resolveNonEmptyTemplate(
|
||||
i18n,
|
||||
lang,
|
||||
kind,
|
||||
"imageTitle",
|
||||
vars,
|
||||
);
|
||||
const imageSubtitle = resolveNonEmptyTemplate(
|
||||
i18n,
|
||||
lang,
|
||||
kind,
|
||||
"imageDescription",
|
||||
vars,
|
||||
);
|
||||
|
||||
const imageBuffer = await renderMemberMessageImage({
|
||||
kind,
|
||||
@@ -171,11 +198,17 @@ const buildMemberMessagePayload = async (
|
||||
export class MemberMessageService {
|
||||
public constructor(private readonly repository: MemberMessageRepository) {}
|
||||
|
||||
public resolveBotId(client: DispatchMemberMessageInput["client"]): string | null {
|
||||
public resolveBotId(
|
||||
client: DispatchMemberMessageInput["client"],
|
||||
): string | null {
|
||||
return client.user?.id ?? null;
|
||||
}
|
||||
|
||||
public async getConfig(botId: string, guildId: string, kind: MemberMessageKind) {
|
||||
public async getConfig(
|
||||
botId: string,
|
||||
guildId: string,
|
||||
kind: MemberMessageKind,
|
||||
) {
|
||||
return this.repository.getByBotGuildKind(botId, guildId, kind);
|
||||
}
|
||||
|
||||
@@ -192,7 +225,9 @@ export class MemberMessageService {
|
||||
await this.repository.deleteByBotGuild(botId, guildId);
|
||||
}
|
||||
|
||||
public async assignWelcomeAutoRoles(input: AssignWelcomeAutoRolesInput): Promise<AssignWelcomeAutoRolesResult> {
|
||||
public async assignWelcomeAutoRoles(
|
||||
input: AssignWelcomeAutoRolesInput,
|
||||
): Promise<AssignWelcomeAutoRolesResult> {
|
||||
const botId = this.resolveBotId(input.client);
|
||||
if (!botId) {
|
||||
return {
|
||||
@@ -204,7 +239,11 @@ export class MemberMessageService {
|
||||
};
|
||||
}
|
||||
|
||||
const config = await this.repository.getByBotGuildKind(botId, input.member.guild.id, "welcome");
|
||||
const config = await this.repository.getByBotGuildKind(
|
||||
botId,
|
||||
input.member.guild.id,
|
||||
"welcome",
|
||||
);
|
||||
const configuredRoleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds);
|
||||
|
||||
if (configuredRoleIds.length === 0) {
|
||||
@@ -239,11 +278,17 @@ export class MemberMessageService {
|
||||
}
|
||||
|
||||
const resolvedRoleIds = await Promise.all(
|
||||
configuredRoleIds.map((roleId) => resolveAssignableRoleId(input.member, roleId)),
|
||||
configuredRoleIds.map((roleId) =>
|
||||
resolveAssignableRoleId(input.member, roleId),
|
||||
),
|
||||
);
|
||||
|
||||
const appliedRoleIds = resolvedRoleIds.filter((roleId): roleId is string => roleId !== null);
|
||||
const skippedRoleIds = configuredRoleIds.filter((roleId) => !appliedRoleIds.includes(roleId));
|
||||
const appliedRoleIds = resolvedRoleIds.filter(
|
||||
(roleId): roleId is string => roleId !== null,
|
||||
);
|
||||
const skippedRoleIds = configuredRoleIds.filter(
|
||||
(roleId) => !appliedRoleIds.includes(roleId),
|
||||
);
|
||||
|
||||
if (appliedRoleIds.length === 0) {
|
||||
return {
|
||||
@@ -285,7 +330,9 @@ export class MemberMessageService {
|
||||
}
|
||||
}
|
||||
|
||||
public async dispatch(input: DispatchMemberMessageInput): Promise<DispatchMemberMessageResult> {
|
||||
public async dispatch(
|
||||
input: DispatchMemberMessageInput,
|
||||
): Promise<DispatchMemberMessageResult> {
|
||||
const botId = this.resolveBotId(input.client);
|
||||
if (!botId) {
|
||||
return {
|
||||
@@ -295,7 +342,11 @@ export class MemberMessageService {
|
||||
};
|
||||
}
|
||||
|
||||
const config = await this.repository.getByBotGuildKind(botId, input.guild.id, input.kind);
|
||||
const config = await this.repository.getByBotGuildKind(
|
||||
botId,
|
||||
input.guild.id,
|
||||
input.kind,
|
||||
);
|
||||
|
||||
if (!input.ignoreEnabled && !config.enabled) {
|
||||
return {
|
||||
@@ -313,7 +364,9 @@ export class MemberMessageService {
|
||||
};
|
||||
}
|
||||
|
||||
const channel = await input.guild.channels.fetch(config.channelId).catch(() => null);
|
||||
const channel = await input.guild.channels
|
||||
.fetch(config.channelId)
|
||||
.catch(() => null);
|
||||
if (!channel) {
|
||||
return {
|
||||
sent: false,
|
||||
@@ -331,9 +384,17 @@ export class MemberMessageService {
|
||||
}
|
||||
|
||||
const me = input.guild.members.me;
|
||||
if (me && "permissionsFor" in channel && typeof channel.permissionsFor === "function") {
|
||||
if (
|
||||
me &&
|
||||
"permissionsFor" in channel &&
|
||||
typeof channel.permissionsFor === "function"
|
||||
) {
|
||||
const permissions = channel.permissionsFor(me);
|
||||
if (!permissions || !permissions.has(PermissionFlagsBits.ViewChannel) || !permissions.has(PermissionFlagsBits.SendMessages)) {
|
||||
if (
|
||||
!permissions ||
|
||||
!permissions.has(PermissionFlagsBits.ViewChannel) ||
|
||||
!permissions.has(PermissionFlagsBits.SendMessages)
|
||||
) {
|
||||
return {
|
||||
sent: false,
|
||||
reason: "missing_permissions",
|
||||
@@ -343,7 +404,14 @@ export class MemberMessageService {
|
||||
}
|
||||
|
||||
const lang = input.i18n.resolveLang(input.guild.preferredLocale ?? null);
|
||||
const payload = await buildMemberMessagePayload(input.i18n, lang, input.kind, config.messageType, input.guild, input.user);
|
||||
const payload = await buildMemberMessagePayload(
|
||||
input.i18n,
|
||||
lang,
|
||||
input.kind,
|
||||
config.messageType,
|
||||
input.guild,
|
||||
input.user,
|
||||
);
|
||||
|
||||
try {
|
||||
await channel.send(payload);
|
||||
|
||||
@@ -47,8 +47,10 @@ const createCustomIds = (): PresenceCustomIds => {
|
||||
const statusLabel = (ctx: CommandExecutionContext, status: string): string =>
|
||||
ctx.ct(`ui.status.options.${status}.label`);
|
||||
|
||||
const activityLabel = (ctx: CommandExecutionContext, activityType: string): string =>
|
||||
ctx.ct(`ui.activity.options.${activityType}.label`);
|
||||
const activityLabel = (
|
||||
ctx: CommandExecutionContext,
|
||||
activityType: string,
|
||||
): string => ctx.ct(`ui.activity.options.${activityType}.label`);
|
||||
|
||||
const panelContent = (
|
||||
ctx: CommandExecutionContext,
|
||||
@@ -60,8 +62,13 @@ const panelContent = (
|
||||
|
||||
const templateText = service.getActiveTemplateText(state, runtimeState);
|
||||
const activityPreview = service.renderPreview(ctx.client, templateText);
|
||||
const activityTexts = state.activity.texts.map((text, index) => `${index + 1}. ${text}`).join(" | ");
|
||||
const currentIndex = Math.min(runtimeState.activePresenceTextIndex + 1, state.activity.texts.length);
|
||||
const activityTexts = state.activity.texts
|
||||
.map((text, index) => `${index + 1}. ${text}`)
|
||||
.join(" | ");
|
||||
const currentIndex = Math.min(
|
||||
runtimeState.activePresenceTextIndex + 1,
|
||||
state.activity.texts.length,
|
||||
);
|
||||
|
||||
const summary = ctx.ct("responses.panel", {
|
||||
status: statusLabel(ctx, state.status),
|
||||
@@ -138,16 +145,23 @@ const buildContainer = (
|
||||
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(statusSelect),
|
||||
);
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(activitySelect),
|
||||
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
activitySelect,
|
||||
),
|
||||
);
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(textButton, intervalButton),
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
textButton,
|
||||
intervalButton,
|
||||
),
|
||||
);
|
||||
|
||||
return container;
|
||||
};
|
||||
|
||||
export const createPresenceCommandExecute = (presenceService: PresenceService) => {
|
||||
export const createPresenceCommandExecute = (
|
||||
presenceService: PresenceService,
|
||||
) => {
|
||||
return async (ctx: CommandExecutionContext): Promise<void> => {
|
||||
const state = await presenceService.loadState(ctx.client);
|
||||
presenceService.applyState(ctx.client, state);
|
||||
@@ -172,13 +186,20 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
||||
await replyMessage
|
||||
.edit({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, presenceService, state, customIds, true)],
|
||||
components: [
|
||||
buildContainer(ctx, presenceService, state, customIds, true),
|
||||
],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
|
||||
await presenceService.replacePanelSession(sessionKey, { collector, disable: disablePanel });
|
||||
const collector = replyMessage.createMessageComponentCollector({
|
||||
time: 15 * 60_000,
|
||||
});
|
||||
await presenceService.replacePanelSession(sessionKey, {
|
||||
collector,
|
||||
disable: disablePanel,
|
||||
});
|
||||
|
||||
collector.on("collect", async (interaction) => {
|
||||
if (interaction.user.id !== ownerId) {
|
||||
@@ -194,7 +215,10 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
||||
if (interaction.customId === customIds.statusSelect) {
|
||||
const nextStatus = interaction.values[0];
|
||||
if (!nextStatus || !isPresenceStatusValue(nextStatus)) {
|
||||
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
|
||||
await interaction.reply({
|
||||
content: ctx.ct("responses.invalidSelection"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -202,7 +226,9 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
||||
await presenceService.persistAndApply(ctx.client, state);
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, presenceService, state, customIds)],
|
||||
components: [
|
||||
buildContainer(ctx, presenceService, state, customIds),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -210,7 +236,10 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
||||
if (interaction.customId === customIds.activitySelect) {
|
||||
const nextType = interaction.values[0];
|
||||
if (!nextType || !isPresenceActivityTypeValue(nextType)) {
|
||||
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
|
||||
await interaction.reply({
|
||||
content: ctx.ct("responses.invalidSelection"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -218,12 +247,17 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
||||
await presenceService.persistAndApply(ctx.client, state);
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, presenceService, state, customIds)],
|
||||
components: [
|
||||
buildContainer(ctx, presenceService, state, customIds),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
|
||||
await interaction.reply({
|
||||
content: ctx.ct("responses.invalidSelection"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,12 +285,14 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
||||
const submitted = await interaction.awaitModalSubmit({
|
||||
time: 120_000,
|
||||
filter: (modalInteraction) =>
|
||||
modalInteraction.customId === customIds.textModal
|
||||
&& modalInteraction.user.id === ownerId,
|
||||
modalInteraction.customId === customIds.textModal &&
|
||||
modalInteraction.user.id === ownerId,
|
||||
});
|
||||
|
||||
const nextTexts = sanitizeActivityTexts(
|
||||
submitted.fields.getTextInputValue(customIds.textInput).split(/\r?\n/g),
|
||||
submitted.fields
|
||||
.getTextInputValue(customIds.textInput)
|
||||
.split(/\r?\n/g),
|
||||
);
|
||||
|
||||
state.activity.texts = nextTexts;
|
||||
@@ -269,13 +305,17 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
||||
|
||||
await replyMessage.edit({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, presenceService, state, customIds)],
|
||||
components: [
|
||||
buildContainer(ctx, presenceService, state, customIds),
|
||||
],
|
||||
});
|
||||
} catch {
|
||||
await interaction.followUp({
|
||||
content: ctx.ct("responses.modalTimeout"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
}).catch(() => undefined);
|
||||
await interaction
|
||||
.followUp({
|
||||
content: ctx.ct("responses.modalTimeout"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -304,11 +344,13 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
||||
const submitted = await interaction.awaitModalSubmit({
|
||||
time: 120_000,
|
||||
filter: (modalInteraction) =>
|
||||
modalInteraction.customId === customIds.intervalModal
|
||||
&& modalInteraction.user.id === ownerId,
|
||||
modalInteraction.customId === customIds.intervalModal &&
|
||||
modalInteraction.user.id === ownerId,
|
||||
});
|
||||
|
||||
const rawSeconds = submitted.fields.getTextInputValue(customIds.intervalInput).trim();
|
||||
const rawSeconds = submitted.fields
|
||||
.getTextInputValue(customIds.intervalInput)
|
||||
.trim();
|
||||
if (!/^\d+$/.test(rawSeconds)) {
|
||||
await submitted.reply({
|
||||
content: ctx.ct("responses.invalidInterval", {
|
||||
@@ -339,33 +381,47 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
||||
|
||||
await replyMessage.edit({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, presenceService, state, customIds)],
|
||||
components: [
|
||||
buildContainer(ctx, presenceService, state, customIds),
|
||||
],
|
||||
});
|
||||
} catch {
|
||||
await interaction.followUp({
|
||||
content: ctx.ct("responses.modalTimeout"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
}).catch(() => undefined);
|
||||
await interaction
|
||||
.followUp({
|
||||
content: ctx.ct("responses.modalTimeout"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
|
||||
await interaction.reply({
|
||||
content: ctx.ct("responses.invalidSelection"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
|
||||
await interaction.reply({
|
||||
content: ctx.ct("responses.invalidSelection"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
});
|
||||
} catch (error) {
|
||||
log.error({ err: error }, "interaction failed");
|
||||
const fallback = ctx.t("errors.execution");
|
||||
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
|
||||
await interaction
|
||||
.reply({ content: fallback, flags: [MessageFlags.Ephemeral] })
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.followUp({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
|
||||
await interaction
|
||||
.followUp({ content: fallback, flags: [MessageFlags.Ephemeral] })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -20,14 +20,15 @@ import {
|
||||
import type { PresenceRepository } from "./repository.js";
|
||||
import type { PresencePanelSession, PresenceRuntimeState } from "./types.js";
|
||||
|
||||
const DISCORD_ACTIVITY_TYPES: Record<PresenceActivityTypeValue, ActivityType> = {
|
||||
PLAYING: ActivityType.Playing,
|
||||
STREAMING: ActivityType.Streaming,
|
||||
WATCHING: ActivityType.Watching,
|
||||
LISTENING: ActivityType.Listening,
|
||||
COMPETING: ActivityType.Competing,
|
||||
CUSTOM: ActivityType.Custom,
|
||||
};
|
||||
const DISCORD_ACTIVITY_TYPES: Record<PresenceActivityTypeValue, ActivityType> =
|
||||
{
|
||||
PLAYING: ActivityType.Playing,
|
||||
STREAMING: ActivityType.Streaming,
|
||||
WATCHING: ActivityType.Watching,
|
||||
LISTENING: ActivityType.Listening,
|
||||
COMPETING: ActivityType.Competing,
|
||||
CUSTOM: ActivityType.Custom,
|
||||
};
|
||||
|
||||
const createRuntimeState = (): PresenceRuntimeState => ({
|
||||
dynamicPresenceRefreshTimer: null,
|
||||
@@ -47,13 +48,16 @@ const clearRuntimeTimers = (runtimeState: PresenceRuntimeState): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveDiscordStatus = (status: PresenceStatusValue): "online" | "idle" | "dnd" | "invisible" => {
|
||||
const resolveDiscordStatus = (
|
||||
status: PresenceStatusValue,
|
||||
): "online" | "idle" | "dnd" | "invisible" => {
|
||||
return status === "streaming" ? "online" : status;
|
||||
};
|
||||
|
||||
export class PresenceService {
|
||||
private readonly runtimeByBotId = new Map<string, PresenceRuntimeState>();
|
||||
private readonly panelSessions = new ComponentSessionRegistry<PresencePanelSession>();
|
||||
private readonly panelSessions =
|
||||
new ComponentSessionRegistry<PresencePanelSession>();
|
||||
|
||||
public constructor(
|
||||
private readonly repository: PresenceRepository,
|
||||
@@ -76,20 +80,33 @@ export class PresenceService {
|
||||
return next;
|
||||
}
|
||||
|
||||
public normalizeState(state: PresenceState, runtimeState: PresenceRuntimeState): void {
|
||||
public normalizeState(
|
||||
state: PresenceState,
|
||||
runtimeState: PresenceRuntimeState,
|
||||
): void {
|
||||
const activityTexts = sanitizeActivityTexts(state.activity.texts);
|
||||
state.activity.texts = activityTexts;
|
||||
state.activity.text = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
|
||||
state.activity.rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
|
||||
state.activity.text =
|
||||
activityTexts[0] ?? sanitizeActivityText(state.activity.text);
|
||||
state.activity.rotationIntervalSeconds =
|
||||
sanitizePresenceRotationIntervalSeconds(
|
||||
state.activity.rotationIntervalSeconds,
|
||||
);
|
||||
|
||||
if (runtimeState.activePresenceTextIndex >= activityTexts.length) {
|
||||
runtimeState.activePresenceTextIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public getActiveTemplateText(state: PresenceState, runtimeState: PresenceRuntimeState): string {
|
||||
public getActiveTemplateText(
|
||||
state: PresenceState,
|
||||
runtimeState: PresenceRuntimeState,
|
||||
): string {
|
||||
this.normalizeState(state, runtimeState);
|
||||
return state.activity.texts[runtimeState.activePresenceTextIndex] ?? state.activity.text;
|
||||
return (
|
||||
state.activity.texts[runtimeState.activePresenceTextIndex] ??
|
||||
state.activity.text
|
||||
);
|
||||
}
|
||||
|
||||
public renderPreview(client: Client, templateText: string): string {
|
||||
@@ -111,7 +128,10 @@ export class PresenceService {
|
||||
this.syncDynamicPresenceTimers(client, state, runtimeState);
|
||||
}
|
||||
|
||||
public async persistAndApply(client: Client, state: PresenceState): Promise<void> {
|
||||
public async persistAndApply(
|
||||
client: Client,
|
||||
state: PresenceState,
|
||||
): Promise<void> {
|
||||
this.applyState(client, state);
|
||||
|
||||
const botId = this.resolveBotId(client);
|
||||
@@ -134,11 +154,17 @@ export class PresenceService {
|
||||
return `${this.resolveBotId(client) ?? "unbound"}:${userId}`;
|
||||
}
|
||||
|
||||
public async replacePanelSession(key: string, session: PresencePanelSession): Promise<void> {
|
||||
public async replacePanelSession(
|
||||
key: string,
|
||||
session: PresencePanelSession,
|
||||
): Promise<void> {
|
||||
await this.panelSessions.replace(key, session);
|
||||
}
|
||||
|
||||
public deletePanelSessionIfCollectorMatch(key: string, collector: PresencePanelSession["collector"]): void {
|
||||
public deletePanelSessionIfCollectorMatch(
|
||||
key: string,
|
||||
collector: PresencePanelSession["collector"],
|
||||
): void {
|
||||
this.panelSessions.deleteIfCollectorMatch(key, collector);
|
||||
}
|
||||
|
||||
@@ -151,7 +177,11 @@ export class PresenceService {
|
||||
await this.panelSessions.stopAll("shutdown");
|
||||
}
|
||||
|
||||
private applyPresenceState(client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void {
|
||||
private applyPresenceState(
|
||||
client: Client,
|
||||
state: PresenceState,
|
||||
runtimeState: PresenceRuntimeState,
|
||||
): void {
|
||||
if (!client.user) {
|
||||
return;
|
||||
}
|
||||
@@ -201,7 +231,11 @@ export class PresenceService {
|
||||
});
|
||||
}
|
||||
|
||||
private syncDynamicPresenceTimers(client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void {
|
||||
private syncDynamicPresenceTimers(
|
||||
client: Client,
|
||||
state: PresenceState,
|
||||
runtimeState: PresenceRuntimeState,
|
||||
): void {
|
||||
this.normalizeState(state, runtimeState);
|
||||
clearRuntimeTimers(runtimeState);
|
||||
|
||||
@@ -213,14 +247,18 @@ export class PresenceService {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeState.activePresenceTextIndex = (runtimeState.activePresenceTextIndex + 1) % state.activity.texts.length;
|
||||
runtimeState.activePresenceTextIndex =
|
||||
(runtimeState.activePresenceTextIndex + 1) %
|
||||
state.activity.texts.length;
|
||||
this.applyPresenceState(client, state, runtimeState);
|
||||
}, state.activity.rotationIntervalSeconds * 1_000);
|
||||
|
||||
runtimeState.presenceRotationTimer.unref?.();
|
||||
}
|
||||
|
||||
const hasKnownVariable = state.activity.texts.some((templateText) => containsPresenceTemplateVariables(templateText));
|
||||
const hasKnownVariable = state.activity.texts.some((templateText) =>
|
||||
containsPresenceTemplateVariables(templateText),
|
||||
);
|
||||
if (!hasKnownVariable) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Client } from "discord.js";
|
||||
|
||||
import { env } from "../../config/env.js";
|
||||
import { hasTemplateVariable, renderTemplate } from "../../utils/templateVariables.js";
|
||||
import {
|
||||
hasTemplateVariable,
|
||||
renderTemplate,
|
||||
} from "../../utils/templateVariables.js";
|
||||
import { sanitizeActivityText } from "../../validators/presence.js";
|
||||
|
||||
export const PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS = 60_000;
|
||||
@@ -53,11 +56,19 @@ const formatUptime = (totalSeconds: number): string => {
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
const buildPresenceTemplateValues = (client: Client): Record<string, string> => {
|
||||
const buildPresenceTemplateValues = (
|
||||
client: Client,
|
||||
): Record<string, string> => {
|
||||
const guildCount = client.guilds.cache.size;
|
||||
const memberCount = client.guilds.cache.reduce((total, guild) => total + (guild.memberCount ?? 0), 0);
|
||||
const memberCount = client.guilds.cache.reduce(
|
||||
(total, guild) => total + (guild.memberCount ?? 0),
|
||||
0,
|
||||
);
|
||||
const channelCount = client.channels.cache.size;
|
||||
const uptimeSeconds = Math.max(0, Math.floor((client.uptime ?? process.uptime() * 1_000) / 1_000));
|
||||
const uptimeSeconds = Math.max(
|
||||
0,
|
||||
Math.floor((client.uptime ?? process.uptime() * 1_000) / 1_000),
|
||||
);
|
||||
|
||||
return {
|
||||
bot_name: client.user?.username ?? "bot",
|
||||
@@ -72,18 +83,29 @@ const buildPresenceTemplateValues = (client: Client): Record<string, string> =>
|
||||
};
|
||||
};
|
||||
|
||||
export const renderPresenceTemplate = (client: Client, template: string): string => {
|
||||
export const renderPresenceTemplate = (
|
||||
client: Client,
|
||||
template: string,
|
||||
): string => {
|
||||
const sanitizedTemplate = sanitizeActivityText(template);
|
||||
const rendered = renderTemplate(sanitizedTemplate, buildPresenceTemplateValues(client), {
|
||||
aliases: PRESENCE_TEMPLATE_VARIABLE_ALIASES,
|
||||
keepUnknown: true,
|
||||
});
|
||||
const rendered = renderTemplate(
|
||||
sanitizedTemplate,
|
||||
buildPresenceTemplateValues(client),
|
||||
{
|
||||
aliases: PRESENCE_TEMPLATE_VARIABLE_ALIASES,
|
||||
keepUnknown: true,
|
||||
},
|
||||
);
|
||||
|
||||
return sanitizeActivityText(rendered);
|
||||
};
|
||||
|
||||
export const containsPresenceTemplateVariables = (template: string): boolean =>
|
||||
hasTemplateVariable(template, PRESENCE_KNOWN_TEMPLATE_VARIABLES, PRESENCE_TEMPLATE_VARIABLE_ALIASES);
|
||||
hasTemplateVariable(
|
||||
template,
|
||||
PRESENCE_KNOWN_TEMPLATE_VARIABLES,
|
||||
PRESENCE_TEMPLATE_VARIABLE_ALIASES,
|
||||
);
|
||||
|
||||
export const getPresenceTemplateHelpText = (): string =>
|
||||
PRESENCE_VISIBLE_TEMPLATE_VARIABLES.map((name) => `{{${name}}}`).join(", ");
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
TransportContext,
|
||||
TranslationVars,
|
||||
} from "../types/command.js";
|
||||
import type { BuildExecutionContextInput, HandlerExecutionDeps } from "../types/handlers.js";
|
||||
import type {
|
||||
BuildExecutionContextInput,
|
||||
HandlerExecutionDeps,
|
||||
} from "../types/handlers.js";
|
||||
import type { I18nService } from "../i18n/index.js";
|
||||
import { createDiscordMemberPermissionsResolver } from "./discordPermissionResolver.js";
|
||||
|
||||
@@ -14,7 +17,8 @@ export const createTranslator = (
|
||||
i18n: I18nService,
|
||||
lang: SupportedLang,
|
||||
): ((key: string, vars?: TranslationVars) => string) => {
|
||||
return (key: string, vars?: TranslationVars): string => i18n.t(lang, key, vars);
|
||||
return (key: string, vars?: TranslationVars): string =>
|
||||
i18n.t(lang, key, vars);
|
||||
};
|
||||
|
||||
export const buildCommandExecutionContext = (
|
||||
@@ -86,4 +90,4 @@ export const buildCommandExecutionContext = (
|
||||
prefix: i18nContext.prefix,
|
||||
defaultLang: i18nContext.defaultLang,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
|
||||
import type { BuildExecutionContextInput } from "../types/handlers.js";
|
||||
|
||||
const resolveFromGuild = async (guild: Guild | null, userId: string): Promise<Readonly<PermissionsBitField> | null> => {
|
||||
const resolveFromGuild = async (
|
||||
guild: Guild | null,
|
||||
userId: string,
|
||||
): Promise<Readonly<PermissionsBitField> | null> => {
|
||||
if (!guild) {
|
||||
return null;
|
||||
}
|
||||
@@ -30,13 +33,17 @@ const resolveFromInteractionMember = (
|
||||
|
||||
const member = interaction.member;
|
||||
if (member && typeof member === "object" && "permissions" in member) {
|
||||
const rawPermissions = (member as { permissions?: string | number | bigint }).permissions;
|
||||
const rawPermissions = (
|
||||
member as { permissions?: string | number | bigint }
|
||||
).permissions;
|
||||
|
||||
if (rawPermissions !== undefined) {
|
||||
try {
|
||||
const normalized = typeof rawPermissions === "string" || typeof rawPermissions === "number"
|
||||
? BigInt(rawPermissions)
|
||||
: rawPermissions;
|
||||
const normalized =
|
||||
typeof rawPermissions === "string" ||
|
||||
typeof rawPermissions === "number"
|
||||
? BigInt(rawPermissions)
|
||||
: rawPermissions;
|
||||
|
||||
return new PermissionsBitField(normalized);
|
||||
} catch {
|
||||
@@ -72,7 +79,9 @@ export const createDiscordMemberPermissionsResolver = (
|
||||
return directFromMessage;
|
||||
}
|
||||
|
||||
const fallbackFromGuildMember = message.guild?.members.resolve(message.author.id)?.permissions;
|
||||
const fallbackFromGuildMember = message.guild?.members.resolve(
|
||||
message.author.id,
|
||||
)?.permissions;
|
||||
if (fallbackFromGuildMember) {
|
||||
return fallbackFromGuildMember;
|
||||
}
|
||||
|
||||
@@ -23,13 +23,19 @@ const shouldThrottlePrefixPreParse = (userId: string): boolean => {
|
||||
const now = Date.now();
|
||||
const lastSeenAt = prefixPreParseThrottle.get(userId);
|
||||
|
||||
if (lastSeenAt !== undefined && now - lastSeenAt < PREFIX_PRE_PARSE_THROTTLE_MS) {
|
||||
if (
|
||||
lastSeenAt !== undefined &&
|
||||
now - lastSeenAt < PREFIX_PRE_PARSE_THROTTLE_MS
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
prefixPreParseThrottle.set(userId, now);
|
||||
|
||||
if (now - prefixPreParseThrottleLastSweepAt >= PREFIX_PRE_PARSE_THROTTLE_SWEEP_INTERVAL_MS) {
|
||||
if (
|
||||
now - prefixPreParseThrottleLastSweepAt >=
|
||||
PREFIX_PRE_PARSE_THROTTLE_SWEEP_INTERVAL_MS
|
||||
) {
|
||||
for (const [trackedUserId, trackedAt] of prefixPreParseThrottle.entries()) {
|
||||
if (now - trackedAt >= PREFIX_PRE_PARSE_THROTTLE_MAX_AGE_MS) {
|
||||
prefixPreParseThrottle.delete(trackedUserId);
|
||||
@@ -49,9 +55,11 @@ const resolvePrefixLang = (
|
||||
fallbackLang: SupportedLang,
|
||||
guildPreferredLocale?: string | null,
|
||||
): SupportedLang => {
|
||||
|
||||
const contextualLang = deps.i18n.resolveLang(guildPreferredLocale);
|
||||
const contextualTrigger = deps.i18n.commandTrigger(contextualLang, command.meta.name);
|
||||
const contextualTrigger = deps.i18n.commandTrigger(
|
||||
contextualLang,
|
||||
command.meta.name,
|
||||
);
|
||||
|
||||
if (contextualTrigger === trigger) {
|
||||
return contextualLang;
|
||||
@@ -72,8 +80,11 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
|
||||
}
|
||||
|
||||
const firstSpaceIndex = content.indexOf(" ");
|
||||
const trigger = (firstSpaceIndex === -1 ? content : content.slice(0, firstSpaceIndex)).toLowerCase();
|
||||
const rawArgs = firstSpaceIndex === -1 ? "" : content.slice(firstSpaceIndex + 1);
|
||||
const trigger = (
|
||||
firstSpaceIndex === -1 ? content : content.slice(0, firstSpaceIndex)
|
||||
).toLowerCase();
|
||||
const rawArgs =
|
||||
firstSpaceIndex === -1 ? "" : content.slice(firstSpaceIndex + 1);
|
||||
|
||||
const match = deps.registry.findByAnyPrefixTrigger(trigger);
|
||||
if (!match) {
|
||||
@@ -87,7 +98,13 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
|
||||
const reply = createPrefixReply(message);
|
||||
|
||||
const command = match.command;
|
||||
const lang = resolvePrefixLang(deps, command, trigger, match.lang, message.guild?.preferredLocale ?? null);
|
||||
const lang = resolvePrefixLang(
|
||||
deps,
|
||||
command,
|
||||
trigger,
|
||||
match.lang,
|
||||
message.guild?.preferredLocale ?? null,
|
||||
);
|
||||
const t = createTranslator(deps.i18n, lang);
|
||||
|
||||
const parsed = await parsePrefixArgs(message, command.args, rawArgs);
|
||||
@@ -97,7 +114,13 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const usage = buildPrefixUsage(command, deps.prefix, lang, deps.defaultLang, deps.i18n);
|
||||
const usage = buildPrefixUsage(
|
||||
command,
|
||||
deps.prefix,
|
||||
lang,
|
||||
deps.defaultLang,
|
||||
deps.i18n,
|
||||
);
|
||||
await reply(t(firstError.key, { ...(firstError.vars ?? {}), usage }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,16 @@ import type { PrefixReplyObject } from "../types/reply.js";
|
||||
|
||||
const PREFIX_EPHEMERAL_DELETE_DELAY_MS = 10_000;
|
||||
|
||||
const PREFIX_ALLOWED_MENTIONS_DEFAULT: NonNullable<MessageReplyOptions["allowedMentions"]> = {
|
||||
const PREFIX_ALLOWED_MENTIONS_DEFAULT: NonNullable<
|
||||
MessageReplyOptions["allowedMentions"]
|
||||
> = {
|
||||
parse: [],
|
||||
repliedUser: false,
|
||||
};
|
||||
|
||||
const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable<InteractionReplyOptions["allowedMentions"]> = {
|
||||
const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable<
|
||||
InteractionReplyOptions["allowedMentions"]
|
||||
> = {
|
||||
parse: [],
|
||||
};
|
||||
|
||||
@@ -27,8 +31,14 @@ const FLAG_NAME_TO_BITS: Record<string, bigint> = {
|
||||
IsComponentsV2: 32768n,
|
||||
};
|
||||
|
||||
const hasBitfieldLike = (value: unknown): value is { bitfield: bigint | number } => {
|
||||
return Boolean(value) && typeof value === "object" && "bitfield" in (value as Record<string, unknown>);
|
||||
const hasBitfieldLike = (
|
||||
value: unknown,
|
||||
): value is { bitfield: bigint | number } => {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
typeof value === "object" &&
|
||||
"bitfield" in (value as Record<string, unknown>)
|
||||
);
|
||||
};
|
||||
|
||||
const toFlagBits = (flags: unknown): bigint | null => {
|
||||
@@ -72,7 +82,9 @@ const hasEphemeralInFlags = (flags: unknown): boolean => {
|
||||
return bits !== null && (bits & EPHEMERAL_FLAG) !== 0n;
|
||||
};
|
||||
|
||||
const sanitizePrefixFlags = (flags: unknown): MessageReplyOptions["flags"] | undefined => {
|
||||
const sanitizePrefixFlags = (
|
||||
flags: unknown,
|
||||
): MessageReplyOptions["flags"] | undefined => {
|
||||
const bits = toFlagBits(flags);
|
||||
if (bits === null) {
|
||||
return undefined;
|
||||
@@ -97,7 +109,9 @@ const scheduleDelete = (message: Message): void => {
|
||||
}, PREFIX_EPHEMERAL_DELETE_DELAY_MS);
|
||||
};
|
||||
|
||||
const withPrefixAllowedMentions = (options: MessageReplyOptions): MessageReplyOptions => {
|
||||
const withPrefixAllowedMentions = (
|
||||
options: MessageReplyOptions,
|
||||
): MessageReplyOptions => {
|
||||
return {
|
||||
...options,
|
||||
allowedMentions: {
|
||||
@@ -107,7 +121,9 @@ const withPrefixAllowedMentions = (options: MessageReplyOptions): MessageReplyOp
|
||||
};
|
||||
};
|
||||
|
||||
const withSlashAllowedMentions = (options: InteractionReplyOptions): InteractionReplyOptions => {
|
||||
const withSlashAllowedMentions = (
|
||||
options: InteractionReplyOptions,
|
||||
): InteractionReplyOptions => {
|
||||
return {
|
||||
...options,
|
||||
allowedMentions: {
|
||||
@@ -117,9 +133,13 @@ const withSlashAllowedMentions = (options: InteractionReplyOptions): Interaction
|
||||
};
|
||||
};
|
||||
|
||||
const toMessageReplyOptions = (payload: Exclude<ReplyPayload, string>): MessageReplyOptions => {
|
||||
const toMessageReplyOptions = (
|
||||
payload: Exclude<ReplyPayload, string>,
|
||||
): MessageReplyOptions => {
|
||||
const rest = { ...(payload as Record<string, unknown>) };
|
||||
const sanitizedFlags = sanitizePrefixFlags((payload as InteractionReplyOptions).flags);
|
||||
const sanitizedFlags = sanitizePrefixFlags(
|
||||
(payload as InteractionReplyOptions).flags,
|
||||
);
|
||||
|
||||
// Drop interaction-only fields so prefix replies stay valid message payloads.
|
||||
delete rest.fetchReply;
|
||||
@@ -134,14 +154,18 @@ const toMessageReplyOptions = (payload: Exclude<ReplyPayload, string>): MessageR
|
||||
return rest as MessageReplyOptions;
|
||||
};
|
||||
|
||||
export const createPrefixReply = (message: Message): ((payload: ReplyPayload) => Promise<unknown>) => {
|
||||
export const createPrefixReply = (
|
||||
message: Message,
|
||||
): ((payload: ReplyPayload) => Promise<unknown>) => {
|
||||
return async (payload: ReplyPayload): Promise<unknown> => {
|
||||
if (typeof payload === "string") {
|
||||
return message.reply(withPrefixAllowedMentions({ content: payload }));
|
||||
}
|
||||
|
||||
const shouldDeleteAfterDelay = hasEphemeral(payload);
|
||||
const sent = await message.reply(withPrefixAllowedMentions(toMessageReplyOptions(payload)));
|
||||
const sent = await message.reply(
|
||||
withPrefixAllowedMentions(toMessageReplyOptions(payload)),
|
||||
);
|
||||
if (shouldDeleteAfterDelay) {
|
||||
scheduleDelete(sent);
|
||||
}
|
||||
@@ -163,7 +187,9 @@ export const createSlashReply = (
|
||||
return interaction.reply(options);
|
||||
}
|
||||
|
||||
const options = withSlashAllowedMentions(payload as InteractionReplyOptions);
|
||||
const options = withSlashAllowedMentions(
|
||||
payload as InteractionReplyOptions,
|
||||
);
|
||||
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
return interaction.followUp(options);
|
||||
@@ -171,4 +197,4 @@ export const createSlashReply = (
|
||||
|
||||
return interaction.reply(options);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,9 @@ export const createSlashHandler = (deps: SlashHandlerDeps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = deps.i18n.resolveLang(interaction.locale ?? interaction.guildLocale);
|
||||
const lang = deps.i18n.resolveLang(
|
||||
interaction.locale ?? interaction.guildLocale,
|
||||
);
|
||||
const reply = createSlashReply(interaction);
|
||||
const t = createTranslator(deps.i18n, lang);
|
||||
|
||||
|
||||
+183
-160
@@ -2,206 +2,229 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { SUPPORTED_LANGS, type SupportedLang, type TranslationVars } from "../types/command.js";
|
||||
import {
|
||||
SUPPORTED_LANGS,
|
||||
type SupportedLang,
|
||||
type TranslationVars,
|
||||
} from "../types/command.js";
|
||||
import type { JsonObject } from "../types/i18n.js";
|
||||
|
||||
const DISCORD_LOCALE_MAP: Record<string, SupportedLang> = {
|
||||
en: "en",
|
||||
"en-us": "en",
|
||||
"en-gb": "en",
|
||||
es: "es",
|
||||
"es-es": "es",
|
||||
"es-419": "es",
|
||||
de: "de",
|
||||
"de-de": "de",
|
||||
ja: "ja",
|
||||
"ja-jp": "ja",
|
||||
fr: "fr",
|
||||
"fr-fr": "fr",
|
||||
pt: "pt",
|
||||
"pt-br": "pt",
|
||||
"pt-pt": "pt",
|
||||
ru: "ru",
|
||||
"ru-ru": "ru",
|
||||
it: "it",
|
||||
"it-it": "it",
|
||||
nl: "nl",
|
||||
"nl-nl": "nl",
|
||||
pl: "pl",
|
||||
"pl-pl": "pl",
|
||||
zh: "zh",
|
||||
"zh-cn": "zh",
|
||||
"zh-hans": "zh",
|
||||
"zh-tw": "zh",
|
||||
"zh-hant": "zh",
|
||||
hi: "hi",
|
||||
"hi-in": "hi",
|
||||
ar: "ar",
|
||||
"ar-sa": "ar",
|
||||
bn: "bn",
|
||||
"bn-bd": "bn",
|
||||
"bn-in": "bn",
|
||||
id: "id",
|
||||
"id-id": "id",
|
||||
tr: "tr",
|
||||
"tr-tr": "tr",
|
||||
en: "en",
|
||||
"en-us": "en",
|
||||
"en-gb": "en",
|
||||
es: "es",
|
||||
"es-es": "es",
|
||||
"es-419": "es",
|
||||
de: "de",
|
||||
"de-de": "de",
|
||||
ja: "ja",
|
||||
"ja-jp": "ja",
|
||||
fr: "fr",
|
||||
"fr-fr": "fr",
|
||||
pt: "pt",
|
||||
"pt-br": "pt",
|
||||
"pt-pt": "pt",
|
||||
ru: "ru",
|
||||
"ru-ru": "ru",
|
||||
it: "it",
|
||||
"it-it": "it",
|
||||
nl: "nl",
|
||||
"nl-nl": "nl",
|
||||
pl: "pl",
|
||||
"pl-pl": "pl",
|
||||
zh: "zh",
|
||||
"zh-cn": "zh",
|
||||
"zh-hans": "zh",
|
||||
"zh-tw": "zh",
|
||||
"zh-hant": "zh",
|
||||
hi: "hi",
|
||||
"hi-in": "hi",
|
||||
ar: "ar",
|
||||
"ar-sa": "ar",
|
||||
bn: "bn",
|
||||
"bn-bd": "bn",
|
||||
"bn-in": "bn",
|
||||
id: "id",
|
||||
"id-id": "id",
|
||||
tr: "tr",
|
||||
"tr-tr": "tr",
|
||||
};
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LOCALE_DIR_CANDIDATES = [
|
||||
path.resolve(CURRENT_DIR, "..", "..", "..", "locales"),
|
||||
path.resolve(process.cwd(), "apps", "bot", "locales"),
|
||||
path.resolve(process.cwd(), "locales"),
|
||||
CURRENT_DIR,
|
||||
path.resolve(process.cwd(), "src", "legacy", "i18n"),
|
||||
path.resolve(process.cwd(), "dist", "legacy", "i18n"),
|
||||
path.resolve(CURRENT_DIR, "..", "..", "..", "locales"),
|
||||
path.resolve(process.cwd(), "apps", "bot", "locales"),
|
||||
path.resolve(process.cwd(), "locales"),
|
||||
CURRENT_DIR,
|
||||
path.resolve(process.cwd(), "src", "legacy", "i18n"),
|
||||
path.resolve(process.cwd(), "dist", "legacy", "i18n"),
|
||||
];
|
||||
|
||||
const resolveLocaleFilePath = (lang: SupportedLang): string => {
|
||||
for (const directory of LOCALE_DIR_CANDIDATES) {
|
||||
const filePath = path.join(directory, `${lang}.json`);
|
||||
if (existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
for (const directory of LOCALE_DIR_CANDIDATES) {
|
||||
const filePath = path.join(directory, `${lang}.json`);
|
||||
if (existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`[i18n] missing locale file for "${lang}"`);
|
||||
throw new Error(`[i18n] missing locale file for "${lang}"`);
|
||||
};
|
||||
|
||||
export class I18nService {
|
||||
private readonly dictionaries: Record<SupportedLang, JsonObject>;
|
||||
private readonly fallbackLang: SupportedLang = "en";
|
||||
private readonly dictionaries: Record<SupportedLang, JsonObject>;
|
||||
private readonly fallbackLang: SupportedLang = "en";
|
||||
|
||||
public constructor(private readonly defaultLang: SupportedLang) {
|
||||
this.dictionaries = this.loadDictionaries();
|
||||
}
|
||||
public constructor(private readonly defaultLang: SupportedLang) {
|
||||
this.dictionaries = this.loadDictionaries();
|
||||
}
|
||||
|
||||
public resolveLang(input?: string | null): SupportedLang {
|
||||
if (!input) {
|
||||
return this.fallbackLang;
|
||||
}
|
||||
public resolveLang(input?: string | null): SupportedLang {
|
||||
if (!input) {
|
||||
return this.fallbackLang;
|
||||
}
|
||||
|
||||
const normalized = input.toLowerCase();
|
||||
const normalized = input.toLowerCase();
|
||||
|
||||
const direct = DISCORD_LOCALE_MAP[normalized];
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const direct = DISCORD_LOCALE_MAP[normalized];
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const short = normalized.split("-")[0];
|
||||
const fromShort = short ? DISCORD_LOCALE_MAP[short] : undefined;
|
||||
if (fromShort) {
|
||||
return fromShort;
|
||||
}
|
||||
const short = normalized.split("-")[0];
|
||||
const fromShort = short ? DISCORD_LOCALE_MAP[short] : undefined;
|
||||
if (fromShort) {
|
||||
return fromShort;
|
||||
}
|
||||
|
||||
return this.fallbackLang;
|
||||
}
|
||||
return this.fallbackLang;
|
||||
}
|
||||
|
||||
public t(lang: SupportedLang, key: string, vars: TranslationVars = {}): string {
|
||||
const fromLang = this.lookup(this.dictionaries[lang], key);
|
||||
const fromFallback = this.lookup(this.dictionaries[this.fallbackLang], key);
|
||||
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
|
||||
public t(
|
||||
lang: SupportedLang,
|
||||
key: string,
|
||||
vars: TranslationVars = {},
|
||||
): string {
|
||||
const fromLang = this.lookup(this.dictionaries[lang], key);
|
||||
const fromFallback = this.lookup(this.dictionaries[this.fallbackLang], key);
|
||||
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
|
||||
|
||||
const template =
|
||||
typeof fromLang === "string"
|
||||
? fromLang
|
||||
: typeof fromFallback === "string"
|
||||
? fromFallback
|
||||
: typeof fromDefault === "string"
|
||||
? fromDefault
|
||||
: key;
|
||||
const template =
|
||||
typeof fromLang === "string"
|
||||
? fromLang
|
||||
: typeof fromFallback === "string"
|
||||
? fromFallback
|
||||
: typeof fromDefault === "string"
|
||||
? fromDefault
|
||||
: key;
|
||||
|
||||
return this.format(template, vars);
|
||||
}
|
||||
return this.format(template, vars);
|
||||
}
|
||||
|
||||
public commandT(lang: SupportedLang, commandName: string, relativeKey: string, vars: TranslationVars = {}): string {
|
||||
return this.t(lang, `${this.commandBaseKey(commandName)}.${relativeKey}`, vars);
|
||||
}
|
||||
public commandT(
|
||||
lang: SupportedLang,
|
||||
commandName: string,
|
||||
relativeKey: string,
|
||||
vars: TranslationVars = {},
|
||||
): string {
|
||||
return this.t(
|
||||
lang,
|
||||
`${this.commandBaseKey(commandName)}.${relativeKey}`,
|
||||
vars,
|
||||
);
|
||||
}
|
||||
|
||||
public commandName(lang: SupportedLang, commandName: string): string {
|
||||
const key = `${this.commandBaseKey(commandName)}.name`;
|
||||
const fromLang = this.lookup(this.dictionaries[lang], key);
|
||||
if (typeof fromLang === "string" && fromLang.length > 0) {
|
||||
return fromLang;
|
||||
}
|
||||
public commandName(lang: SupportedLang, commandName: string): string {
|
||||
const key = `${this.commandBaseKey(commandName)}.name`;
|
||||
const fromLang = this.lookup(this.dictionaries[lang], key);
|
||||
if (typeof fromLang === "string" && fromLang.length > 0) {
|
||||
return fromLang;
|
||||
}
|
||||
|
||||
const fromFallback = this.lookup(this.dictionaries[this.fallbackLang], key);
|
||||
if (typeof fromFallback === "string" && fromFallback.length > 0) {
|
||||
return fromFallback;
|
||||
}
|
||||
const fromFallback = this.lookup(this.dictionaries[this.fallbackLang], key);
|
||||
if (typeof fromFallback === "string" && fromFallback.length > 0) {
|
||||
return fromFallback;
|
||||
}
|
||||
|
||||
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
|
||||
if (typeof fromDefault === "string" && fromDefault.length > 0) {
|
||||
return fromDefault;
|
||||
}
|
||||
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
|
||||
if (typeof fromDefault === "string" && fromDefault.length > 0) {
|
||||
return fromDefault;
|
||||
}
|
||||
|
||||
return commandName;
|
||||
}
|
||||
return commandName;
|
||||
}
|
||||
|
||||
public commandTrigger(lang: SupportedLang, commandName: string): string {
|
||||
return this.commandName(lang, commandName).trim().toLowerCase();
|
||||
}
|
||||
public commandTrigger(lang: SupportedLang, commandName: string): string {
|
||||
return this.commandName(lang, commandName).trim().toLowerCase();
|
||||
}
|
||||
|
||||
public commandObject(lang: SupportedLang, commandName: string): Record<string, unknown> {
|
||||
const key = this.commandBaseKey(commandName);
|
||||
const fromLang = this.lookup(this.dictionaries[lang], key);
|
||||
if (this.isObject(fromLang)) {
|
||||
return fromLang;
|
||||
}
|
||||
public commandObject(
|
||||
lang: SupportedLang,
|
||||
commandName: string,
|
||||
): Record<string, unknown> {
|
||||
const key = this.commandBaseKey(commandName);
|
||||
const fromLang = this.lookup(this.dictionaries[lang], key);
|
||||
if (this.isObject(fromLang)) {
|
||||
return fromLang;
|
||||
}
|
||||
|
||||
const fromFallback = this.lookup(this.dictionaries[this.fallbackLang], key);
|
||||
if (this.isObject(fromFallback)) {
|
||||
return fromFallback;
|
||||
}
|
||||
const fromFallback = this.lookup(this.dictionaries[this.fallbackLang], key);
|
||||
if (this.isObject(fromFallback)) {
|
||||
return fromFallback;
|
||||
}
|
||||
|
||||
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
|
||||
if (this.isObject(fromDefault)) {
|
||||
return fromDefault;
|
||||
}
|
||||
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
|
||||
if (this.isObject(fromDefault)) {
|
||||
return fromDefault;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
public format(template: string, vars: TranslationVars = {}): string {
|
||||
return this.interpolate(template, vars);
|
||||
}
|
||||
public format(template: string, vars: TranslationVars = {}): string {
|
||||
return this.interpolate(template, vars);
|
||||
}
|
||||
|
||||
private loadDictionaries(): Record<SupportedLang, JsonObject> {
|
||||
return SUPPORTED_LANGS.reduce<Record<SupportedLang, JsonObject>>((acc, lang) => {
|
||||
const filePath = resolveLocaleFilePath(lang);
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
acc[lang] = JSON.parse(raw) as JsonObject;
|
||||
return acc;
|
||||
}, {} as Record<SupportedLang, JsonObject>);
|
||||
}
|
||||
private loadDictionaries(): Record<SupportedLang, JsonObject> {
|
||||
return SUPPORTED_LANGS.reduce<Record<SupportedLang, JsonObject>>(
|
||||
(acc, lang) => {
|
||||
const filePath = resolveLocaleFilePath(lang);
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
acc[lang] = JSON.parse(raw) as JsonObject;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<SupportedLang, JsonObject>,
|
||||
);
|
||||
}
|
||||
|
||||
private lookup(source: JsonObject, key: string): unknown {
|
||||
const parts = key.split(".");
|
||||
let current: unknown = source;
|
||||
private lookup(source: JsonObject, key: string): unknown {
|
||||
const parts = key.split(".");
|
||||
let current: unknown = source;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
current = (current as JsonObject)[part];
|
||||
}
|
||||
current = (current as JsonObject)[part];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private commandBaseKey(commandName: string): string {
|
||||
return `commands.${commandName}`;
|
||||
}
|
||||
private commandBaseKey(commandName: string): string {
|
||||
return `commands.${commandName}`;
|
||||
}
|
||||
|
||||
private isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
private isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
private interpolate(template: string, vars: TranslationVars): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, variable: string) => {
|
||||
const value = vars[variable];
|
||||
return value === undefined || value === null ? "" : String(value);
|
||||
});
|
||||
}
|
||||
private interpolate(template: string, vars: TranslationVars): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, variable: string) => {
|
||||
const value = vars[variable];
|
||||
return value === undefined || value === null ? "" : String(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,17 @@ import {
|
||||
resolvePrefixTrigger,
|
||||
resolveSlashName,
|
||||
} from "../../core/commands/usage.js";
|
||||
import type { BotCommand, CommandExecutionContext } from "../../types/command.js";
|
||||
import type {
|
||||
BotCommand,
|
||||
CommandExecutionContext,
|
||||
} from "../../types/command.js";
|
||||
|
||||
const categoryName = (command: BotCommand): string => command.meta.category;
|
||||
|
||||
const commandDescription = (ctx: CommandExecutionContext, command: BotCommand): string =>
|
||||
ctx.i18n.commandT(ctx.lang, command.meta.name, "description");
|
||||
const commandDescription = (
|
||||
ctx: CommandExecutionContext,
|
||||
command: BotCommand,
|
||||
): string => ctx.i18n.commandT(ctx.lang, command.meta.name, "description");
|
||||
|
||||
export const resolveCommandFromQuery = (
|
||||
ctx: CommandExecutionContext,
|
||||
@@ -41,12 +46,23 @@ export const createGlobalHelpEmbed = (
|
||||
grouped.get(key)?.push(command);
|
||||
}
|
||||
|
||||
const helpUsage = buildPrefixUsage(helpCommand, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n);
|
||||
const helpUsage = buildPrefixUsage(
|
||||
helpCommand,
|
||||
ctx.prefix,
|
||||
ctx.lang,
|
||||
ctx.defaultLang,
|
||||
ctx.i18n,
|
||||
);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2b6cb0)
|
||||
.setTitle(ctx.ct("embed.title"))
|
||||
.setDescription(ctx.ct("embed.description", { prefix: ctx.prefix, usage: `${helpUsage} <command>` }));
|
||||
.setDescription(
|
||||
ctx.ct("embed.description", {
|
||||
prefix: ctx.prefix,
|
||||
usage: `${helpUsage} <command>`,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [category, categoryCommands] of grouped.entries()) {
|
||||
const lines = categoryCommands
|
||||
@@ -70,45 +86,85 @@ export const createCommandDetailsEmbed = (
|
||||
ctx: CommandExecutionContext,
|
||||
command: BotCommand,
|
||||
): EmbedBuilder => {
|
||||
const usagePrefix = buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n);
|
||||
const usagePrefix = buildPrefixUsage(
|
||||
command,
|
||||
ctx.prefix,
|
||||
ctx.lang,
|
||||
ctx.defaultLang,
|
||||
ctx.i18n,
|
||||
);
|
||||
const usageSlash = buildSlashUsage(command, ctx.lang, ctx.i18n);
|
||||
const prefixTrigger = resolvePrefixTrigger(command, ctx.lang, ctx.defaultLang, ctx.i18n);
|
||||
const prefixTrigger = resolvePrefixTrigger(
|
||||
command,
|
||||
ctx.lang,
|
||||
ctx.defaultLang,
|
||||
ctx.i18n,
|
||||
);
|
||||
const slashTrigger = resolveSlashName(command, ctx.lang, ctx.i18n);
|
||||
const localizedCommandName = ctx.i18n.commandName(ctx.lang, command.meta.name);
|
||||
const localizedCommandName = ctx.i18n.commandName(
|
||||
ctx.lang,
|
||||
command.meta.name,
|
||||
);
|
||||
|
||||
const args = command.args.length === 0
|
||||
? ctx.ct("labels.noArgs")
|
||||
: command.args
|
||||
.map((arg) => {
|
||||
const description = ctx.i18n.commandT(ctx.lang, command.meta.name, arg.descriptionKey);
|
||||
const requirement = arg.required ? ctx.ct("labels.required") : ctx.ct("labels.optional");
|
||||
return `- ${arg.name} (${arg.type}, ${requirement}): ${description}`;
|
||||
})
|
||||
.join("\n");
|
||||
const args =
|
||||
command.args.length === 0
|
||||
? ctx.ct("labels.noArgs")
|
||||
: command.args
|
||||
.map((arg) => {
|
||||
const description = ctx.i18n.commandT(
|
||||
ctx.lang,
|
||||
command.meta.name,
|
||||
arg.descriptionKey,
|
||||
);
|
||||
const requirement = arg.required
|
||||
? ctx.ct("labels.required")
|
||||
: ctx.ct("labels.optional");
|
||||
return `- ${arg.name} (${arg.type}, ${requirement}): ${description}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const examples = command.examples.length === 0
|
||||
? ctx.ct("labels.noExamples")
|
||||
: command.examples
|
||||
.map((example) => {
|
||||
const source = example.source ?? "prefix";
|
||||
const baseUsage = source === "slash"
|
||||
? buildSlashUsage(command, ctx.lang, ctx.i18n)
|
||||
: buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n);
|
||||
const examples =
|
||||
command.examples.length === 0
|
||||
? ctx.ct("labels.noExamples")
|
||||
: command.examples
|
||||
.map((example) => {
|
||||
const source = example.source ?? "prefix";
|
||||
const baseUsage =
|
||||
source === "slash"
|
||||
? buildSlashUsage(command, ctx.lang, ctx.i18n)
|
||||
: buildPrefixUsage(
|
||||
command,
|
||||
ctx.prefix,
|
||||
ctx.lang,
|
||||
ctx.defaultLang,
|
||||
ctx.i18n,
|
||||
);
|
||||
|
||||
const baseCommand = source === "slash"
|
||||
? `/${slashTrigger}`
|
||||
: `${ctx.prefix}${prefixTrigger}`;
|
||||
const baseCommand =
|
||||
source === "slash"
|
||||
? `/${slashTrigger}`
|
||||
: `${ctx.prefix}${prefixTrigger}`;
|
||||
|
||||
const input = example.args ? `${baseCommand} ${example.args}` : baseUsage;
|
||||
const description = ctx.i18n.commandT(ctx.lang, command.meta.name, example.descriptionKey);
|
||||
return `- ${input}: ${description}`;
|
||||
})
|
||||
.join("\n");
|
||||
const input = example.args
|
||||
? `${baseCommand} ${example.args}`
|
||||
: baseUsage;
|
||||
const description = ctx.i18n.commandT(
|
||||
ctx.lang,
|
||||
command.meta.name,
|
||||
example.descriptionKey,
|
||||
);
|
||||
return `- ${input}: ${description}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setColor(0x2f855a)
|
||||
.setTitle(ctx.ct("embed.detailsTitle", { name: localizedCommandName }))
|
||||
.setDescription(ctx.ct("embed.detailsDescription", { description: commandDescription(ctx, command) }))
|
||||
.setDescription(
|
||||
ctx.ct("embed.detailsDescription", {
|
||||
description: commandDescription(ctx, command),
|
||||
}),
|
||||
)
|
||||
.addFields(
|
||||
{
|
||||
name: ctx.ct("embed.fields.usage"),
|
||||
@@ -123,5 +179,7 @@ export const createCommandDetailsEmbed = (
|
||||
value: examples,
|
||||
},
|
||||
)
|
||||
.setFooter({ text: ctx.ct("embed.footer", { source: command.meta.category }) });
|
||||
.setFooter({
|
||||
text: ctx.ct("embed.footer", { source: command.meta.category }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { createLogsCommandExecute } from "../../features/logs/commandPanel.js";
|
||||
export type { LogEventRepository } from "../../features/logs/repository.js";
|
||||
export { LogEventService } from "../../features/logs/service.js";
|
||||
export { LogEventService } from "../../features/logs/service.js";
|
||||
|
||||
@@ -8,4 +8,4 @@ export interface ArgumentParseError {
|
||||
export interface ParsedArgumentsResult {
|
||||
values: Record<string, CommandArgValue>;
|
||||
errors: ArgumentParseError[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,24 @@ export const SUPPORTED_LANGS = [
|
||||
|
||||
export type SupportedLang = (typeof SUPPORTED_LANGS)[number];
|
||||
export type CommandSource = "prefix" | "slash";
|
||||
export type CommandArgType = "string" | "user" | "int" | "boolean" | "number" | "channel" | "role";
|
||||
export type CommandArgType =
|
||||
| "string"
|
||||
| "user"
|
||||
| "int"
|
||||
| "boolean"
|
||||
| "number"
|
||||
| "channel"
|
||||
| "role";
|
||||
|
||||
export type TranslationVars = Record<string, string | number | boolean | null | undefined>;
|
||||
export type ReplyPayload = string | MessageCreateOptions | MessageReplyOptions | InteractionReplyOptions;
|
||||
export type TranslationVars = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
export type ReplyPayload =
|
||||
| string
|
||||
| MessageCreateOptions
|
||||
| MessageReplyOptions
|
||||
| InteractionReplyOptions;
|
||||
|
||||
export type CommandArgValue =
|
||||
| string
|
||||
@@ -85,8 +99,16 @@ export interface CommandRegistryReader {
|
||||
export interface CommandI18nTools {
|
||||
commandName: (lang: SupportedLang, commandName: string) => string;
|
||||
commandTrigger: (lang: SupportedLang, commandName: string) => string;
|
||||
commandT: (lang: SupportedLang, commandName: string, relativeKey: string, vars?: TranslationVars) => string;
|
||||
commandObject: (lang: SupportedLang, commandName: string) => Record<string, unknown>;
|
||||
commandT: (
|
||||
lang: SupportedLang,
|
||||
commandName: string,
|
||||
relativeKey: string,
|
||||
vars?: TranslationVars,
|
||||
) => string;
|
||||
commandObject: (
|
||||
lang: SupportedLang,
|
||||
commandName: string,
|
||||
) => Record<string, unknown>;
|
||||
format: (template: string, vars?: TranslationVars) => string;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,4 +12,4 @@ export interface DeployCommandsOptions {
|
||||
export interface DeployCommandsResult {
|
||||
scope: "guild" | "global";
|
||||
count: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,4 +35,4 @@ export interface PrefixHandlerDeps extends HandlerExecutionDeps {
|
||||
|
||||
export interface SlashHandlerDeps extends HandlerExecutionDeps {
|
||||
dispatcher: CommandDispatchPort;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
@@ -91,4 +91,4 @@ export interface LogChannelProvisionResult {
|
||||
createdCount: number;
|
||||
reusedCount: number;
|
||||
failedCategories: LogEventCategoryKey[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ import type {
|
||||
import type { I18nService } from "../i18n/index.js";
|
||||
|
||||
export type MemberMessageKind = "welcome" | "goodbye";
|
||||
export type MemberMessageRenderType = "simple" | "embed" | "container" | "image";
|
||||
export type MemberMessageRenderType =
|
||||
| "simple"
|
||||
| "embed"
|
||||
| "container"
|
||||
| "image";
|
||||
|
||||
export interface MemberMessageConfig {
|
||||
enabled: boolean;
|
||||
@@ -113,4 +117,4 @@ export interface MemberMessagePanelUiState {
|
||||
export interface MemberMessagePanelSession {
|
||||
collector: ReturnType<Message["createMessageComponentCollector"]>;
|
||||
disable: () => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import type { Message } from "discord.js";
|
||||
|
||||
export type PresenceStatusValue = "online" | "idle" | "dnd" | "invisible" | "streaming";
|
||||
export type PresenceActivityTypeValue = "PLAYING" | "STREAMING" | "WATCHING" | "LISTENING" | "COMPETING" | "CUSTOM";
|
||||
export type PresenceStatusValue =
|
||||
| "online"
|
||||
| "idle"
|
||||
| "dnd"
|
||||
| "invisible"
|
||||
| "streaming";
|
||||
export type PresenceActivityTypeValue =
|
||||
| "PLAYING"
|
||||
| "STREAMING"
|
||||
| "WATCHING"
|
||||
| "LISTENING"
|
||||
| "COMPETING"
|
||||
| "CUSTOM";
|
||||
|
||||
export interface PresenceActivityState {
|
||||
type: PresenceActivityTypeValue;
|
||||
@@ -46,4 +57,4 @@ export interface PresenceRuntimeState {
|
||||
activePanelsByUserId: Map<string, PresencePanelSession>;
|
||||
}
|
||||
|
||||
export type DiscordPresenceStatus = "online" | "idle" | "dnd" | "invisible";
|
||||
export type DiscordPresenceStatus = "online" | "idle" | "dnd" | "invisible";
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import type { ReplyPayload } from "./command.js";
|
||||
|
||||
export type PrefixReplyObject = Exclude<ReplyPayload, string>;
|
||||
export type PrefixReplyObject = Exclude<ReplyPayload, string>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface TemplateRenderOptions {
|
||||
aliases?: Record<string, string>;
|
||||
keepUnknown?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,29 @@ import type { TemplateRenderOptions } from "../types/templateVariables.js";
|
||||
|
||||
const createTemplateTokenRegex = (): RegExp => /\{\{([a-zA-Z0-9_]+)\}\}/g;
|
||||
|
||||
const normalizeVariableName = (rawName: string): string => rawName.toLowerCase();
|
||||
const normalizeVariableName = (rawName: string): string =>
|
||||
rawName.toLowerCase();
|
||||
|
||||
const normalizeAliases = (aliases: Record<string, string> = {}): Record<string, string> =>
|
||||
const normalizeAliases = (
|
||||
aliases: Record<string, string> = {},
|
||||
): Record<string, string> =>
|
||||
Object.entries(aliases).reduce<Record<string, string>>((acc, [from, to]) => {
|
||||
acc[normalizeVariableName(from)] = normalizeVariableName(to);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const resolveVariableName = (rawName: string, aliases: Record<string, string>): string => {
|
||||
const resolveVariableName = (
|
||||
rawName: string,
|
||||
aliases: Record<string, string>,
|
||||
): string => {
|
||||
const normalized = normalizeVariableName(rawName);
|
||||
return aliases[normalized] ?? normalized;
|
||||
};
|
||||
|
||||
export const extractTemplateVariables = (template: string, aliases: Record<string, string> = {}): string[] => {
|
||||
export const extractTemplateVariables = (
|
||||
template: string,
|
||||
aliases: Record<string, string> = {},
|
||||
): string[] => {
|
||||
const normalizedAliases = normalizeAliases(aliases);
|
||||
const found = new Set<string>();
|
||||
|
||||
@@ -63,7 +72,9 @@ export const renderTemplate = (
|
||||
): string => {
|
||||
const normalizedAliases = normalizeAliases(options.aliases);
|
||||
const keepUnknown = options.keepUnknown ?? true;
|
||||
const normalizedValues = Object.entries(values).reduce<Record<string, string>>((acc, [key, value]) => {
|
||||
const normalizedValues = Object.entries(values).reduce<
|
||||
Record<string, string>
|
||||
>((acc, [key, value]) => {
|
||||
acc[normalizeVariableName(key)] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -24,7 +24,9 @@ export const createDefaultLogEventState = (): LogEventStateByKey => {
|
||||
return state;
|
||||
};
|
||||
|
||||
export const cloneLogEventState = (state: LogEventStateByKey): LogEventStateByKey => {
|
||||
export const cloneLogEventState = (
|
||||
state: LogEventStateByKey,
|
||||
): LogEventStateByKey => {
|
||||
const next = {} as LogEventStateByKey;
|
||||
|
||||
for (const eventKey of LOG_EVENT_KEYS) {
|
||||
@@ -42,7 +44,9 @@ export const isLogEventKey = (value: string): value is LogEventKey => {
|
||||
return LOG_EVENT_KEYS_SET.has(value as LogEventKey);
|
||||
};
|
||||
|
||||
export const mergeLogEventRowsIntoState = (rows: readonly LogEventRow[]): LogEventStateByKey => {
|
||||
export const mergeLogEventRowsIntoState = (
|
||||
rows: readonly LogEventRow[],
|
||||
): LogEventStateByKey => {
|
||||
const state = createDefaultLogEventState();
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -57,4 +61,4 @@ export const mergeLogEventRowsIntoState = (rows: readonly LogEventRow[]): LogEve
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,13 +6,24 @@ import type {
|
||||
|
||||
const DISCORD_SNOWFLAKE_REGEX = /^\d{17,20}$/;
|
||||
|
||||
export const MEMBER_MESSAGE_KINDS: readonly MemberMessageKind[] = ["welcome", "goodbye"];
|
||||
export const MEMBER_MESSAGE_KINDS: readonly MemberMessageKind[] = [
|
||||
"welcome",
|
||||
"goodbye",
|
||||
];
|
||||
|
||||
export const MEMBER_MESSAGE_RENDER_TYPES: readonly MemberMessageRenderType[] = ["simple", "embed", "container", "image"];
|
||||
export const MEMBER_MESSAGE_RENDER_TYPES: readonly MemberMessageRenderType[] = [
|
||||
"simple",
|
||||
"embed",
|
||||
"container",
|
||||
"image",
|
||||
];
|
||||
|
||||
export const DEFAULT_MEMBER_MESSAGE_RENDER_TYPE: MemberMessageRenderType = "simple";
|
||||
export const DEFAULT_MEMBER_MESSAGE_RENDER_TYPE: MemberMessageRenderType =
|
||||
"simple";
|
||||
|
||||
export const sanitizeMemberMessageRoleIds = (roleIds: readonly string[]): string[] => {
|
||||
export const sanitizeMemberMessageRoleIds = (
|
||||
roleIds: readonly string[],
|
||||
): string[] => {
|
||||
const uniqueRoleIds = new Set<string>();
|
||||
|
||||
for (const rawRoleId of roleIds) {
|
||||
@@ -34,10 +45,14 @@ export const createDefaultMemberMessageConfig = (): MemberMessageConfig => ({
|
||||
autoRoleIds: [],
|
||||
});
|
||||
|
||||
export const isMemberMessageKindValue = (value: string): value is MemberMessageKind => {
|
||||
export const isMemberMessageKindValue = (
|
||||
value: string,
|
||||
): value is MemberMessageKind => {
|
||||
return MEMBER_MESSAGE_KINDS.includes(value as MemberMessageKind);
|
||||
};
|
||||
|
||||
export const isMemberMessageRenderTypeValue = (value: string): value is MemberMessageRenderType => {
|
||||
export const isMemberMessageRenderTypeValue = (
|
||||
value: string,
|
||||
): value is MemberMessageRenderType => {
|
||||
return MEMBER_MESSAGE_RENDER_TYPES.includes(value as MemberMessageRenderType);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,13 @@ import type {
|
||||
PresenceStatusValue,
|
||||
} from "../types/presence.js";
|
||||
|
||||
export const PRESENCE_STATUSES: readonly PresenceStatusValue[] = ["online", "idle", "dnd", "invisible", "streaming"];
|
||||
export const PRESENCE_STATUSES: readonly PresenceStatusValue[] = [
|
||||
"online",
|
||||
"idle",
|
||||
"dnd",
|
||||
"invisible",
|
||||
"streaming",
|
||||
];
|
||||
export const PRESENCE_ACTIVITY_TYPES: readonly PresenceActivityTypeValue[] = [
|
||||
"PLAYING",
|
||||
"STREAMING",
|
||||
@@ -30,10 +36,14 @@ export const createDefaultPresenceState = (): PresenceState => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const isPresenceStatusValue = (value: string): value is PresenceStatusValue =>
|
||||
export const isPresenceStatusValue = (
|
||||
value: string,
|
||||
): value is PresenceStatusValue =>
|
||||
PRESENCE_STATUSES.includes(value as PresenceStatusValue);
|
||||
|
||||
export const isPresenceActivityTypeValue = (value: string): value is PresenceActivityTypeValue =>
|
||||
export const isPresenceActivityTypeValue = (
|
||||
value: string,
|
||||
): value is PresenceActivityTypeValue =>
|
||||
PRESENCE_ACTIVITY_TYPES.includes(value as PresenceActivityTypeValue);
|
||||
|
||||
export const sanitizeActivityText = (value: string): string => {
|
||||
@@ -59,7 +69,9 @@ export const sanitizeActivityTexts = (values: readonly string[]): string[] => {
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
export const sanitizePresenceRotationIntervalSeconds = (value: number): number => {
|
||||
export const sanitizePresenceRotationIntervalSeconds = (
|
||||
value: number,
|
||||
): number => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS;
|
||||
}
|
||||
@@ -76,10 +88,12 @@ export const sanitizePresenceRotationIntervalSeconds = (value: number): number =
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const isPresenceRotationIntervalSecondsValue = (value: number): boolean =>
|
||||
Number.isInteger(value)
|
||||
&& value >= MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS
|
||||
&& value <= MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS;
|
||||
export const isPresenceRotationIntervalSecondsValue = (
|
||||
value: number,
|
||||
): boolean =>
|
||||
Number.isInteger(value) &&
|
||||
value >= MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS &&
|
||||
value <= MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -92,12 +106,19 @@ export const parsePresenceState = (value: unknown): PresenceState | null => {
|
||||
const statusValue = value.status;
|
||||
const activityValue = value.activity;
|
||||
|
||||
if (typeof statusValue !== "string" || !isPresenceStatusValue(statusValue) || !isRecord(activityValue)) {
|
||||
if (
|
||||
typeof statusValue !== "string" ||
|
||||
!isPresenceStatusValue(statusValue) ||
|
||||
!isRecord(activityValue)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activityType = activityValue.type;
|
||||
if (typeof activityType !== "string" || !isPresenceActivityTypeValue(activityType)) {
|
||||
if (
|
||||
typeof activityType !== "string" ||
|
||||
!isPresenceActivityTypeValue(activityType)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -106,7 +127,11 @@ export const parsePresenceState = (value: unknown): PresenceState | null => {
|
||||
const intervalValue = activityValue.rotationIntervalSeconds;
|
||||
|
||||
const activityTexts = Array.isArray(activityTextsValue)
|
||||
? sanitizeActivityTexts(activityTextsValue.filter((entry): entry is string => typeof entry === "string"))
|
||||
? sanitizeActivityTexts(
|
||||
activityTextsValue.filter(
|
||||
(entry): entry is string => typeof entry === "string",
|
||||
),
|
||||
)
|
||||
: typeof legacyActivityTextValue === "string"
|
||||
? sanitizeActivityTexts([legacyActivityTextValue])
|
||||
: null;
|
||||
@@ -115,9 +140,10 @@ export const parsePresenceState = (value: unknown): PresenceState | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rotationIntervalSeconds = typeof intervalValue === "number"
|
||||
? sanitizePresenceRotationIntervalSeconds(intervalValue)
|
||||
: DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS;
|
||||
const rotationIntervalSeconds =
|
||||
typeof intervalValue === "number"
|
||||
? sanitizePresenceRotationIntervalSeconds(intervalValue)
|
||||
: DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS;
|
||||
|
||||
return {
|
||||
status: statusValue,
|
||||
|
||||
@@ -54,20 +54,23 @@ export class BotManager {
|
||||
const persistedBots = await listBotsToRestore(this.pool);
|
||||
|
||||
for (const bot of persistedBots) {
|
||||
await this.startBot(bot.id, bot.tenantId).catch(async (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "Unknown startup error";
|
||||
await this.startBot(bot.id, bot.tenantId).catch(
|
||||
async (error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown startup error";
|
||||
|
||||
await setBotStatusById(this.pool, bot.id, "error", message);
|
||||
await insertRuntimeEvent(this.pool, {
|
||||
tenantId: bot.tenantId,
|
||||
botId: bot.id,
|
||||
level: "error",
|
||||
message: "Bot failed to restore at startup",
|
||||
metadata: {
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
});
|
||||
await setBotStatusById(this.pool, bot.id, "error", message);
|
||||
await insertRuntimeEvent(this.pool, {
|
||||
tenantId: bot.tenantId,
|
||||
botId: bot.id,
|
||||
level: "error",
|
||||
message: "Bot failed to restore at startup",
|
||||
metadata: {
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +159,8 @@ export class BotManager {
|
||||
client.destroy();
|
||||
this.runningBots.delete(botId);
|
||||
|
||||
const message = error instanceof Error ? error.message : "Unknown login error";
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown login error";
|
||||
await setBotStatusById(this.pool, botId, "error", message);
|
||||
await insertRuntimeEvent(this.pool, {
|
||||
tenantId: bot.tenantId,
|
||||
@@ -215,7 +219,10 @@ export class BotManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async withLock(botId: string, operation: () => Promise<void>): Promise<void> {
|
||||
private async withLock(
|
||||
botId: string,
|
||||
operation: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const previous = this.operationLocks.get(botId) ?? Promise.resolve();
|
||||
|
||||
const next = previous
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
|
||||
import { BotManager } from "../manager/BotManager.js";
|
||||
|
||||
export const createBotControlWorker = (redis: Redis, manager: BotManager): Worker<BotControlJob> => {
|
||||
export const createBotControlWorker = (
|
||||
redis: Redis,
|
||||
manager: BotManager,
|
||||
): Worker<BotControlJob> => {
|
||||
return new Worker<BotControlJob>(
|
||||
BOT_CONTROL_QUEUE,
|
||||
async (job) => {
|
||||
|
||||
@@ -6,12 +6,8 @@ import { createCommandList } from "../legacy/commands/index.js";
|
||||
import { env } from "../legacy/config/env.js";
|
||||
import { CommandRegistry } from "../legacy/core/commands/registry.js";
|
||||
import { LocalCommandDispatchPort } from "../legacy/core/execution/dispatch.js";
|
||||
import {
|
||||
MemoryCooldownStore,
|
||||
} from "../legacy/core/execution/cooldownStore.js";
|
||||
import {
|
||||
MemoryGlobalRateLimitStore,
|
||||
} from "../legacy/core/execution/globalRateLimitStore.js";
|
||||
import { MemoryCooldownStore } from "../legacy/core/execution/cooldownStore.js";
|
||||
import { MemoryGlobalRateLimitStore } from "../legacy/core/execution/globalRateLimitStore.js";
|
||||
import { CommandExecutor } from "../legacy/core/execution/CommandExecutor.js";
|
||||
import { createScopedLogger } from "../legacy/core/logging/logger.js";
|
||||
import {
|
||||
@@ -23,15 +19,9 @@ import { registerEvents } from "../legacy/events/index.js";
|
||||
import { createPrefixHandler } from "../legacy/handlers/prefixHandler.js";
|
||||
import { createSlashHandler } from "../legacy/handlers/slashHandler.js";
|
||||
import { I18nService } from "../legacy/i18n/index.js";
|
||||
import {
|
||||
LogEventService,
|
||||
} from "../legacy/modules/logs/index.js";
|
||||
import {
|
||||
MemberMessageService,
|
||||
} from "../legacy/modules/memberMessages/index.js";
|
||||
import {
|
||||
PresenceService,
|
||||
} from "../legacy/modules/presence/index.js";
|
||||
import { LogEventService } from "../legacy/modules/logs/index.js";
|
||||
import { MemberMessageService } from "../legacy/modules/memberMessages/index.js";
|
||||
import { PresenceService } from "../legacy/modules/presence/index.js";
|
||||
import { TenantLogEventStore } from "./stores/TenantLogEventStore.js";
|
||||
import { TenantMemberMessageStore } from "./stores/TenantMemberMessageStore.js";
|
||||
import { TenantPresenceStore } from "./stores/TenantPresenceStore.js";
|
||||
@@ -54,9 +44,21 @@ export interface LegacyBotRuntime {
|
||||
export const initializeLegacyBotRuntime = async (
|
||||
input: InitializeLegacyBotRuntimeInput,
|
||||
): Promise<LegacyBotRuntime> => {
|
||||
const presenceStore = new TenantPresenceStore(input.pool, input.tenantId, input.ownerUserId);
|
||||
const memberMessageStore = new TenantMemberMessageStore(input.pool, input.tenantId, input.ownerUserId);
|
||||
const logEventStore = new TenantLogEventStore(input.pool, input.tenantId, input.ownerUserId);
|
||||
const presenceStore = new TenantPresenceStore(
|
||||
input.pool,
|
||||
input.tenantId,
|
||||
input.ownerUserId,
|
||||
);
|
||||
const memberMessageStore = new TenantMemberMessageStore(
|
||||
input.pool,
|
||||
input.tenantId,
|
||||
input.ownerUserId,
|
||||
);
|
||||
const logEventStore = new TenantLogEventStore(
|
||||
input.pool,
|
||||
input.tenantId,
|
||||
input.ownerUserId,
|
||||
);
|
||||
|
||||
const dbLifecycle = new DatabaseLifecycle(
|
||||
[
|
||||
@@ -70,7 +72,10 @@ export const initializeLegacyBotRuntime = async (
|
||||
await dbLifecycle.init();
|
||||
|
||||
const services: AppFeatureServices = {
|
||||
presenceService: new PresenceService(presenceStore, env.PRESENCE_STREAM_URL),
|
||||
presenceService: new PresenceService(
|
||||
presenceStore,
|
||||
env.PRESENCE_STREAM_URL,
|
||||
),
|
||||
memberMessageService: new MemberMessageService(memberMessageStore),
|
||||
logEventService: new LogEventService(logEventStore),
|
||||
};
|
||||
@@ -137,15 +142,36 @@ export const initializeLegacyBotRuntime = async (
|
||||
return {
|
||||
shutdown: async () => {
|
||||
await services.logEventService.shutdown().catch((error) => {
|
||||
log.error({ err: error, tenantId: input.tenantId, botClientId: input.botClientId }, "logs service shutdown failed");
|
||||
log.error(
|
||||
{
|
||||
err: error,
|
||||
tenantId: input.tenantId,
|
||||
botClientId: input.botClientId,
|
||||
},
|
||||
"logs service shutdown failed",
|
||||
);
|
||||
});
|
||||
|
||||
await services.presenceService.shutdown().catch((error) => {
|
||||
log.error({ err: error, tenantId: input.tenantId, botClientId: input.botClientId }, "presence service shutdown failed");
|
||||
log.error(
|
||||
{
|
||||
err: error,
|
||||
tenantId: input.tenantId,
|
||||
botClientId: input.botClientId,
|
||||
},
|
||||
"presence service shutdown failed",
|
||||
);
|
||||
});
|
||||
|
||||
await dbLifecycle.shutdown().catch((error) => {
|
||||
log.error({ err: error, tenantId: input.tenantId, botClientId: input.botClientId }, "runtime db lifecycle shutdown failed");
|
||||
log.error(
|
||||
{
|
||||
err: error,
|
||||
tenantId: input.tenantId,
|
||||
botClientId: input.botClientId,
|
||||
},
|
||||
"runtime db lifecycle shutdown failed",
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,13 +34,16 @@ export class TenantLogEventStore implements LogEventRepository {
|
||||
await this.pool.query(logEventSchemaProbeSql);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"[db:init] missing or incompatible table \"bot_log_event_configs\". Run migrations with \"npm run migrate\".",
|
||||
'[db:init] missing or incompatible table "bot_log_event_configs". Run migrations with "npm run migrate".',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async listByBotGuild(botId: string, guildId: string): Promise<LogEventRow[]> {
|
||||
public async listByBotGuild(
|
||||
botId: string,
|
||||
guildId: string,
|
||||
): Promise<LogEventRow[]> {
|
||||
const result = await this.pool.query<LogEventRow>(
|
||||
`
|
||||
SELECT event_key, enabled, channel_id
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user