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