nettoyage du code

This commit is contained in:
Puechberty Arthur
2026-04-23 23:39:18 +02:00
parent 9c311b75dd
commit 0d55fe5978
160 changed files with 4165 additions and 1237 deletions
+14 -14
View File
@@ -2,19 +2,19 @@
Version: 3.0
But
---
## But
Decrire l'architecture cible du monorepo SaaS pour bot Discord multi-tenant.
Vue d'ensemble
--------------
## Vue d'ensemble
- Stack backend: Node.js + TypeScript + Express + PostgreSQL + Redis + BullMQ.
- Bot runtime: Discord.js avec gestion dynamique multi-instance.
- Frontend: Next.js App Router.
- Structure: monorepo `apps/*` + `packages/*`.
Organisation des dossiers
-------------------------
## Organisation des dossiers
- `apps/api/`
- OAuth2 Discord, JWT cookie, routes multi-tenant, validation token bot, publication jobs BullMQ.
- `apps/bot/`
@@ -26,8 +26,8 @@ Organisation des dossiers
- `database/migrations/`
- SQL versionne (`schema_migrations`) avec schema multi-tenant.
Principes d'architecture
------------------------
## Principes d'architecture
- Multi-tenant strict:
- toutes les requetes de lecture/ecriture passent par `tenant_id`.
- tables critiques liees a `tenant_id` et/ou `owner_user_id`.
@@ -40,15 +40,15 @@ Principes d'architecture
- Bot: runtime Discord.
- Web: UX dashboard.
Conventions de code
-------------------
## Conventions de code
- TypeScript strict.
- Exports nommes.
- Erreurs API explicites et codes HTTP coherents.
- Logs sans fuite de secrets.
Workflow recommande
-------------------
## Workflow recommande
1. Ajouter/adapter migration SQL dans `database/migrations`.
2. Adapter repositories API/Bot avec filtre tenant.
3. Ajouter endpoint API + validation zod.
@@ -56,8 +56,8 @@ Workflow recommande
5. Mettre a jour le dashboard web.
6. Valider `npm run typecheck` puis `docker compose up -d --build`.
Securite
--------
## Securite
- Ne jamais committer `.env`.
- Garder `TOKEN_ENCRYPTION_KEY` hors depot.
- Appliquer `httpOnly` + `sameSite` sur cookie session.
+1 -1
View File
@@ -45,7 +45,7 @@ docker-compose.yml
- Maintient une map en mémoire:
```ts
Map<botId, DiscordClient>
Map<botId, DiscordClient>;
```
- Worker BullMQ: consomme les jobs `start|stop|restart`
+33 -8
View File
@@ -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,
});
}
}
};
@@ -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();
}
+10 -3
View File
@@ -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}`,
+6 -2
View File
@@ -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"],
});
+17 -4
View File
@@ -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);
+8 -2
View File
@@ -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");
}
+8 -5
View File
@@ -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);
});
+14 -4
View File
@@ -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`);
}
});
+41 -9
View File
@@ -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 -1
View File
@@ -4,7 +4,10 @@
"composite": true,
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
"types": ["node"],
"declaration": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts"]
}
+4 -1
View File
@@ -24,6 +24,9 @@ for (const fileName of readdirSync(SOURCE_DIR)) {
}
for (const targetDirectory of TARGET_DIRECTORIES) {
copyFileSync(path.join(SOURCE_DIR, fileName), path.join(targetDirectory, fileName));
copyFileSync(
path.join(SOURCE_DIR, fileName),
path.join(targetDirectory, fileName),
);
}
}
+37 -8
View File
@@ -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.",
);
+9 -3
View File
@@ -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 (
+17 -11
View File
@@ -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);
});
+35 -18
View File
@@ -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,7 +72,9 @@ 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({
@@ -129,16 +131,21 @@ 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
const cooldownStore =
env.STATE_BACKEND === "redis" && redis
? new RedisCooldownStore(redis)
: new MemoryCooldownStore();
const globalRateLimitStore = env.STATE_BACKEND === "redis" && redis
const globalRateLimitStore =
env.STATE_BACKEND === "redis" && redis
? new RedisGlobalRateLimitStore(redis)
: new MemoryGlobalRateLimitStore();
@@ -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,
+3 -9
View File
@@ -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;
+11 -3
View File
@@ -14,7 +14,11 @@ import {
} from "../modules/memberMessages/index.js";
/** Commande `goodbye` — ouvre le panneau de configuration des messages 'goodbye'. */
export const createGoodbyeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
export const createGoodbyeCommand = (
memberMessageService: MemberMessageService,
i18n: I18nService,
) =>
defineCommand({
meta: {
name: "goodbye",
category: "utility",
@@ -27,5 +31,9 @@ export const createGoodbyeCommand = (memberMessageService: MemberMessageService,
descriptionKey: "examples.slash",
},
],
execute: createMemberMessagePanelExecute("goodbye", memberMessageService, i18n),
});
execute: createMemberMessagePanelExecute(
"goodbye",
memberMessageService,
i18n,
),
});
+4 -1
View File
@@ -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),
+3 -1
View File
@@ -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;
}
+3 -2
View File
@@ -6,7 +6,8 @@ import {
type LogEventService,
} from "../modules/logs/index.js";
export const createLogsCommand = (logEventService: LogEventService) => defineCommand({
export const createLogsCommand = (logEventService: LogEventService) =>
defineCommand({
meta: {
name: "logs",
category: "utility",
@@ -20,4 +21,4 @@ export const createLogsCommand = (logEventService: LogEventService) => defineCom
},
],
execute: createLogsCommandExecute(logEventService),
});
});
+3 -2
View File
@@ -6,7 +6,8 @@ import {
type PresenceService,
} from "../modules/presence/index.js";
export const createPresenceCommand = (presenceService: PresenceService) => defineCommand({
export const createPresenceCommand = (presenceService: PresenceService) =>
defineCommand({
meta: {
name: "presence",
category: "utility",
@@ -20,4 +21,4 @@ export const createPresenceCommand = (presenceService: PresenceService) => defin
},
],
execute: createPresenceCommandExecute(presenceService),
});
});
+11 -3
View File
@@ -14,7 +14,11 @@ import {
} from "../modules/memberMessages/index.js";
/** Commande `welcome` — ouvre le panneau de configuration des messages 'welcome'. */
export const createWelcomeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
export const createWelcomeCommand = (
memberMessageService: MemberMessageService,
i18n: I18nService,
) =>
defineCommand({
meta: {
name: "welcome",
category: "utility",
@@ -27,5 +31,9 @@ export const createWelcomeCommand = (memberMessageService: MemberMessageService,
descriptionKey: "examples.slash",
},
],
execute: createMemberMessagePanelExecute("welcome", memberMessageService, i18n),
});
execute: createMemberMessagePanelExecute(
"welcome",
memberMessageService,
i18n,
),
});
+36 -15
View File
@@ -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;
+97 -24
View File
@@ -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.`,
+11 -3
View File
@@ -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 };
}
+12 -3
View File
@@ -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,8 +184,7 @@ const applyOption = (
return;
}
builder.addStringOption((opt) =>
{
builder.addStringOption((opt) => {
const configured = opt
.setName(arg.name)
.setDescription(descriptionEn)
@@ -179,8 +199,7 @@ const applyOption = (
}
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;
+23 -6
View File
@@ -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;
}
+26 -6
View File
@@ -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,9 +136,15 @@ 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
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;
@@ -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,7 +178,8 @@ const parseScriptResult = (rawResult: unknown, fallbackWindowSeconds: number): {
};
const toPositiveInt = (value: unknown): number | null => {
const numeric = typeof value === "number"
const numeric =
typeof value === "number"
? value
: typeof value === "string"
? Number(value)
@@ -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,
],
);
}
@@ -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(
`
+4 -1
View File
@@ -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",
);
});
});
};
+13 -5
View File
@@ -12,12 +12,17 @@ export const registerGuildMemberAdd = (
memberMessageService: MemberMessageService,
): void => {
client.on(Events.GuildMemberAdd, (member) => {
void memberMessageService.assignWelcomeAutoRoles({ client, member }).then((result) => {
void memberMessageService
.assignWelcomeAutoRoles({ client, member })
.then((result) => {
if (result.assigned) {
return;
}
if (result.reason === "no_roles_configured" || result.reason === "no_assignable_roles") {
if (
result.reason === "no_roles_configured" ||
result.reason === "no_assignable_roles"
) {
return;
}
@@ -30,7 +35,8 @@ export const registerGuildMemberAdd = (
},
"failed to assign welcome auto roles",
);
}).catch((error) => {
})
.catch((error) => {
log.error(
{
guildId: member.guild.id,
@@ -41,13 +47,15 @@ export const registerGuildMemberAdd = (
);
});
void memberMessageService.dispatch({
void memberMessageService
.dispatch({
client,
i18n,
guild: member.guild,
user: member.user,
kind: "welcome",
}).catch((error) => {
})
.catch((error) => {
log.error(
{
guildId: member.guild.id,
@@ -12,13 +12,15 @@ export const registerGuildMemberRemove = (
memberMessageService: MemberMessageService,
): void => {
client.on(Events.GuildMemberRemove, (member) => {
void memberMessageService.dispatch({
void memberMessageService
.dispatch({
client,
i18n,
guild: member.guild,
user: member.user,
kind: "goodbye",
}).catch((error) => {
})
.catch((error) => {
log.error(
{
guildId: member.guild.id,
+13 -2
View File
@@ -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()) {
+228 -67
View File
@@ -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,18 +84,29 @@ 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, {
void logEventService
.dispatchEvent(client, {
eventKey: input.eventKey,
guildId,
summary: input.summary,
...(input.details && input.details.length > 0 ? { details: input.details } : {}),
...(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");
})
.catch((error) => {
logger.error(
{ eventKey: input.eventKey, guildId, err: error },
"failed to dispatch runtime log event",
);
});
};
@@ -110,10 +123,15 @@ export const registerLogRuntimeEvents = (
emitRuntimeLog(client, logEventService, i18n, {
eventKey: "messageCreate",
guild: message.guild,
summary: tForGuild(i18n, message.guild, "logsRuntime.summaries.messageCreate", {
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", {
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", {
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", {
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", {
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", {
let summary = tForGuild(
i18n,
guild,
"logsRuntime.summaries.interactionGeneric",
{
user: `<@${interaction.user.id}>`,
});
},
);
if (interaction.isChatInputCommand()) {
summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionSlash", {
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", {
summary = tForGuild(
i18n,
guild,
"logsRuntime.summaries.interactionButton",
{
user: `<@${interaction.user.id}>`,
});
},
);
} else if (interaction.isStringSelectMenu()) {
summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionSelect", {
summary = tForGuild(
i18n,
guild,
"logsRuntime.summaries.interactionSelect",
{
user: `<@${interaction.user.id}>`,
});
},
);
} else if (interaction.isModalSubmit()) {
summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionModal", {
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", {
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", {
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", {
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", {
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", {
summary: tForGuild(
i18n,
thread.guild ?? null,
"logsRuntime.summaries.threadCreate",
{
thread: thread.name,
},
),
details: [
detailLine(i18n, thread.guild ?? null, "threadId", {
value: thread.id,
}),
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", {
summary: tForGuild(
i18n,
thread.guild ?? null,
"logsRuntime.summaries.threadDelete",
{
thread: thread.name,
},
),
details: [
detailLine(i18n, thread.guild ?? null, "threadId", {
value: thread.id,
}),
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", {
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", {
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", {
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", {
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", {
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", {
summary: tForGuild(
i18n,
ban.guild,
"logsRuntime.summaries.guildBanRemove",
{
user: `<@${ban.user.id}>`,
}),
},
),
details: [
detailLine(i18n, ban.guild, "user", {
tag: ban.user.tag,
+4 -1
View File
@@ -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");
+24 -8
View File
@@ -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 () => {
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,12 +54,17 @@ 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 deployedByLeader = await leaderCoordinator.runIfLeader(
"slash-deploy",
botId,
async () => {
const result = await deployApplicationCommands({
token: runtimeAuth.token,
clientId: runtimeAuth.clientId,
@@ -67,10 +80,13 @@ export const registerClientReady = (
},
"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");
+9 -4
View File
@@ -32,11 +32,14 @@ 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> = {
export const LOG_CHANNEL_NAME_BY_CATEGORY: Record<LogEventCategoryKey, string> =
{
message: "logs-message",
member: "logs-member",
interaction: "logs-interaction",
@@ -47,8 +50,10 @@ export const LOG_CHANNEL_NAME_BY_CATEGORY: Record<LogEventCategoryKey, string> =
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;
@@ -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({
await interaction
.reply({
content: ctx.t("errors.execution"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
})
.catch(() => undefined);
return;
}
await interaction.followUp({
await interaction
.followUp({
content: ctx.t("errors.execution"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
})
.catch(() => undefined);
}
});
@@ -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>;
}
+96 -29
View File
@@ -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) => ({
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,7 +207,10 @@ export class LogEventService {
return false;
}
return channel.type === ChannelType.GuildText && channel.name === expectedName;
return (
channel.type === ChannelType.GuildText &&
channel.name === expectedName
);
});
const existingByLegacyName = existingByExpectedName
@@ -177,9 +220,11 @@ export class LogEventService {
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}`)
);
});
@@ -188,13 +233,19 @@ export class LogEventService {
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",
@@ -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({
await interaction
.reply({
content: ctx.t("errors.execution"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
})
.catch(() => undefined);
return;
}
await interaction.followUp({
await interaction
.followUp({
content: ctx.t("errors.execution"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
})
.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,7 +121,8 @@ const buildMemberMessagePayload = async (
user: User,
): Promise<MessageCreateOptions> => {
const vars = messageTemplateVars(guild, user);
const allowedMentions: NonNullable<MessageCreateOptions["allowedMentions"]> = {
const allowedMentions: NonNullable<MessageCreateOptions["allowedMentions"]> =
{
parse: [],
users: [user.id],
};
@@ -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({
await interaction
.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
})
.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({
await interaction
.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
})
.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> = {
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), {
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 = (
@@ -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,11 +33,15 @@ 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"
const normalized =
typeof rawPermissions === "string" ||
typeof rawPermissions === "number"
? BigInt(rawPermissions)
: rawPermissions;
@@ -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;
}
+31 -8
View File
@@ -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;
}
+38 -12
View File
@@ -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);
+3 -1
View File
@@ -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);
+30 -7
View File
@@ -2,7 +2,11 @@ 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> = {
@@ -97,7 +101,11 @@ export class I18nService {
return this.fallbackLang;
}
public t(lang: SupportedLang, key: string, vars: TranslationVars = {}): string {
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);
@@ -114,8 +122,17 @@ export class I18nService {
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 {
@@ -142,7 +159,10 @@ export class I18nService {
return this.commandName(lang, commandName).trim().toLowerCase();
}
public commandObject(lang: SupportedLang, commandName: string): Record<string, unknown> {
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)) {
@@ -167,12 +187,15 @@ export class I18nService {
}
private loadDictionaries(): Record<SupportedLang, JsonObject> {
return SUPPORTED_LANGS.reduce<Record<SupportedLang, JsonObject>>((acc, lang) => {
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>);
},
{} as Record<SupportedLang, JsonObject>,
);
}
private lookup(source: JsonObject, key: string): unknown {
+77 -19
View File
@@ -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,37 +86,73 @@ 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
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");
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
const examples =
command.examples.length === 0
? ctx.ct("labels.noExamples")
: command.examples
.map((example) => {
const source = example.source ?? "prefix";
const baseUsage = source === "slash"
const baseUsage =
source === "slash"
? buildSlashUsage(command, ctx.lang, ctx.i18n)
: buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n);
: buildPrefixUsage(
command,
ctx.prefix,
ctx.lang,
ctx.defaultLang,
ctx.i18n,
);
const baseCommand = source === "slash"
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);
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");
@@ -108,7 +160,11 @@ export const createCommandDetailsEmbed = (
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 }),
});
};
+27 -5
View File
@@ -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;
}
+5 -1
View File
@@ -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;
+13 -2
View File
@@ -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;
+16 -5
View File
@@ -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;
}, {});
+6 -2
View File
@@ -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) {
@@ -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);
};
+38 -12
View File
@@ -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,7 +140,8 @@ export const parsePresenceState = (value: unknown): PresenceState | null => {
return null;
}
const rotationIntervalSeconds = typeof intervalValue === "number"
const rotationIntervalSeconds =
typeof intervalValue === "number"
? sanitizePresenceRotationIntervalSeconds(intervalValue)
: DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS;
+12 -5
View File
@@ -54,8 +54,10 @@ 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, {
@@ -67,7 +69,8 @@ export class BotManager {
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
+4 -1
View File
@@ -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
@@ -39,7 +39,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 [];
@@ -52,7 +54,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),
};
};
@@ -69,13 +73,17 @@ export class TenantMemberMessageStore 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
@@ -32,7 +32,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;
@@ -55,7 +58,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;
}
@@ -87,7 +93,7 @@ export class TenantPresenceStore 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 },
);
}
@@ -112,10 +118,16 @@ export class TenantPresenceStore 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(
`
+3 -1
View File
@@ -4,7 +4,9 @@
"composite": true,
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
"types": ["node"],
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts"]
}
+2 -1
View File
@@ -1,7 +1,8 @@
import { DashboardClient } from "../../../components/dashboard-client";
import PageWrapper from "../../../components/ui/PageWrapper";
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
const apiBaseUrl =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
export const dynamic = "force-dynamic";
+4 -4
View File
@@ -137,15 +137,15 @@ export default async function LocaleLayout({
const messages = await getMessages();
const direction = RTL_LOCALES.includes(
locale as (typeof RTL_LOCALES)[number],
)
const direction = RTL_LOCALES.includes(locale as (typeof RTL_LOCALES)[number])
? "rtl"
: "ltr";
return (
<html lang={locale} dir={direction}>
<body className={`${headingFont.variable} ${monoFont.variable} antialiased`}>
<body
className={`${headingFont.variable} ${monoFont.variable} antialiased`}
>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
+9 -2
View File
@@ -1,10 +1,17 @@
import { getT } from "../../../i18n/server";
import { Badge } from "../../../components/ui/Badge";
import { buttonClassName } from "../../../components/ui/Button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../components/ui/Card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/Card";
import PageWrapper from "../../../components/ui/PageWrapper";
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
const apiBaseUrl =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
export default async function LoginPage() {
const t = await getT();
+25 -5
View File
@@ -72,9 +72,21 @@ a {
inset: 0;
z-index: -1;
background:
radial-gradient(circle at 18% 10%, color-mix(in srgb, var(--primary) 28%, transparent), transparent 44%),
radial-gradient(circle at 82% 0%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 36%),
linear-gradient(180deg, color-mix(in srgb, var(--bg-elevated) 86%, var(--bg)), var(--bg));
radial-gradient(
circle at 18% 10%,
color-mix(in srgb, var(--primary) 28%, transparent),
transparent 44%
),
radial-gradient(
circle at 82% 0%,
color-mix(in srgb, var(--accent) 20%, transparent),
transparent 36%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--bg-elevated) 86%, var(--bg)),
var(--bg)
);
}
.ui-background::before {
@@ -82,8 +94,16 @@ a {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, color-mix(in srgb, var(--foreground-muted) 10%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--foreground-muted) 10%, transparent) 1px, transparent 1px);
linear-gradient(
to right,
color-mix(in srgb, var(--foreground-muted) 10%, transparent) 1px,
transparent 1px
),
linear-gradient(
to bottom,
color-mix(in srgb, var(--foreground-muted) 10%, transparent) 1px,
transparent 1px
);
background-size: 42px 42px;
mask-image: radial-gradient(circle at 55% 42%, black, transparent 78%);
opacity: 0.2;
+6 -2
View File
@@ -19,8 +19,12 @@ export default function FeatureCard({
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-[var(--foreground)]">{title}</h3>
<p className="text-sm text-[var(--foreground-muted)]">{description}</p>
<h3 className="text-lg font-semibold text-[var(--foreground)]">
{title}
</h3>
<p className="text-sm text-[var(--foreground-muted)]">
{description}
</p>
</div>
</CardContent>
</Card>
+132 -19
View File
@@ -9,9 +9,27 @@ export default async function Features() {
title: t("features.items.autoCommands.title"),
description: t("features.items.autoCommands.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 7h16v10H4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 11h0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 7h16v10H4z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 11h0"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
@@ -19,9 +37,27 @@ export default async function Features() {
title: t("features.items.componentsV2.title"),
description: t("features.items.componentsV2.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3v18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 8h14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 3v18"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5 8h14"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
@@ -29,9 +65,27 @@ export default async function Features() {
title: t("features.items.autoHelp.title"),
description: t("features.items.autoHelp.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12h18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6 8v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12h18"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 8v8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
@@ -39,9 +93,27 @@ export default async function Features() {
title: t("features.items.multiLanguage.title"),
description: t("features.items.multiLanguage.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 7h16v10H4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 11h0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 7h16v10H4z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 11h0"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
@@ -49,9 +121,27 @@ export default async function Features() {
title: t("features.items.dynamicVariables.title"),
description: t("features.items.dynamicVariables.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3v18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 8h14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 3v18"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5 8h14"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
@@ -59,9 +149,27 @@ export default async function Features() {
title: t("features.items.hosting.title"),
description: t("features.items.hosting.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12h18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6 8v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12h18"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 8v8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
@@ -76,7 +184,12 @@ export default async function Features() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{items.map((it, idx) => (
<FeatureCard key={idx} title={it.title} description={it.description} icon={it.icon} />
<FeatureCard
key={idx}
title={it.title}
description={it.description}
icon={it.icon}
/>
))}
</div>
</section>
+17 -4
View File
@@ -3,7 +3,13 @@ import { getT } from "../i18n/server";
import { Badge } from "./ui/Badge";
import { buttonClassName } from "./ui/Button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/Card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/Card";
export default async function Hero() {
const t = await getT();
@@ -24,7 +30,10 @@ export default async function Hero() {
</p>
<div className="flex flex-wrap items-center gap-3">
<Link className={buttonClassName({ size: "lg", variant: "primary" })} href="/login">
<Link
className={buttonClassName({ size: "lg", variant: "primary" })}
href="/login"
>
{t("hero.ctaStart")}
</Link>
@@ -53,14 +62,18 @@ export default async function Hero() {
key={bot.name}
>
<div className="space-y-1">
<p className="text-sm font-semibold text-[var(--foreground)]">{bot.name}</p>
<p className="text-sm font-semibold text-[var(--foreground)]">
{bot.name}
</p>
<p className="text-xs text-[var(--foreground-muted)]">
{t("hero.card.instanceMetrics")}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={bot.status === "online" ? "success" : "neutral"}>
<Badge
variant={bot.status === "online" ? "success" : "neutral"}
>
{bot.status === "online"
? t("hero.status.online")
: t("hero.status.stopped")}
+6 -2
View File
@@ -36,8 +36,12 @@ export default async function HowItWorks() {
{`${i + 1}`}
</Badge>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-[var(--foreground)]">{s.title}</h3>
<p className="text-sm text-[var(--foreground-muted)]">{s.desc}</p>
<h3 className="text-lg font-semibold text-[var(--foreground)]">
{s.title}
</h3>
<p className="text-sm text-[var(--foreground-muted)]">
{s.desc}
</p>
</div>
</CardContent>
</Card>
+54 -14
View File
@@ -5,7 +5,13 @@ import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useT } from "../i18n/client";
import { Badge } from "./ui/Badge";
import { Button, buttonClassName } from "./ui/Button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/Card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/Card";
import { Input } from "./ui/Input";
type User = {
@@ -105,7 +111,10 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
const botsJson = await botsResponse.json();
setBots((botsJson.bots ?? []) as Bot[]);
} catch (cause) {
const message = cause instanceof Error ? cause.message : t("dashboard.errors.unexpected");
const message =
cause instanceof Error
? cause.message
: t("dashboard.errors.unexpected");
setError(message);
} finally {
setLoading(false);
@@ -153,7 +162,10 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
setDisplayName("");
await refreshData();
} catch (cause) {
const message = cause instanceof Error ? cause.message : t("dashboard.errors.unexpected");
const message =
cause instanceof Error
? cause.message
: t("dashboard.errors.unexpected");
setError(message);
} finally {
setSubmitting(false);
@@ -164,19 +176,28 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
setError(null);
try {
const response = await fetch(`${apiBaseUrl}/api/bots/${botId}/${action}`, {
const response = await fetch(
`${apiBaseUrl}/api/bots/${botId}/${action}`,
{
method: "POST",
credentials: "include",
});
},
);
if (!response.ok) {
const errorJson = await response.json().catch(() => ({}));
throw new Error(errorJson.error ?? t("dashboard.errors.actionFailed", { action: actionLabel[action] }));
throw new Error(
errorJson.error ??
t("dashboard.errors.actionFailed", { action: actionLabel[action] }),
);
}
await refreshData();
} catch (cause) {
const message = cause instanceof Error ? cause.message : t("dashboard.errors.unexpected");
const message =
cause instanceof Error
? cause.message
: t("dashboard.errors.unexpected");
setError(message);
}
};
@@ -185,7 +206,9 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
return (
<Card className="max-w-xl">
<CardContent>
<p className="text-sm text-[var(--foreground-muted)]">{t("dashboard.loading")}</p>
<p className="text-sm text-[var(--foreground-muted)]">
{t("dashboard.loading")}
</p>
</CardContent>
</Card>
);
@@ -223,7 +246,9 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<Badge variant="accent">{t("dashboard.tenant", { tenantId: user.tenantId })}</Badge>
<Badge variant="accent">
{t("dashboard.tenant", { tenantId: user.tenantId })}
</Badge>
<h1 className="text-3xl font-semibold tracking-tight text-[var(--foreground)]">
{user.username}
</h1>
@@ -239,7 +264,9 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
{error ? (
<Card className="border-[color:color-mix(in_srgb,var(--danger)_35%,transparent)] bg-[color:color-mix(in_srgb,var(--danger)_10%,var(--surface))]">
<CardContent>
<p className="text-sm font-semibold text-[var(--danger)]">{error}</p>
<p className="text-sm font-semibold text-[var(--danger)]">
{error}
</p>
</CardContent>
</Card>
) : null}
@@ -249,7 +276,9 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
<Card className="h-full">
<CardHeader className="space-y-2">
<CardTitle>{t("dashboard.addBot.title")}</CardTitle>
<CardDescription>{t("dashboard.addBot.description")}</CardDescription>
<CardDescription>
{t("dashboard.addBot.description")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -280,7 +309,12 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
/>
</label>
<Button disabled={submitting} fullWidth type="submit" variant="primary">
<Button
disabled={submitting}
fullWidth
type="submit"
variant="primary"
>
{submitting
? t("dashboard.addBot.submitPending")
: t("dashboard.addBot.submit")}
@@ -294,7 +328,11 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
<Card className="h-full">
<CardHeader className="mb-4 flex flex-row flex-wrap items-center justify-between gap-3 space-y-0">
<CardTitle>{t("dashboard.bots.title")}</CardTitle>
<Button onClick={() => void refreshData()} size="sm" variant="secondary">
<Button
onClick={() => void refreshData()}
size="sm"
variant="secondary"
>
{t("dashboard.bots.refresh")}
</Button>
</CardHeader>
@@ -330,7 +368,9 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
{bot.lastError ? (
<p className="text-sm font-medium text-[var(--danger)]">
{t("dashboard.bots.lastError", { message: bot.lastError })}
{t("dashboard.bots.lastError", {
message: bot.lastError,
})}
</p>
) : null}
+16 -4
View File
@@ -14,11 +14,17 @@ export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
);
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
export function CardHeader({
className,
...props
}: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("mb-5 space-y-2", className)} {...props} />;
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
export function CardTitle({
className,
...props
}: HTMLAttributes<HTMLHeadingElement>) {
return (
<h2
className={cn(
@@ -36,12 +42,18 @@ export function CardDescription({
}: HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn("text-sm leading-relaxed text-[var(--foreground-muted)]", className)}
className={cn(
"text-sm leading-relaxed text-[var(--foreground-muted)]",
className,
)}
{...props}
/>
);
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
export function CardContent({
className,
...props
}: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("space-y-4", className)} {...props} />;
}
+8 -2
View File
@@ -2,10 +2,16 @@ import type { HTMLAttributes } from "react";
import { cn } from "./cn";
export function Container({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
export function Container({
className,
...props
}: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("mx-auto w-full max-w-[1200px] px-4 sm:px-6 lg:px-8", className)}
className={cn(
"mx-auto w-full max-w-[1200px] px-4 sm:px-6 lg:px-8",
className,
)}
{...props}
/>
);
+5 -1
View File
@@ -15,7 +15,11 @@ type NavbarProps = {
export default async function Navbar({ currentPath }: NavbarProps) {
const t = await getT();
const links: Array<{ href: "/" | "/dashboard"; key: NavbarPath; label: string }> = [
const links: Array<{
href: "/" | "/dashboard";
key: NavbarPath;
label: string;
}> = [
{ href: "/", key: "home", label: t("nav.home") },
{ href: "/dashboard", key: "dashboard", label: t("nav.dashboard") },
];
+3 -1
View File
@@ -27,7 +27,9 @@ export default async function PageWrapper({
<Navbar currentPath={currentPath} />
<main className={cn("relative py-10 md:py-14", className)}>
<Container className={cn("space-y-12", contentClassName)}>{children}</Container>
<Container className={cn("space-y-12", contentClassName)}>
{children}
</Container>
</main>
{footer ? (
+1 -3
View File
@@ -4,7 +4,5 @@ import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: [
"/((?!api|auth|_next|_vercel|.*\\..*).*)"
],
matcher: ["/((?!api|auth|_next|_vercel|.*\\..*).*)"],
};
+15
View File
@@ -5,7 +5,22 @@ const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// indispensable pour Docker + monorepo
output: "standalone",
// IMPORTANT pour éviter les problèmes de paths en monorepo
outputFileTracingRoot: process.cwd(),
// optimise build (optionnel mais recommandé)
poweredByHeader: false,
compress: true,
// évite erreurs turbopack / build tracing inutile
experimental: {
// utile en monorepo pour stabilité
externalDir: true,
},
};
export default withNextIntl(nextConfig);
+3 -14
View File
@@ -1,11 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"incremental": true,
"module": "esnext",
@@ -19,13 +15,6 @@
}
]
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"include": ["**/*.ts", "**/*.tsx", "next-env.d.ts", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+2 -3
View File
@@ -57,7 +57,8 @@ services:
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep -q PONG"]
test:
["CMD-SHELL", 'redis-cli -a "$${REDIS_PASSWORD}" ping | grep -q PONG']
interval: 10s
timeout: 5s
retries: 5
@@ -115,8 +116,6 @@ services:
condition: service_healthy
api:
condition: service_started
ports:
- "${BOT_PORT}:4100"
networks:
- edge_net
- backend_net
+92
View File
@@ -0,0 +1,92 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
export default [
// =========================
// IGNORE GLOBAL
// =========================
{
ignores: [
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/.next/**",
"**/coverage/**",
],
},
// =========================
// BASE JS RULES
// =========================
js.configs.recommended,
// =========================
// TYPESCRIPT SUPPORT
// =========================
...tseslint.configs.recommended,
// =========================
// NODE (API / BOT / SCRIPTS / SHARED)
// =========================
{
files: [
"apps/api/**/*.{js,mjs,ts}",
"apps/bot/**/*.{js,mjs,ts}",
"scripts/**/*.{js,mjs,ts}",
"packages/shared/**/*.{js,ts}",
],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.node,
},
},
rules: {
"no-unused-vars": "warn",
},
},
// =========================
// WEB (NEXT.JS)
// =========================
{
files: ["apps/web/**/*.{ts,tsx,js,jsx}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
},
},
rules: {
"no-unused-vars": "warn",
},
},
// =========================
// COMMONJS FILES (.cjs)
// =========================
{
files: ["**/*.cjs"],
languageOptions: {
sourceType: "commonjs",
globals: {
module: "readonly",
require: "readonly",
exports: "readonly",
},
},
},
// =========================
// LEGACY BOT (réduit le bruit)
// =========================
{
files: ["apps/bot/src/legacy/**"],
rules: {
"no-unused-vars": "warn",
},
},
];
+1105 -1
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -25,11 +25,15 @@
"check": "npm run typecheck"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^22.10.7",
"autoprefixer": "^10.4.14",
"eslint": "^10.2.1",
"globals": "^17.5.0",
"postcss": "^8.4.21",
"tailwindcss": "^3.4.7",
"tsx": "^4.19.3",
"typescript": "^5.7.3",
"tailwindcss": "^3.4.7",
"postcss": "^8.4.21",
"autoprefixer": "^10.4.14"
"typescript-eslint": "^8.59.0"
}
}
+7 -1
View File
@@ -1,5 +1,11 @@
export const BOT_CONTROL_QUEUE = "bot-control";
export const BOT_CONTROL_QUEUE_PREFIX = "discord-saas";
export const BOT_STATUS = ["stopped", "starting", "running", "stopping", "error"] as const;
export const BOT_STATUS = [
"stopped",
"starting",
"running",
"stopping",
"error",
] as const;
export type BotStatus = (typeof BOT_STATUS)[number];

Some files were not shown because too many files have changed in this diff Show More