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
|
Version: 3.0
|
||||||
|
|
||||||
But
|
## But
|
||||||
---
|
|
||||||
Decrire l'architecture cible du monorepo SaaS pour bot Discord multi-tenant.
|
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.
|
- Stack backend: Node.js + TypeScript + Express + PostgreSQL + Redis + BullMQ.
|
||||||
- Bot runtime: Discord.js avec gestion dynamique multi-instance.
|
- Bot runtime: Discord.js avec gestion dynamique multi-instance.
|
||||||
- Frontend: Next.js App Router.
|
- Frontend: Next.js App Router.
|
||||||
- Structure: monorepo `apps/*` + `packages/*`.
|
- Structure: monorepo `apps/*` + `packages/*`.
|
||||||
|
|
||||||
Organisation des dossiers
|
## Organisation des dossiers
|
||||||
-------------------------
|
|
||||||
- `apps/api/`
|
- `apps/api/`
|
||||||
- OAuth2 Discord, JWT cookie, routes multi-tenant, validation token bot, publication jobs BullMQ.
|
- OAuth2 Discord, JWT cookie, routes multi-tenant, validation token bot, publication jobs BullMQ.
|
||||||
- `apps/bot/`
|
- `apps/bot/`
|
||||||
@@ -26,8 +26,8 @@ Organisation des dossiers
|
|||||||
- `database/migrations/`
|
- `database/migrations/`
|
||||||
- SQL versionne (`schema_migrations`) avec schema multi-tenant.
|
- SQL versionne (`schema_migrations`) avec schema multi-tenant.
|
||||||
|
|
||||||
Principes d'architecture
|
## Principes d'architecture
|
||||||
------------------------
|
|
||||||
- Multi-tenant strict:
|
- Multi-tenant strict:
|
||||||
- toutes les requetes de lecture/ecriture passent par `tenant_id`.
|
- toutes les requetes de lecture/ecriture passent par `tenant_id`.
|
||||||
- tables critiques liees a `tenant_id` et/ou `owner_user_id`.
|
- tables critiques liees a `tenant_id` et/ou `owner_user_id`.
|
||||||
@@ -40,15 +40,15 @@ Principes d'architecture
|
|||||||
- Bot: runtime Discord.
|
- Bot: runtime Discord.
|
||||||
- Web: UX dashboard.
|
- Web: UX dashboard.
|
||||||
|
|
||||||
Conventions de code
|
## Conventions de code
|
||||||
-------------------
|
|
||||||
- TypeScript strict.
|
- TypeScript strict.
|
||||||
- Exports nommes.
|
- Exports nommes.
|
||||||
- Erreurs API explicites et codes HTTP coherents.
|
- Erreurs API explicites et codes HTTP coherents.
|
||||||
- Logs sans fuite de secrets.
|
- Logs sans fuite de secrets.
|
||||||
|
|
||||||
Workflow recommande
|
## Workflow recommande
|
||||||
-------------------
|
|
||||||
1. Ajouter/adapter migration SQL dans `database/migrations`.
|
1. Ajouter/adapter migration SQL dans `database/migrations`.
|
||||||
2. Adapter repositories API/Bot avec filtre tenant.
|
2. Adapter repositories API/Bot avec filtre tenant.
|
||||||
3. Ajouter endpoint API + validation zod.
|
3. Ajouter endpoint API + validation zod.
|
||||||
@@ -56,8 +56,8 @@ Workflow recommande
|
|||||||
5. Mettre a jour le dashboard web.
|
5. Mettre a jour le dashboard web.
|
||||||
6. Valider `npm run typecheck` puis `docker compose up -d --build`.
|
6. Valider `npm run typecheck` puis `docker compose up -d --build`.
|
||||||
|
|
||||||
Securite
|
## Securite
|
||||||
--------
|
|
||||||
- Ne jamais committer `.env`.
|
- Ne jamais committer `.env`.
|
||||||
- Garder `TOKEN_ENCRYPTION_KEY` hors depot.
|
- Garder `TOKEN_ENCRYPTION_KEY` hors depot.
|
||||||
- Appliquer `httpOnly` + `sameSite` sur cookie session.
|
- Appliquer `httpOnly` + `sameSite` sur cookie session.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ docker-compose.yml
|
|||||||
- Maintient une map en mémoire:
|
- Maintient une map en mémoire:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
Map<botId, DiscordClient>
|
Map<botId, DiscordClient>;
|
||||||
```
|
```
|
||||||
|
|
||||||
- Worker BullMQ: consomme les jobs `start|stop|restart`
|
- Worker BullMQ: consomme les jobs `start|stop|restart`
|
||||||
@@ -55,32 +55,32 @@ Map<botId, DiscordClient>
|
|||||||
### 3) Web (`apps/web`)
|
### 3) Web (`apps/web`)
|
||||||
|
|
||||||
- Pages principales:
|
- Pages principales:
|
||||||
- `/login`
|
- `/login`
|
||||||
- `/dashboard`
|
- `/dashboard`
|
||||||
- Dashboard utilisateur:
|
- Dashboard utilisateur:
|
||||||
- Ajouter un bot (token)
|
- Ajouter un bot (token)
|
||||||
- Voir la liste des bots du tenant
|
- Voir la liste des bots du tenant
|
||||||
- `start / stop / restart`
|
- `start / stop / restart`
|
||||||
|
|
||||||
## Schéma PostgreSQL (core SaaS)
|
## Schéma PostgreSQL (core SaaS)
|
||||||
|
|
||||||
Migration: `database/migrations/0004_saas_multitenant.sql`
|
Migration: `database/migrations/0004_saas_multitenant.sql`
|
||||||
|
|
||||||
- `tenants`
|
- `tenants`
|
||||||
- `id` (UUID)
|
- `id` (UUID)
|
||||||
- `owner_user_id`
|
- `owner_user_id`
|
||||||
- `users`
|
- `users`
|
||||||
- `tenant_id` FK
|
- `tenant_id` FK
|
||||||
- `discord_user_id` (unique)
|
- `discord_user_id` (unique)
|
||||||
- `role`
|
- `role`
|
||||||
- `bots`
|
- `bots`
|
||||||
- `tenant_id` FK
|
- `tenant_id` FK
|
||||||
- `owner_user_id` FK
|
- `owner_user_id` FK
|
||||||
- `discord_bot_id` (unique global)
|
- `discord_bot_id` (unique global)
|
||||||
- `token_ciphertext`, `token_iv`, `token_tag`
|
- `token_ciphertext`, `token_iv`, `token_tag`
|
||||||
- `status`, `last_error`
|
- `status`, `last_error`
|
||||||
- `bot_runtime_events`
|
- `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.
|
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`
|
- `GET /api/bots`
|
||||||
- `POST /api/bots`
|
- `POST /api/bots`
|
||||||
- body: `{ token: string, displayName?: string }`
|
- body: `{ token: string, displayName?: string }`
|
||||||
- `POST /api/bots/:botId/start`
|
- `POST /api/bots/:botId/start`
|
||||||
- `POST /api/bots/:botId/stop`
|
- `POST /api/bots/:botId/stop`
|
||||||
- `POST /api/bots/:botId/restart`
|
- `POST /api/bots/:botId/restart`
|
||||||
@@ -107,7 +107,7 @@ Tous ces endpoints sont tenant-scopés via la session.
|
|||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
- Tokens bot jamais stockés en clair
|
- 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
|
- Validation token côté Discord avant insertion
|
||||||
- Session auth via JWT httpOnly cookie
|
- Session auth via JWT httpOnly cookie
|
||||||
- Filtrage systématique des requêtes par `tenant_id`
|
- 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.
|
- Une instance bot unique peut gérer des dizaines/centaines de bots selon ressources.
|
||||||
- Pour monter en charge horizontalement:
|
- Pour monter en charge horizontalement:
|
||||||
- scaler `apps/api`
|
- scaler `apps/api`
|
||||||
- scaler `apps/bot` avec coordination queue/locks
|
- scaler `apps/bot` avec coordination queue/locks
|
||||||
- conserver Redis + PostgreSQL managés
|
- conserver Redis + PostgreSQL managés
|
||||||
|
|||||||
@@ -10,12 +10,21 @@ const LOCK_CLASS_ID = 7813;
|
|||||||
const LOCK_OBJECT_ID = 4312;
|
const LOCK_OBJECT_ID = 4312;
|
||||||
|
|
||||||
const parseBoolean = (value, fallback = false) => {
|
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;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = String(value).trim().toLowerCase();
|
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");
|
const sha256 = (content) => createHash("sha256").update(content).digest("hex");
|
||||||
@@ -33,7 +42,9 @@ const loadMigrations = async (migrationsDir) => {
|
|||||||
const files = await listMigrationFiles(migrationsDir);
|
const files = await listMigrationFiles(migrationsDir);
|
||||||
|
|
||||||
if (files.length === 0) {
|
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 = [];
|
const migrations = [];
|
||||||
@@ -63,9 +74,13 @@ const ensureMigrationTable = async (client) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyMigrations = async (client, migrations) => {
|
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) {
|
for (const migration of migrations) {
|
||||||
const existingChecksum = appliedByVersion.get(migration.fileName);
|
const existingChecksum = appliedByVersion.get(migration.fileName);
|
||||||
@@ -91,7 +106,9 @@ const applyMigrations = async (client, migrations) => {
|
|||||||
console.log(`[migrate] applied ${migration.fileName}`);
|
console.log(`[migrate] applied ${migration.fileName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query("ROLLBACK").catch(() => undefined);
|
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,
|
connectionString: process.env.DATABASE_URL,
|
||||||
ssl: parseBoolean(process.env.DATABASE_SSL, false)
|
ssl: parseBoolean(process.env.DATABASE_SSL, false)
|
||||||
? {
|
? {
|
||||||
rejectUnauthorized: true,
|
rejectUnauthorized: true,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,12 +138,20 @@ const main = async () => {
|
|||||||
try {
|
try {
|
||||||
const migrations = await loadMigrations(migrationsDir);
|
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 ensureMigrationTable(client);
|
||||||
await applyMigrations(client, migrations);
|
await applyMigrations(client, migrations);
|
||||||
console.log("[migrate] completed");
|
console.log("[migrate] completed");
|
||||||
} finally {
|
} 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();
|
client.release();
|
||||||
await pool.end();
|
await pool.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export const buildDiscordLoginUrl = (state: string): string => {
|
|||||||
return `https://discord.com/oauth2/authorize?${params.toString()}`;
|
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) {
|
if (!avatarHash) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -61,7 +64,9 @@ const createAvatarUrl = (discordUserId: string, avatarHash: string | null): stri
|
|||||||
return `https://cdn.discordapp.com/avatars/${discordUserId}/${avatarHash}.png`;
|
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({
|
const payload = new URLSearchParams({
|
||||||
client_id: env.DISCORD_CLIENT_ID,
|
client_id: env.DISCORD_CLIENT_ID,
|
||||||
client_secret: env.DISCORD_CLIENT_SECRET,
|
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`, {
|
const response = await fetch(`${DISCORD_API_BASE}/users/@me`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bot ${botToken}`,
|
Authorization: `Bot ${botToken}`,
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ interface SessionJwtPayload {
|
|||||||
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET);
|
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({
|
return new SignJWT({
|
||||||
tenantId: session.tenantId,
|
tenantId: session.tenantId,
|
||||||
discordUserId: session.discordUserId,
|
discordUserId: session.discordUserId,
|
||||||
@@ -25,7 +27,9 @@ export const issueSessionToken = async (session: AuthSession): Promise<string> =
|
|||||||
.sign(secret);
|
.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, {
|
const verified = await jwtVerify<SessionJwtPayload>(token, secret, {
|
||||||
algorithms: ["HS256"],
|
algorithms: ["HS256"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,18 +5,27 @@ loadEnv();
|
|||||||
|
|
||||||
const parseBoolean = (value: string): boolean => {
|
const parseBoolean = (value: string): boolean => {
|
||||||
const normalized = value.trim().toLowerCase();
|
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({
|
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),
|
PORT: z.coerce.number().int().positive().default(4000),
|
||||||
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
|
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
|
||||||
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
|
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
|
||||||
REDIS_URL: z.string().url("REDIS_URL must be a valid URL"),
|
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_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"),
|
||||||
DISCORD_CLIENT_SECRET: z.string().min(1, "DISCORD_CLIENT_SECRET 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"),
|
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"),
|
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"),
|
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),
|
COOKIE_SECURE: z.string().default("false").transform(parseBoolean),
|
||||||
TOKEN_ENCRYPTION_KEY: z.string().min(10, "TOKEN_ENCRYPTION_KEY is required"),
|
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_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);
|
export const env = envSchema.parse(process.env);
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export const createPgPool = (): Pool => {
|
|||||||
connectionString: env.DATABASE_URL,
|
connectionString: env.DATABASE_URL,
|
||||||
ssl: env.DATABASE_SSL
|
ssl: env.DATABASE_SSL
|
||||||
? {
|
? {
|
||||||
rejectUnauthorized: true,
|
rejectUnauthorized: true,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -151,7 +151,10 @@ export const getUserByIdAndTenant = async (
|
|||||||
return mapUser(result.rows[0] as Record<string, unknown>);
|
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(
|
const result = await pool.query(
|
||||||
`
|
`
|
||||||
SELECT id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
|
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],
|
[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");
|
throw new Error("BOT_ALREADY_CLAIMED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,9 +56,15 @@ const bootstrap = async (): Promise<void> => {
|
|||||||
return;
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,12 +99,10 @@ const bootstrap = async (): Promise<void> => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const server = app.listen(env.PORT, () => {
|
const server = app.listen(env.PORT, () => {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[api] listening on :${env.PORT}`);
|
console.log(`[api] listening on :${env.PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const shutdown = async (signal: string): Promise<void> => {
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[api] shutdown requested (${signal})`);
|
console.log(`[api] shutdown requested (${signal})`);
|
||||||
|
|
||||||
server.close(async () => {
|
server.close(async () => {
|
||||||
@@ -119,7 +123,6 @@ const bootstrap = async (): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
bootstrap().catch((error) => {
|
bootstrap().catch((error) => {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("[api] fatal startup error", error);
|
console.error("[api] fatal startup error", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import type { Router } from "express";
|
|||||||
import { Router as expressRouter } from "express";
|
import { Router as expressRouter } from "express";
|
||||||
import type { Pool } from "pg";
|
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 { issueSessionToken } from "../auth/jwt.js";
|
||||||
import { env } from "../config/env.js";
|
import { env } from "../config/env.js";
|
||||||
import { upsertUserFromDiscord } from "../db/repositories.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];
|
const cookieState = req.cookies?.[OAUTH_STATE_COOKIE_NAME];
|
||||||
|
|
||||||
if (!code || !state || !cookieState || state !== cookieState) {
|
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`);
|
res.redirect(`${env.WEB_URL}/login?error=oauth_state_invalid`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -66,11 +72,15 @@ export const createAuthRouter = ({ pool }: AuthRouterDependencies): Router => {
|
|||||||
username: user.username,
|
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.cookie(env.SESSION_COOKIE_NAME, sessionToken, sessionCookieConfig);
|
||||||
res.redirect(`${env.WEB_URL}/dashboard`);
|
res.redirect(`${env.WEB_URL}/dashboard`);
|
||||||
} catch {
|
} 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`);
|
res.redirect(`${env.WEB_URL}/login?error=oauth_failed`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,13 +50,23 @@ const queueBotAction = (
|
|||||||
return;
|
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) {
|
if (!bot) {
|
||||||
res.status(404).json({ error: "Bot not found" });
|
res.status(404).json({ error: "Bot not found" });
|
||||||
return;
|
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, {
|
await insertBotRuntimeEvent(deps.pool, {
|
||||||
tenantId: req.auth.tenantId,
|
tenantId: req.auth.tenantId,
|
||||||
@@ -105,13 +115,18 @@ export const createBotRouter = (deps: BotRouterDependencies): Router => {
|
|||||||
|
|
||||||
const parsedBody = addBotSchema.safeParse(req.body);
|
const parsedBody = addBotSchema.safeParse(req.body);
|
||||||
if (!parsedBody.success) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const botIdentity = await validateDiscordBotToken(parsedBody.data.token);
|
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, {
|
const bot = await createOrUpdateBotForTenant(deps.pool, {
|
||||||
tenantId: req.auth.tenantId,
|
tenantId: req.auth.tenantId,
|
||||||
@@ -135,11 +150,16 @@ export const createBotRouter = (deps: BotRouterDependencies): Router => {
|
|||||||
res.status(201).json({ bot });
|
res.status(201).json({ bot });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "BOT_ALREADY_CLAIMED") {
|
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;
|
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 });
|
res.status(400).json({ error: error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -148,9 +168,21 @@ export const createBotRouter = (deps: BotRouterDependencies): Router => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/:botId/start", deps.tenantControlRateLimit, queueBotAction("start", "starting", deps));
|
router.post(
|
||||||
router.post("/:botId/stop", deps.tenantControlRateLimit, queueBotAction("stop", "stopping", deps));
|
"/:botId/start",
|
||||||
router.post("/:botId/restart", deps.tenantControlRateLimit, queueBotAction("restart", "starting", deps));
|
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;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
"composite": true,
|
"composite": true,
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"types": ["node"]
|
"types": ["node"],
|
||||||
|
"declaration": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,24 +6,27 @@ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const BOT_DIR = path.resolve(SCRIPT_DIR, "..");
|
const BOT_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||||
const SOURCE_DIR = path.join(BOT_DIR, "locales");
|
const SOURCE_DIR = path.join(BOT_DIR, "locales");
|
||||||
const TARGET_DIRECTORIES = [
|
const TARGET_DIRECTORIES = [
|
||||||
path.join(BOT_DIR, "dist", "locales"),
|
path.join(BOT_DIR, "dist", "locales"),
|
||||||
path.join(BOT_DIR, "dist", "legacy", "i18n"),
|
path.join(BOT_DIR, "dist", "legacy", "i18n"),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!existsSync(SOURCE_DIR)) {
|
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) {
|
for (const targetDirectory of TARGET_DIRECTORIES) {
|
||||||
mkdirSync(targetDirectory, { recursive: true });
|
mkdirSync(targetDirectory, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const fileName of readdirSync(SOURCE_DIR)) {
|
for (const fileName of readdirSync(SOURCE_DIR)) {
|
||||||
if (!fileName.endsWith(".json")) {
|
if (!fileName.endsWith(".json")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const targetDirectory of TARGET_DIRECTORIES) {
|
for (const targetDirectory of TARGET_DIRECTORIES) {
|
||||||
copyFileSync(path.join(SOURCE_DIR, fileName), path.join(targetDirectory, fileName));
|
copyFileSync(
|
||||||
}
|
path.join(SOURCE_DIR, fileName),
|
||||||
|
path.join(targetDirectory, fileName),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,12 @@ loadEnv();
|
|||||||
|
|
||||||
const parseBoolean = (value: string): boolean => {
|
const parseBoolean = (value: string): boolean => {
|
||||||
const normalized = value.trim().toLowerCase();
|
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 => {
|
const optionalString = (value?: string): string | undefined => {
|
||||||
@@ -33,7 +38,9 @@ const parseOptionalUrl = (value: unknown): string | undefined => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const envSchema = z.object({
|
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),
|
PORT: z.coerce.number().int().positive().default(4100),
|
||||||
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
|
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
|
||||||
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
|
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
|
||||||
@@ -58,7 +65,10 @@ const envSchema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.default("false")
|
.default("false")
|
||||||
.transform(parseBoolean),
|
.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"),
|
TOKEN_ENCRYPTION_KEY: z.string().min(10, "TOKEN_ENCRYPTION_KEY is required"),
|
||||||
PRESENCE_STREAM_URL: z
|
PRESENCE_STREAM_URL: z
|
||||||
.string()
|
.string()
|
||||||
@@ -67,7 +77,10 @@ const envSchema = z.object({
|
|||||||
.default("https://twitch.tv/discord"),
|
.default("https://twitch.tv/discord"),
|
||||||
PREFIX: z.string().min(1).max(5).default("+"),
|
PREFIX: z.string().min(1).max(5).default("+"),
|
||||||
DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"),
|
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
|
AUTO_DEPLOY_SLASH: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -76,9 +89,21 @@ const envSchema = z.object({
|
|||||||
LOG_LEVEL: z.string().trim().min(1).default("info"),
|
LOG_LEVEL: z.string().trim().min(1).default("info"),
|
||||||
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
|
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
|
||||||
COMMAND_DISPATCH_MODE: z.enum(["local", "worker"]).default("local"),
|
COMMAND_DISPATCH_MODE: z.enum(["local", "worker"]).default("local"),
|
||||||
COMMAND_QUEUE_NAME: z.string().trim().min(1).default("bot:${botId}:command-jobs"),
|
COMMAND_QUEUE_NAME: z
|
||||||
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(20),
|
.string()
|
||||||
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(10),
|
.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
|
RATE_LIMIT_FAIL_OPEN: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -93,7 +118,11 @@ const envSchema = z.object({
|
|||||||
|
|
||||||
const parsed = envSchema.parse(process.env);
|
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(
|
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.",
|
"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 => {
|
export const createPgPool = (): Pool => {
|
||||||
const ssl = env.DATABASE_SSL
|
const ssl = env.DATABASE_SSL
|
||||||
? {
|
? {
|
||||||
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
|
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
|
||||||
ca: env.DATABASE_SSL_CA,
|
ca: env.DATABASE_SSL_CA,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return new Pool({
|
return new Pool({
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ export interface StoredBotCredentials {
|
|||||||
status: BotStatus;
|
status: BotStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapBotCredentials = (row: Record<string, unknown>): StoredBotCredentials => {
|
const mapBotCredentials = (
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
): StoredBotCredentials => {
|
||||||
return {
|
return {
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
tenantId: String(row.tenant_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(
|
const result = await pool.query(
|
||||||
`
|
`
|
||||||
SELECT id, tenant_id, owner_user_id, discord_bot_id, display_name, token_ciphertext, token_iv, token_tag, status
|
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 (
|
export const getBotCredentialsById = async (
|
||||||
|
|||||||
+20
-14
@@ -13,9 +13,9 @@ const bootstrap = async (): Promise<void> => {
|
|||||||
|
|
||||||
const redis = env.REDIS_URL
|
const redis = env.REDIS_URL
|
||||||
? new Redis(env.REDIS_URL, {
|
? new Redis(env.REDIS_URL, {
|
||||||
maxRetriesPerRequest: null,
|
maxRetriesPerRequest: null,
|
||||||
enableReadyCheck: true,
|
enableReadyCheck: true,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await pgPool.query("SELECT 1");
|
await pgPool.query("SELECT 1");
|
||||||
@@ -23,24 +23,33 @@ const bootstrap = async (): Promise<void> => {
|
|||||||
await redis.ping();
|
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();
|
await manager.loadAndStartPersistedBots();
|
||||||
|
|
||||||
const worker = redis ? createBotControlWorker(redis.duplicate(), manager) : null;
|
const worker = redis
|
||||||
|
? createBotControlWorker(redis.duplicate(), manager)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (worker) {
|
if (worker) {
|
||||||
worker.on("completed", (job) => {
|
worker.on("completed", (job) => {
|
||||||
// eslint-disable-next-line no-console
|
console.log(
|
||||||
console.log(`[bot] completed ${job.data.action} for bot ${job.data.botId}`);
|
`[bot] completed ${job.data.action} for bot ${job.data.botId}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on("failed", (job, error) => {
|
worker.on("failed", (job, error) => {
|
||||||
// eslint-disable-next-line no-console
|
console.error(
|
||||||
console.error(`[bot] failed ${job?.data.action ?? "unknown"} for bot ${job?.data.botId ?? "unknown"}`, error);
|
`[bot] failed ${job?.data.action ?? "unknown"} for bot ${job?.data.botId ?? "unknown"}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// eslint-disable-next-line no-console
|
console.warn(
|
||||||
console.warn("[bot] REDIS_URL is not configured, bot control queue worker is disabled");
|
"[bot] REDIS_URL is not configured, bot control queue worker is disabled",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const healthServer = createServer((req, res) => {
|
const healthServer = createServer((req, res) => {
|
||||||
@@ -65,12 +74,10 @@ const bootstrap = async (): Promise<void> => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
healthServer.listen(env.PORT, () => {
|
healthServer.listen(env.PORT, () => {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[bot] health endpoint available on :${env.PORT}`);
|
console.log(`[bot] health endpoint available on :${env.PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const shutdown = async (signal: string): Promise<void> => {
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[bot] shutdown requested (${signal})`);
|
console.log(`[bot] shutdown requested (${signal})`);
|
||||||
|
|
||||||
healthServer.close(async () => {
|
healthServer.close(async () => {
|
||||||
@@ -92,7 +99,6 @@ const bootstrap = async (): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
bootstrap().catch((error) => {
|
bootstrap().catch((error) => {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("[bot] fatal startup error", error);
|
console.error("[bot] fatal startup error", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,20 +32,16 @@ import { registerEvents } from "../events/index.js";
|
|||||||
import { createPrefixHandler } from "../handlers/prefixHandler.js";
|
import { createPrefixHandler } from "../handlers/prefixHandler.js";
|
||||||
import { createSlashHandler } from "../handlers/slashHandler.js";
|
import { createSlashHandler } from "../handlers/slashHandler.js";
|
||||||
import { I18nService } from "../i18n/index.js";
|
import { I18nService } from "../i18n/index.js";
|
||||||
import {
|
import { LogEventService } from "../modules/logs/index.js";
|
||||||
LogEventService,
|
import { MemberMessageService } from "../modules/memberMessages/index.js";
|
||||||
} from "../modules/logs/index.js";
|
import { PresenceService } from "../modules/presence/index.js";
|
||||||
import {
|
|
||||||
MemberMessageService,
|
|
||||||
} from "../modules/memberMessages/index.js";
|
|
||||||
import {
|
|
||||||
PresenceService,
|
|
||||||
} from "../modules/presence/index.js";
|
|
||||||
|
|
||||||
const SHUTDOWN_TIMEOUT_MS = 10_000;
|
const SHUTDOWN_TIMEOUT_MS = 10_000;
|
||||||
const log = createScopedLogger("bootstrap");
|
const log = createScopedLogger("bootstrap");
|
||||||
|
|
||||||
const bindGracefulShutdown = (shutdown: (signal: string) => Promise<void>): void => {
|
const bindGracefulShutdown = (
|
||||||
|
shutdown: (signal: string) => Promise<void>,
|
||||||
|
): void => {
|
||||||
process.once("SIGINT", () => {
|
process.once("SIGINT", () => {
|
||||||
void shutdown("SIGINT");
|
void shutdown("SIGINT");
|
||||||
});
|
});
|
||||||
@@ -56,7 +52,11 @@ const bindGracefulShutdown = (shutdown: (signal: string) => Promise<void>): void
|
|||||||
};
|
};
|
||||||
|
|
||||||
const bindFatalProcessHandlers = (
|
const bindFatalProcessHandlers = (
|
||||||
shutdown: (signal: string, exitCode?: number, error?: unknown) => Promise<void>,
|
shutdown: (
|
||||||
|
signal: string,
|
||||||
|
exitCode?: number,
|
||||||
|
error?: unknown,
|
||||||
|
) => Promise<void>,
|
||||||
): void => {
|
): void => {
|
||||||
process.once("uncaughtException", (error) => {
|
process.once("uncaughtException", (error) => {
|
||||||
log.error({ err: error }, "process uncaught exception");
|
log.error({ err: error }, "process uncaught exception");
|
||||||
@@ -72,16 +72,18 @@ const bindFatalProcessHandlers = (
|
|||||||
export const bootstrap = async (): Promise<void> => {
|
export const bootstrap = async (): Promise<void> => {
|
||||||
const dispatchMode: CommandDispatchMode = env.COMMAND_DISPATCH_MODE;
|
const dispatchMode: CommandDispatchMode = env.COMMAND_DISPATCH_MODE;
|
||||||
if (dispatchMode === "worker") {
|
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({
|
const pool = new Pool({
|
||||||
connectionString: env.DATABASE_URL,
|
connectionString: env.DATABASE_URL,
|
||||||
ssl: env.DATABASE_SSL
|
ssl: env.DATABASE_SSL
|
||||||
? {
|
? {
|
||||||
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
|
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
|
||||||
...(env.DATABASE_SSL_CA ? { ca: env.DATABASE_SSL_CA } : {}),
|
...(env.DATABASE_SSL_CA ? { ca: env.DATABASE_SSL_CA } : {}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,18 +131,23 @@ export const bootstrap = async (): Promise<void> => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const services: AppFeatureServices = {
|
const services: AppFeatureServices = {
|
||||||
presenceService: new PresenceService(presenceStore, env.PRESENCE_STREAM_URL),
|
presenceService: new PresenceService(
|
||||||
|
presenceStore,
|
||||||
|
env.PRESENCE_STREAM_URL,
|
||||||
|
),
|
||||||
memberMessageService: new MemberMessageService(memberMessageStore),
|
memberMessageService: new MemberMessageService(memberMessageStore),
|
||||||
logEventService: new LogEventService(logEventStore),
|
logEventService: new LogEventService(logEventStore),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cooldownStore = env.STATE_BACKEND === "redis" && redis
|
const cooldownStore =
|
||||||
? new RedisCooldownStore(redis)
|
env.STATE_BACKEND === "redis" && redis
|
||||||
: new MemoryCooldownStore();
|
? new RedisCooldownStore(redis)
|
||||||
|
: new MemoryCooldownStore();
|
||||||
|
|
||||||
const globalRateLimitStore = env.STATE_BACKEND === "redis" && redis
|
const globalRateLimitStore =
|
||||||
? new RedisGlobalRateLimitStore(redis)
|
env.STATE_BACKEND === "redis" && redis
|
||||||
: new MemoryGlobalRateLimitStore();
|
? new RedisGlobalRateLimitStore(redis)
|
||||||
|
: new MemoryGlobalRateLimitStore();
|
||||||
|
|
||||||
const executor = new CommandExecutor({
|
const executor = new CommandExecutor({
|
||||||
cooldownStore,
|
cooldownStore,
|
||||||
@@ -162,7 +169,11 @@ export const bootstrap = async (): Promise<void> => {
|
|||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
let client: Client | null = null;
|
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) {
|
if (shuttingDown) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -176,7 +187,10 @@ export const bootstrap = async (): Promise<void> => {
|
|||||||
|
|
||||||
const forcedExitCode = exitCode === 0 ? 1 : exitCode;
|
const forcedExitCode = exitCode === 0 ? 1 : exitCode;
|
||||||
const forceExitTimer = setTimeout(() => {
|
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);
|
process.exit(forcedExitCode);
|
||||||
}, SHUTDOWN_TIMEOUT_MS);
|
}, SHUTDOWN_TIMEOUT_MS);
|
||||||
|
|
||||||
@@ -224,7 +238,10 @@ export const bootstrap = async (): Promise<void> => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const i18n = new I18nService(env.DEFAULT_LANG);
|
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({
|
const onPrefixMessage = createPrefixHandler({
|
||||||
registry,
|
registry,
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import type {
|
import type { MemberMessageService } from "../modules/memberMessages/index.js";
|
||||||
MemberMessageService,
|
import type { LogEventService } from "../modules/logs/index.js";
|
||||||
} from "../modules/memberMessages/index.js";
|
import type { PresenceService } from "../modules/presence/index.js";
|
||||||
import type {
|
|
||||||
LogEventService,
|
|
||||||
} from "../modules/logs/index.js";
|
|
||||||
import type {
|
|
||||||
PresenceService,
|
|
||||||
} from "../modules/presence/index.js";
|
|
||||||
|
|
||||||
export interface AppFeatureServices {
|
export interface AppFeatureServices {
|
||||||
presenceService: PresenceService;
|
presenceService: PresenceService;
|
||||||
|
|||||||
@@ -14,18 +14,26 @@ import {
|
|||||||
} from "../modules/memberMessages/index.js";
|
} from "../modules/memberMessages/index.js";
|
||||||
|
|
||||||
/** Commande `goodbye` — ouvre le panneau de configuration des messages 'goodbye'. */
|
/** Commande `goodbye` — ouvre le panneau de configuration des messages 'goodbye'. */
|
||||||
export const createGoodbyeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
|
export const createGoodbyeCommand = (
|
||||||
meta: {
|
memberMessageService: MemberMessageService,
|
||||||
name: "goodbye",
|
i18n: I18nService,
|
||||||
category: "utility",
|
) =>
|
||||||
},
|
defineCommand({
|
||||||
permissions: [PermissionFlagsBits.ManageGuild],
|
meta: {
|
||||||
sensitive: true,
|
name: "goodbye",
|
||||||
examples: [
|
category: "utility",
|
||||||
{
|
|
||||||
source: "slash",
|
|
||||||
descriptionKey: "examples.slash",
|
|
||||||
},
|
},
|
||||||
],
|
permissions: [PermissionFlagsBits.ManageGuild],
|
||||||
execute: createMemberMessagePanelExecute("goodbye", memberMessageService, i18n),
|
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";
|
import type { BotCommand } from "../types/command.js";
|
||||||
|
|
||||||
/** CommandList: tableau ordonné des commandes disponibles. */
|
/** CommandList: tableau ordonné des commandes disponibles. */
|
||||||
export const createCommandList = (services: AppFeatureServices, i18n: I18nService): BotCommand[] => [
|
export const createCommandList = (
|
||||||
|
services: AppFeatureServices,
|
||||||
|
i18n: I18nService,
|
||||||
|
): BotCommand[] => [
|
||||||
kissCommand,
|
kissCommand,
|
||||||
pingCommand,
|
pingCommand,
|
||||||
createWelcomeCommand(services.memberMessageService, i18n),
|
createWelcomeCommand(services.memberMessageService, i18n),
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export const kissCommand = defineCommand({
|
|||||||
execute: async (ctx) => {
|
execute: async (ctx) => {
|
||||||
const target = ctx.args.user;
|
const target = ctx.args.user;
|
||||||
if (!target || typeof target !== "object" || !("id" in target)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ import {
|
|||||||
type LogEventService,
|
type LogEventService,
|
||||||
} from "../modules/logs/index.js";
|
} from "../modules/logs/index.js";
|
||||||
|
|
||||||
export const createLogsCommand = (logEventService: LogEventService) => defineCommand({
|
export const createLogsCommand = (logEventService: LogEventService) =>
|
||||||
meta: {
|
defineCommand({
|
||||||
name: "logs",
|
meta: {
|
||||||
category: "utility",
|
name: "logs",
|
||||||
},
|
category: "utility",
|
||||||
permissions: [PermissionFlagsBits.ManageGuild],
|
|
||||||
sensitive: true,
|
|
||||||
examples: [
|
|
||||||
{
|
|
||||||
source: "slash",
|
|
||||||
descriptionKey: "examples.slash",
|
|
||||||
},
|
},
|
||||||
],
|
permissions: [PermissionFlagsBits.ManageGuild],
|
||||||
execute: createLogsCommandExecute(logEventService),
|
sensitive: true,
|
||||||
});
|
examples: [
|
||||||
|
{
|
||||||
|
source: "slash",
|
||||||
|
descriptionKey: "examples.slash",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: createLogsCommandExecute(logEventService),
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ import {
|
|||||||
type PresenceService,
|
type PresenceService,
|
||||||
} from "../modules/presence/index.js";
|
} from "../modules/presence/index.js";
|
||||||
|
|
||||||
export const createPresenceCommand = (presenceService: PresenceService) => defineCommand({
|
export const createPresenceCommand = (presenceService: PresenceService) =>
|
||||||
meta: {
|
defineCommand({
|
||||||
name: "presence",
|
meta: {
|
||||||
category: "utility",
|
name: "presence",
|
||||||
},
|
category: "utility",
|
||||||
permissions: [PermissionFlagsBits.ManageGuild],
|
|
||||||
sensitive: true,
|
|
||||||
examples: [
|
|
||||||
{
|
|
||||||
source: "slash",
|
|
||||||
descriptionKey: "examples.slash",
|
|
||||||
},
|
},
|
||||||
],
|
permissions: [PermissionFlagsBits.ManageGuild],
|
||||||
execute: createPresenceCommandExecute(presenceService),
|
sensitive: true,
|
||||||
});
|
examples: [
|
||||||
|
{
|
||||||
|
source: "slash",
|
||||||
|
descriptionKey: "examples.slash",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: createPresenceCommandExecute(presenceService),
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,18 +14,26 @@ import {
|
|||||||
} from "../modules/memberMessages/index.js";
|
} from "../modules/memberMessages/index.js";
|
||||||
|
|
||||||
/** Commande `welcome` — ouvre le panneau de configuration des messages 'welcome'. */
|
/** Commande `welcome` — ouvre le panneau de configuration des messages 'welcome'. */
|
||||||
export const createWelcomeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
|
export const createWelcomeCommand = (
|
||||||
meta: {
|
memberMessageService: MemberMessageService,
|
||||||
name: "welcome",
|
i18n: I18nService,
|
||||||
category: "utility",
|
) =>
|
||||||
},
|
defineCommand({
|
||||||
permissions: [PermissionFlagsBits.ManageGuild],
|
meta: {
|
||||||
sensitive: true,
|
name: "welcome",
|
||||||
examples: [
|
category: "utility",
|
||||||
{
|
|
||||||
source: "slash",
|
|
||||||
descriptionKey: "examples.slash",
|
|
||||||
},
|
},
|
||||||
],
|
permissions: [PermissionFlagsBits.ManageGuild],
|
||||||
execute: createMemberMessagePanelExecute("welcome", memberMessageService, i18n),
|
sensitive: true,
|
||||||
});
|
examples: [
|
||||||
|
{
|
||||||
|
source: "slash",
|
||||||
|
descriptionKey: "examples.slash",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: createMemberMessagePanelExecute(
|
||||||
|
"welcome",
|
||||||
|
memberMessageService,
|
||||||
|
i18n,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ loadEnv();
|
|||||||
|
|
||||||
const toBoolean = (value: string): boolean => {
|
const toBoolean = (value: string): boolean => {
|
||||||
const normalized = value.trim().toLowerCase();
|
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 => {
|
const optionalString = (value?: string): string | undefined => {
|
||||||
@@ -40,11 +45,7 @@ const envSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.min(1, "DATABASE_URL is required")
|
.min(1, "DATABASE_URL is required")
|
||||||
.url("DATABASE_URL must be a valid URL"),
|
.url("DATABASE_URL must be a valid URL"),
|
||||||
DATABASE_SSL: z
|
DATABASE_SSL: z.string().optional().default("false").transform(toBoolean),
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.default("false")
|
|
||||||
.transform(toBoolean),
|
|
||||||
DATABASE_SSL_REJECT_UNAUTHORIZED: z
|
DATABASE_SSL_REJECT_UNAUTHORIZED: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -73,7 +74,10 @@ const envSchema = z.object({
|
|||||||
.default("https://twitch.tv/discord"),
|
.default("https://twitch.tv/discord"),
|
||||||
PREFIX: z.string().min(1).max(5).default("+"),
|
PREFIX: z.string().min(1).max(5).default("+"),
|
||||||
DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"),
|
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
|
AUTO_DEPLOY_SLASH: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -81,11 +85,26 @@ const envSchema = z.object({
|
|||||||
.transform(toBoolean),
|
.transform(toBoolean),
|
||||||
LOG_LEVEL: z.string().trim().min(1).default("info"),
|
LOG_LEVEL: z.string().trim().min(1).default("info"),
|
||||||
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
|
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_DISPATCH_MODE: z.enum(["local", "worker"]).default("local"),
|
||||||
COMMAND_QUEUE_NAME: z.string().trim().min(1).default("bot:${botId}:command-jobs"),
|
COMMAND_QUEUE_NAME: z
|
||||||
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(20),
|
.string()
|
||||||
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(10),
|
.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
|
RATE_LIMIT_FAIL_OPEN: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -100,16 +119,18 @@ const envSchema = z.object({
|
|||||||
|
|
||||||
const parsed = envSchema.parse(process.env);
|
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(
|
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.",
|
"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) {
|
if (parsed.STATE_BACKEND === "redis" && !parsed.REDIS_URL) {
|
||||||
throw new Error(
|
throw new Error("REDIS_URL is required when STATE_BACKEND=redis.");
|
||||||
"REDIS_URL is required when STATE_BACKEND=redis.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const env = parsed;
|
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";
|
import type { CommandArgValue, CommandArgument } from "../../types/command.js";
|
||||||
|
|
||||||
const USER_MENTION_PATTERN = /^<@!?(\d{16,22})>$/;
|
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 BOOLEAN_FALSE = new Set(["false", "0", "no", "n", "off"]);
|
||||||
|
|
||||||
const isGuildBasedChannel = (value: unknown): value is GuildBasedChannel => {
|
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[] => {
|
export const tokenizePrefixInput = (raw: string): string[] => {
|
||||||
@@ -21,7 +32,9 @@ export const tokenizePrefixInput = (raw: string): string[] => {
|
|||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = regex.exec(raw)) !== 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;
|
return tokens;
|
||||||
@@ -41,7 +54,10 @@ export const parsePrefixArgs = async (
|
|||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
if (definition.required) {
|
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;
|
values[definition.name] = undefined;
|
||||||
continue;
|
continue;
|
||||||
@@ -81,7 +97,10 @@ export const parseSlashArgs = (
|
|||||||
values[definition.name] = parseByTypeFromSlash(interaction, definition);
|
values[definition.name] = parseByTypeFromSlash(interaction, definition);
|
||||||
} catch {
|
} catch {
|
||||||
if (definition.required) {
|
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;
|
values[definition.name] = undefined;
|
||||||
}
|
}
|
||||||
@@ -94,14 +113,20 @@ const parseByTypeFromPrefix = async (
|
|||||||
message: Message,
|
message: Message,
|
||||||
type: CommandArgument["type"],
|
type: CommandArgument["type"],
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<{ ok: true; value: CommandArgValue } | { ok: false; error: ArgumentParseError }> => {
|
): Promise<
|
||||||
|
| { ok: true; value: CommandArgValue }
|
||||||
|
| { ok: false; error: ArgumentParseError }
|
||||||
|
> => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "string":
|
case "string":
|
||||||
return { ok: true, value: token };
|
return { ok: true, value: token };
|
||||||
|
|
||||||
case "int": {
|
case "int": {
|
||||||
if (!/^-?\d+$/.test(token)) {
|
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);
|
const value = Number.parseInt(token, 10);
|
||||||
@@ -111,7 +136,10 @@ const parseByTypeFromPrefix = async (
|
|||||||
case "number": {
|
case "number": {
|
||||||
const value = Number(token);
|
const value = Number(token);
|
||||||
if (!Number.isFinite(value)) {
|
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 };
|
return { ok: true, value };
|
||||||
}
|
}
|
||||||
@@ -124,7 +152,10 @@ const parseByTypeFromPrefix = async (
|
|||||||
if (BOOLEAN_FALSE.has(normalized)) {
|
if (BOOLEAN_FALSE.has(normalized)) {
|
||||||
return { ok: true, value: false };
|
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": {
|
case "user": {
|
||||||
@@ -133,7 +164,10 @@ const parseByTypeFromPrefix = async (
|
|||||||
const userId = mentionMatch?.[1] ?? idMatch?.[1];
|
const userId = mentionMatch?.[1] ?? idMatch?.[1];
|
||||||
|
|
||||||
if (!userId) {
|
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);
|
const fromMention = message.mentions.users.get(userId);
|
||||||
@@ -145,7 +179,10 @@ const parseByTypeFromPrefix = async (
|
|||||||
const fetched = await message.client.users.fetch(userId);
|
const fetched = await message.client.users.fetch(userId);
|
||||||
return { ok: true, value: fetched };
|
return { ok: true, value: fetched };
|
||||||
} catch {
|
} 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];
|
const channelId = mentionMatch?.[1] ?? idMatch?.[1];
|
||||||
|
|
||||||
if (!channelId) {
|
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);
|
const fromMention = message.mentions.channels.get(channelId);
|
||||||
@@ -186,7 +226,10 @@ const parseByTypeFromPrefix = async (
|
|||||||
// Falls through to invalid channel error.
|
// 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": {
|
case "role": {
|
||||||
@@ -195,7 +238,10 @@ const parseByTypeFromPrefix = async (
|
|||||||
const roleId = mentionMatch?.[1] ?? idMatch?.[1];
|
const roleId = mentionMatch?.[1] ?? idMatch?.[1];
|
||||||
|
|
||||||
if (!roleId || !message.guild) {
|
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);
|
const fromMention = message.mentions.roles.get(roleId);
|
||||||
@@ -217,7 +263,10 @@ const parseByTypeFromPrefix = async (
|
|||||||
// Falls through to invalid role error.
|
// 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:
|
default:
|
||||||
@@ -231,27 +280,51 @@ const parseByTypeFromSlash = (
|
|||||||
): CommandArgValue => {
|
): CommandArgValue => {
|
||||||
switch (definition.type) {
|
switch (definition.type) {
|
||||||
case "string":
|
case "string":
|
||||||
return interaction.options.getString(definition.name, definition.required) ?? undefined;
|
return (
|
||||||
|
interaction.options.getString(definition.name, definition.required) ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
case "int":
|
case "int":
|
||||||
return interaction.options.getInteger(definition.name, definition.required) ?? undefined;
|
return (
|
||||||
|
interaction.options.getInteger(definition.name, definition.required) ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
return interaction.options.getNumber(definition.name, definition.required) ?? undefined;
|
return (
|
||||||
|
interaction.options.getNumber(definition.name, definition.required) ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
case "boolean":
|
case "boolean":
|
||||||
return interaction.options.getBoolean(definition.name, definition.required) ?? undefined;
|
return (
|
||||||
|
interaction.options.getBoolean(definition.name, definition.required) ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
case "user":
|
case "user":
|
||||||
return interaction.options.getUser(definition.name, definition.required) ?? undefined;
|
return (
|
||||||
|
interaction.options.getUser(definition.name, definition.required) ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
case "channel":
|
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":
|
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:
|
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;
|
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) {
|
if (input.sensitive === true && permissions.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid security config for command "${input.meta.name}": sensitive commands must declare at least one required permission.`,
|
`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 { REST, Routes } from "discord.js";
|
||||||
|
|
||||||
import { buildSlashPayload } from "./slashBuilder.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 body = buildSlashPayload(options.registry.getAll(), options.i18n);
|
||||||
const rest = new REST({ version: "10" }).setToken(options.token);
|
const rest = new REST({ version: "10" }).setToken(options.token);
|
||||||
|
|
||||||
if (options.guildId) {
|
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 };
|
return { scope: "guild", count: body.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export class CommandRegistry {
|
|||||||
return this.commandsByName.get(normalize(name));
|
return this.commandsByName.get(normalize(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
public findByAnyPrefixTrigger(trigger: string): PrefixTriggerMatch | undefined {
|
public findByAnyPrefixTrigger(
|
||||||
|
trigger: string,
|
||||||
|
): PrefixTriggerMatch | undefined {
|
||||||
return this.prefixTriggers.get(normalize(trigger));
|
return this.prefixTriggers.get(normalize(trigger));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +45,9 @@ export class CommandRegistry {
|
|||||||
this.commandsByName.set(name, command);
|
this.commandsByName.set(name, command);
|
||||||
|
|
||||||
for (const lang of SUPPORTED_LANGS) {
|
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);
|
const existing = this.prefixTriggers.get(trigger);
|
||||||
|
|
||||||
if (existing) {
|
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) {
|
for (const keyRaw of slashKeys) {
|
||||||
const key = normalize(keyRaw);
|
const key = normalize(keyRaw);
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
type RESTPostAPIChatInputApplicationCommandsJSONBody,
|
type RESTPostAPIChatInputApplicationCommandsJSONBody,
|
||||||
} from "discord.js";
|
} 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";
|
import type { I18nService } from "../../i18n/index.js";
|
||||||
|
|
||||||
const LANG_TO_DISCORD_LOCALE: Partial<Record<SupportedLang, string>> = {
|
const LANG_TO_DISCORD_LOCALE: Partial<Record<SupportedLang, string>> = {
|
||||||
@@ -24,7 +29,9 @@ const LANG_TO_DISCORD_LOCALE: Partial<Record<SupportedLang, string>> = {
|
|||||||
tr: "tr",
|
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)
|
const entries = Object.entries(source)
|
||||||
.map(([lang, value]) => {
|
.map(([lang, value]) => {
|
||||||
const discordLocale = LANG_TO_DISCORD_LOCALE[lang as SupportedLang];
|
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);
|
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>> = {};
|
const localizations: Partial<Record<SupportedLang, string>> = {};
|
||||||
|
|
||||||
for (const lang of SUPPORTED_LANGS) {
|
for (const lang of SUPPORTED_LANGS) {
|
||||||
@@ -53,7 +63,10 @@ const buildCommandNameLocalizationSource = (command: BotCommand, i18n: I18nServi
|
|||||||
return localizations;
|
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>> = {};
|
const localizations: Partial<Record<SupportedLang, string>> = {};
|
||||||
|
|
||||||
for (const lang of SUPPORTED_LANGS) {
|
for (const lang of SUPPORTED_LANGS) {
|
||||||
@@ -67,7 +80,10 @@ const buildDescriptionLocalizationSource = (descriptionKey: string, i18n: I18nSe
|
|||||||
return localizations;
|
return localizations;
|
||||||
};
|
};
|
||||||
|
|
||||||
const argDescriptionKey = (command: BotCommand, arg: CommandArgument): string => {
|
const argDescriptionKey = (
|
||||||
|
command: BotCommand,
|
||||||
|
arg: CommandArgument,
|
||||||
|
): string => {
|
||||||
return `commands.${command.meta.name}.${arg.descriptionKey}`;
|
return `commands.${command.meta.name}.${arg.descriptionKey}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,7 +91,10 @@ const commandDescriptionKey = (command: BotCommand): string => {
|
|||||||
return `commands.${command.meta.name}.description`;
|
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
|
return commands
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.meta.name.localeCompare(b.meta.name))
|
.sort((a, b) => a.meta.name.localeCompare(b.meta.name))
|
||||||
@@ -95,7 +114,9 @@ const applyOption = (
|
|||||||
): void => {
|
): void => {
|
||||||
const descriptionKey = argDescriptionKey(command, arg);
|
const descriptionKey = argDescriptionKey(command, arg);
|
||||||
const descriptionEn = i18n.t("en", descriptionKey);
|
const descriptionEn = i18n.t("en", descriptionKey);
|
||||||
const descriptionLocalizations = toLocalizationMap(buildDescriptionLocalizationSource(descriptionKey, i18n));
|
const descriptionLocalizations = toLocalizationMap(
|
||||||
|
buildDescriptionLocalizationSource(descriptionKey, i18n),
|
||||||
|
);
|
||||||
|
|
||||||
if (arg.type === "user") {
|
if (arg.type === "user") {
|
||||||
builder.addUserOption((opt) =>
|
builder.addUserOption((opt) =>
|
||||||
@@ -163,24 +184,22 @@ const applyOption = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.addStringOption((opt) =>
|
builder.addStringOption((opt) => {
|
||||||
{
|
const configured = opt
|
||||||
const configured = opt
|
|
||||||
.setName(arg.name)
|
.setName(arg.name)
|
||||||
.setDescription(descriptionEn)
|
.setDescription(descriptionEn)
|
||||||
.setDescriptionLocalizations(descriptionLocalizations)
|
.setDescriptionLocalizations(descriptionLocalizations)
|
||||||
.setRequired(arg.required);
|
.setRequired(arg.required);
|
||||||
|
|
||||||
if (command.meta.name === "help" && arg.name === "command") {
|
if (command.meta.name === "help" && arg.name === "command") {
|
||||||
const choices = buildHelpCommandChoices(allCommands, i18n);
|
const choices = buildHelpCommandChoices(allCommands, i18n);
|
||||||
if (choices.length > 0) {
|
if (choices.length > 0) {
|
||||||
configured.addChoices(...choices);
|
configured.addChoices(...choices);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return configured;
|
return configured;
|
||||||
},
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildSlashPayload = (
|
export const buildSlashPayload = (
|
||||||
@@ -190,8 +209,12 @@ export const buildSlashPayload = (
|
|||||||
return commands.map((command) => {
|
return commands.map((command) => {
|
||||||
const descriptionKey = commandDescriptionKey(command);
|
const descriptionKey = commandDescriptionKey(command);
|
||||||
|
|
||||||
const slashLocalizations = toLocalizationMap(buildCommandNameLocalizationSource(command, i18n));
|
const slashLocalizations = toLocalizationMap(
|
||||||
const descriptionLocalizations = toLocalizationMap(buildDescriptionLocalizationSource(descriptionKey, i18n));
|
buildCommandNameLocalizationSource(command, i18n),
|
||||||
|
);
|
||||||
|
const descriptionLocalizations = toLocalizationMap(
|
||||||
|
buildDescriptionLocalizationSource(descriptionKey, i18n),
|
||||||
|
);
|
||||||
|
|
||||||
const slashBuilder = new SlashCommandBuilder()
|
const slashBuilder = new SlashCommandBuilder()
|
||||||
.setName(command.meta.name)
|
.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) {
|
switch (type) {
|
||||||
case "user":
|
case "user":
|
||||||
return ApplicationCommandOptionType.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 = (
|
export const resolvePrefixTrigger = (
|
||||||
command: BotCommand,
|
command: BotCommand,
|
||||||
@@ -21,7 +26,11 @@ export const resolvePrefixTrigger = (
|
|||||||
return command.meta.name;
|
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);
|
return i18n.commandName(lang, command.meta.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,12 +42,20 @@ export const buildPrefixUsage = (
|
|||||||
i18n: CommandI18nTools,
|
i18n: CommandI18nTools,
|
||||||
): string => {
|
): string => {
|
||||||
const trigger = resolvePrefixTrigger(command, lang, defaultLang, i18n);
|
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}` : ""}`;
|
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 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}` : ""}`;
|
return `/${slashName}${args.length > 0 ? ` ${args}` : ""}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import { PermissionsBitField, type PermissionResolvable } from "discord.js";
|
||||||
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 { AppLogger } from "../logging/logger.js";
|
||||||
import type { CooldownStore } from "./cooldownStore.js";
|
import type { CooldownStore } from "./cooldownStore.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -22,7 +22,10 @@ export interface CommandExecutorDeps {
|
|||||||
export class CommandExecutor {
|
export class CommandExecutor {
|
||||||
public constructor(private readonly deps: CommandExecutorDeps) {}
|
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) {
|
if (this.isSensitiveCommand(command) && !ctx.transport.guild) {
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
ctx.t("errors.permissions.user", {
|
ctx.t("errors.permissions.user", {
|
||||||
@@ -34,20 +37,31 @@ export class CommandExecutor {
|
|||||||
|
|
||||||
const rateLimitRetryAfterSeconds = await this.consumeGlobalRateLimit(ctx);
|
const rateLimitRetryAfterSeconds = await this.consumeGlobalRateLimit(ctx);
|
||||||
if (rateLimitRetryAfterSeconds > 0) {
|
if (rateLimitRetryAfterSeconds > 0) {
|
||||||
await ctx.reply(ctx.t("errors.rateLimit", { seconds: rateLimitRetryAfterSeconds }));
|
await ctx.reply(
|
||||||
|
ctx.t("errors.rateLimit", { seconds: rateLimitRetryAfterSeconds }),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const availablePermissions = await this.resolveMemberPermissions(ctx);
|
const availablePermissions = await this.resolveMemberPermissions(ctx);
|
||||||
const missingUserPermissions = this.getMissingPermissions(command.permissions, availablePermissions);
|
const missingUserPermissions = this.getMissingPermissions(
|
||||||
|
command.permissions,
|
||||||
|
availablePermissions,
|
||||||
|
);
|
||||||
if (missingUserPermissions.length > 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingCooldownSeconds = await this.consumeCooldown(command, ctx);
|
const remainingCooldownSeconds = await this.consumeCooldown(command, ctx);
|
||||||
if (remainingCooldownSeconds > 0) {
|
if (remainingCooldownSeconds > 0) {
|
||||||
await ctx.reply(ctx.t("errors.cooldown", { seconds: remainingCooldownSeconds }));
|
await ctx.reply(
|
||||||
|
ctx.t("errors.cooldown", { seconds: remainingCooldownSeconds }),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +110,11 @@ export class CommandExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!available) {
|
if (!available) {
|
||||||
return [...new Set(required.flatMap((permission) => this.permissionToLabels(permission)))];
|
return [
|
||||||
|
...new Set(
|
||||||
|
required.flatMap((permission) => this.permissionToLabels(permission)),
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -111,7 +129,9 @@ export class CommandExecutor {
|
|||||||
private permissionToLabels(permission: PermissionResolvable): string[] {
|
private permissionToLabels(permission: PermissionResolvable): string[] {
|
||||||
try {
|
try {
|
||||||
const resolved = PermissionsBitField.resolve(permission);
|
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) {
|
if (labels.length > 0) {
|
||||||
return labels;
|
return labels;
|
||||||
}
|
}
|
||||||
@@ -129,7 +149,10 @@ export class CommandExecutor {
|
|||||||
.trim();
|
.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) {
|
if (command.cooldown === undefined || command.cooldown <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -142,7 +165,12 @@ export class CommandExecutor {
|
|||||||
const userId = ctx.execution.actor.userId;
|
const userId = ctx.execution.actor.userId;
|
||||||
|
|
||||||
try {
|
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;
|
return result.allowed ? 0 : result.retryAfterSeconds;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.deps.rateLimitFailOpen) {
|
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);
|
const botId = this.resolveBotId(ctx);
|
||||||
if (!botId) {
|
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 {
|
try {
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ export interface CooldownConsumeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CooldownStore {
|
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 {
|
export class MemoryCooldownStore implements CooldownStore {
|
||||||
@@ -52,7 +57,8 @@ export class MemoryCooldownStore implements CooldownStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldSweepBySize = this.cooldowns.size >= COOLDOWN_SWEEP_MIN_ENTRIES;
|
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) {
|
if (!shouldSweepBySize && !shouldSweepByTime) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ export interface CommandJobPublisher {
|
|||||||
export class LocalCommandDispatchPort implements CommandDispatchPort {
|
export class LocalCommandDispatchPort implements CommandDispatchPort {
|
||||||
public constructor(private readonly executor: CommandExecutor) {}
|
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);
|
await this.executor.run(command, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +50,10 @@ export class WorkerCommandDispatchPort implements CommandDispatchPort {
|
|||||||
private readonly logger: AppLogger,
|
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);
|
const job = toExecutionJob(command, ctx);
|
||||||
|
|
||||||
await this.publisher.publish(job);
|
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;
|
const botId = ctx.transport.client.user?.id;
|
||||||
if (!botId) {
|
if (!botId) {
|
||||||
throw new Error("runtime bot id unavailable for worker command dispatch");
|
throw new Error("runtime bot id unavailable for worker command dispatch");
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializedArgs = Object.fromEntries(
|
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 {
|
return {
|
||||||
@@ -96,11 +108,19 @@ const serializeArgValue = (value: CommandArgValue): unknown => {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
if (
|
||||||
|
typeof value === "string" ||
|
||||||
|
typeof value === "number" ||
|
||||||
|
typeof value === "boolean"
|
||||||
|
) {
|
||||||
return value;
|
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 {
|
return {
|
||||||
id: value.id,
|
id: value.id,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ export interface GlobalRateLimitDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GlobalRateLimitStore {
|
export interface GlobalRateLimitStore {
|
||||||
consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision>;
|
consume(
|
||||||
|
botId: string,
|
||||||
|
userId: string,
|
||||||
|
policy: GlobalRateLimitPolicy,
|
||||||
|
): Promise<GlobalRateLimitDecision>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WindowCounterEntry {
|
interface WindowCounterEntry {
|
||||||
@@ -24,7 +28,11 @@ interface WindowCounterEntry {
|
|||||||
export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore {
|
export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore {
|
||||||
private readonly counters = new Map<string, WindowCounterEntry>();
|
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 sanitized = sanitizePolicy(policy);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const key = this.key(botId, userId, sanitized.windowSeconds);
|
const key = this.key(botId, userId, sanitized.windowSeconds);
|
||||||
@@ -47,7 +55,10 @@ export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore {
|
|||||||
|
|
||||||
existing.count += 1;
|
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;
|
const allowed = existing.count <= sanitized.limit;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -92,7 +103,11 @@ export class RedisGlobalRateLimitStore implements GlobalRateLimitStore {
|
|||||||
private readonly keyPrefix = "bot",
|
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 sanitized = sanitizePolicy(policy);
|
||||||
const key = this.key(botId, userId);
|
const key = this.key(botId, userId);
|
||||||
|
|
||||||
@@ -102,7 +117,10 @@ export class RedisGlobalRateLimitStore implements GlobalRateLimitStore {
|
|||||||
key,
|
key,
|
||||||
String(sanitized.windowSeconds),
|
String(sanitized.windowSeconds),
|
||||||
);
|
);
|
||||||
const { count, retryAfterSeconds } = parseScriptResult(rawResult, sanitized.windowSeconds);
|
const { count, retryAfterSeconds } = parseScriptResult(
|
||||||
|
rawResult,
|
||||||
|
sanitized.windowSeconds,
|
||||||
|
);
|
||||||
const allowed = count <= sanitized.limit;
|
const allowed = count <= sanitized.limit;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -118,11 +136,17 @@ export class RedisGlobalRateLimitStore implements GlobalRateLimitStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizePolicy = (policy: GlobalRateLimitPolicy): GlobalRateLimitPolicy => {
|
const sanitizePolicy = (
|
||||||
const limit = Number.isFinite(policy.limit) && policy.limit > 0 ? Math.floor(policy.limit) : 1;
|
policy: GlobalRateLimitPolicy,
|
||||||
const windowSeconds = Number.isFinite(policy.windowSeconds) && policy.windowSeconds > 0
|
): GlobalRateLimitPolicy => {
|
||||||
? Math.floor(policy.windowSeconds)
|
const limit =
|
||||||
: 1;
|
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 {
|
return {
|
||||||
limit,
|
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) {
|
if (!Array.isArray(rawResult) || rawResult.length < 2) {
|
||||||
throw new Error("Redis rate limit script returned an unexpected payload");
|
throw new Error("Redis rate limit script returned an unexpected payload");
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = toPositiveInt(rawResult[0]);
|
const count = toPositiveInt(rawResult[0]);
|
||||||
if (!count) {
|
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;
|
const ttl = toPositiveInt(rawResult[1]) ?? fallbackWindowSeconds;
|
||||||
@@ -149,11 +178,12 @@ const parseScriptResult = (rawResult: unknown, fallbackWindowSeconds: number): {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toPositiveInt = (value: unknown): number | null => {
|
const toPositiveInt = (value: unknown): number | null => {
|
||||||
const numeric = typeof value === "number"
|
const numeric =
|
||||||
? value
|
typeof value === "number"
|
||||||
: typeof value === "string"
|
? value
|
||||||
? Number(value)
|
: typeof value === "string"
|
||||||
: Number.NaN;
|
? Number(value)
|
||||||
|
: Number.NaN;
|
||||||
|
|
||||||
if (!Number.isFinite(numeric)) {
|
if (!Number.isFinite(numeric)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ export class RedisCommandJobPublisher implements CommandJobPublisher {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async publish(job: CommandExecutionJob): Promise<void> {
|
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 {
|
private resolveQueueName(botId: string): string {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import type { Pool } from "pg";
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
export interface LeaderCoordinator {
|
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 {
|
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();
|
await task();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -17,8 +25,14 @@ export class PostgresLeaderCoordinator implements LeaderCoordinator {
|
|||||||
private readonly namespace = "discord-bot",
|
private readonly namespace = "discord-bot",
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async runIfLeader(lockName: string, botId: string, task: () => Promise<void>): Promise<boolean> {
|
public async runIfLeader(
|
||||||
const advisoryLockKey = hashToInt32(`${this.namespace}:bot:${botId}:leader:${lockName}`);
|
lockName: string,
|
||||||
|
botId: string,
|
||||||
|
task: () => Promise<void>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const advisoryLockKey = hashToInt32(
|
||||||
|
`${this.namespace}:bot:${botId}:leader:${lockName}`,
|
||||||
|
);
|
||||||
const client = await this.pool.connect();
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -28,13 +28,16 @@ export class PostgresLogEventStore implements LogEventRepository {
|
|||||||
await this.pool.query(logEventSchemaProbeSql);
|
await this.pool.query(logEventSchemaProbeSql);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new 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 },
|
{ 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>(
|
const result = await this.pool.query<LogEventRow>(
|
||||||
`
|
`
|
||||||
SELECT event_key, enabled, channel_id
|
SELECT event_key, enabled, channel_id
|
||||||
@@ -107,7 +110,13 @@ export class PostgresLogEventStore implements LogEventRepository {
|
|||||||
channel_id = EXCLUDED.channel_id,
|
channel_id = EXCLUDED.channel_id,
|
||||||
updated_at = NOW()
|
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 [];
|
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);
|
return sanitizeMemberMessageRoleIds(roleIds);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
@@ -50,7 +52,9 @@ const toConfig = (row: MemberMessageRow): MemberMessageConfig => {
|
|||||||
return {
|
return {
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
channelId: row.channel_id,
|
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),
|
autoRoleIds: parseRoleIds(row.auto_role_ids),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -63,13 +67,17 @@ export class PostgresMemberMessageStore implements MemberMessageRepository {
|
|||||||
await this.pool.query(memberMessageSchemaProbeSql);
|
await this.pool.query(memberMessageSchemaProbeSql);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new 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 },
|
{ 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>(
|
const result = await this.pool.query<MemberMessageRow>(
|
||||||
`
|
`
|
||||||
SELECT enabled, channel_id, message_type, auto_role_ids
|
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,
|
auto_role_ids = EXCLUDED.auto_role_ids,
|
||||||
updated_at = NOW()
|
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;
|
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) {
|
if (typeof rawTexts === "string" && rawTexts.trim().length > 0) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(rawTexts) as unknown;
|
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 => {
|
const toPresenceState = (row: PresenceRow): PresenceState | null => {
|
||||||
if (!isPresenceStatusValue(row.status) || !isPresenceActivityTypeValue(row.activity_type)) {
|
if (
|
||||||
|
!isPresenceStatusValue(row.status) ||
|
||||||
|
!isPresenceActivityTypeValue(row.activity_type)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +87,7 @@ export class PostgresPresenceStore implements PresenceRepository {
|
|||||||
await this.pool.query(presenceSchemaProbeSql);
|
await this.pool.query(presenceSchemaProbeSql);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new 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 },
|
{ cause: error },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,10 +107,16 @@ export class PostgresPresenceStore implements PresenceRepository {
|
|||||||
return toPresenceState(row) ?? createDefaultPresenceState();
|
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 activityTexts = sanitizeActivityTexts(state.activity.texts);
|
||||||
const primaryText = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
|
const primaryText =
|
||||||
const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
|
activityTexts[0] ?? sanitizeActivityText(state.activity.text);
|
||||||
|
const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(
|
||||||
|
state.activity.rotationIntervalSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
await this.pool.query(
|
await this.pool.query(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export const registerGuildDelete = (
|
|||||||
memberMessageService.cleanupGuild(botId, guild.id),
|
memberMessageService.cleanupGuild(botId, guild.id),
|
||||||
logEventService.cleanupGuild(botId, guild.id),
|
logEventService.cleanupGuild(botId, guild.id),
|
||||||
]).catch((error) => {
|
]).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,
|
memberMessageService: MemberMessageService,
|
||||||
): void => {
|
): void => {
|
||||||
client.on(Events.GuildMemberAdd, (member) => {
|
client.on(Events.GuildMemberAdd, (member) => {
|
||||||
void memberMessageService.assignWelcomeAutoRoles({ client, member }).then((result) => {
|
void memberMessageService
|
||||||
if (result.assigned) {
|
.assignWelcomeAutoRoles({ client, member })
|
||||||
return;
|
.then((result) => {
|
||||||
}
|
if (result.assigned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.reason === "no_roles_configured" || result.reason === "no_assignable_roles") {
|
if (
|
||||||
return;
|
result.reason === "no_roles_configured" ||
|
||||||
}
|
result.reason === "no_assignable_roles"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
log.warn(
|
log.warn(
|
||||||
{
|
{
|
||||||
guildId: member.guild.id,
|
guildId: member.guild.id,
|
||||||
userId: member.user.id,
|
userId: member.user.id,
|
||||||
reason: result.reason,
|
reason: result.reason,
|
||||||
configuredRoleIds: result.configuredRoleIds,
|
configuredRoleIds: result.configuredRoleIds,
|
||||||
},
|
},
|
||||||
"failed to assign welcome auto roles",
|
"failed to assign welcome auto roles",
|
||||||
);
|
);
|
||||||
}).catch((error) => {
|
})
|
||||||
log.error(
|
.catch((error) => {
|
||||||
{
|
log.error(
|
||||||
guildId: member.guild.id,
|
{
|
||||||
userId: member.user.id,
|
guildId: member.guild.id,
|
||||||
err: error,
|
userId: member.user.id,
|
||||||
},
|
err: error,
|
||||||
"welcome auto-role dispatch crashed",
|
},
|
||||||
);
|
"welcome auto-role dispatch crashed",
|
||||||
});
|
);
|
||||||
|
});
|
||||||
|
|
||||||
void memberMessageService.dispatch({
|
void memberMessageService
|
||||||
client,
|
.dispatch({
|
||||||
i18n,
|
client,
|
||||||
guild: member.guild,
|
i18n,
|
||||||
user: member.user,
|
guild: member.guild,
|
||||||
kind: "welcome",
|
user: member.user,
|
||||||
}).catch((error) => {
|
kind: "welcome",
|
||||||
log.error(
|
})
|
||||||
{
|
.catch((error) => {
|
||||||
guildId: member.guild.id,
|
log.error(
|
||||||
userId: member.user.id,
|
{
|
||||||
err: error,
|
guildId: member.guild.id,
|
||||||
},
|
userId: member.user.id,
|
||||||
"failed to send welcome message",
|
err: error,
|
||||||
);
|
},
|
||||||
});
|
"failed to send welcome message",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,21 +12,23 @@ export const registerGuildMemberRemove = (
|
|||||||
memberMessageService: MemberMessageService,
|
memberMessageService: MemberMessageService,
|
||||||
): void => {
|
): void => {
|
||||||
client.on(Events.GuildMemberRemove, (member) => {
|
client.on(Events.GuildMemberRemove, (member) => {
|
||||||
void memberMessageService.dispatch({
|
void memberMessageService
|
||||||
client,
|
.dispatch({
|
||||||
i18n,
|
client,
|
||||||
guild: member.guild,
|
i18n,
|
||||||
user: member.user,
|
guild: member.guild,
|
||||||
kind: "goodbye",
|
user: member.user,
|
||||||
}).catch((error) => {
|
kind: "goodbye",
|
||||||
log.error(
|
})
|
||||||
{
|
.catch((error) => {
|
||||||
guildId: member.guild.id,
|
log.error(
|
||||||
userId: member.user.id,
|
{
|
||||||
err: error,
|
guildId: member.guild.id,
|
||||||
},
|
userId: member.user.id,
|
||||||
"failed to send goodbye message",
|
err: error,
|
||||||
);
|
},
|
||||||
});
|
"failed to send goodbye message",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,18 @@ export const registerEvents = (
|
|||||||
registerGuildMemberRemove(client, i18n, services.memberMessageService);
|
registerGuildMemberRemove(client, i18n, services.memberMessageService);
|
||||||
|
|
||||||
registerGuildCreate(client);
|
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";
|
import { createScopedLogger } from "../core/logging/logger.js";
|
||||||
|
|
||||||
const log = createScopedLogger("event:interactionCreate");
|
const log = createScopedLogger("event:interactionCreate");
|
||||||
@@ -11,7 +15,9 @@ const log = createScopedLogger("event:interactionCreate");
|
|||||||
*/
|
*/
|
||||||
export const registerInteractionCreate = (
|
export const registerInteractionCreate = (
|
||||||
client: Client,
|
client: Client,
|
||||||
onSlashInteraction: (interaction: ChatInputCommandInteraction) => Promise<void>,
|
onSlashInteraction: (
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
) => Promise<void>,
|
||||||
): void => {
|
): void => {
|
||||||
client.on(Events.InteractionCreate, (interaction) => {
|
client.on(Events.InteractionCreate, (interaction) => {
|
||||||
if (!interaction.isChatInputCommand()) {
|
if (!interaction.isChatInputCommand()) {
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { Events, type Client, type Guild, type InviteGuild } from "discord.js";
|
||||||
Events,
|
|
||||||
type Client,
|
|
||||||
type Guild,
|
|
||||||
type InviteGuild,
|
|
||||||
} from "discord.js";
|
|
||||||
|
|
||||||
import { createScopedLogger } from "../core/logging/logger.js";
|
import { createScopedLogger } from "../core/logging/logger.js";
|
||||||
import type { I18nService } from "../i18n/index.js";
|
import type { I18nService } from "../i18n/index.js";
|
||||||
@@ -26,7 +21,10 @@ const tForGuild = (
|
|||||||
key: string,
|
key: string,
|
||||||
vars: Record<string, string | number> = {},
|
vars: Record<string, string | number> = {},
|
||||||
): string => {
|
): 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);
|
const lang = i18n.resolveLang(preferredLocale);
|
||||||
return i18n.t(lang, key, vars);
|
return i18n.t(lang, key, vars);
|
||||||
};
|
};
|
||||||
@@ -45,7 +43,11 @@ const formatContent = (
|
|||||||
guild: Guild | InviteGuild | null | undefined,
|
guild: Guild | InviteGuild | null | undefined,
|
||||||
content: string | null | undefined,
|
content: string | null | undefined,
|
||||||
): string => {
|
): string => {
|
||||||
const noContent = tForGuild(i18n, guild, "logsRuntime.placeholders.noContent");
|
const noContent = tForGuild(
|
||||||
|
i18n,
|
||||||
|
guild,
|
||||||
|
"logsRuntime.placeholders.noContent",
|
||||||
|
);
|
||||||
|
|
||||||
if (typeof content !== "string") {
|
if (typeof content !== "string") {
|
||||||
return noContent;
|
return noContent;
|
||||||
@@ -82,19 +84,30 @@ const emitRuntimeLog = (
|
|||||||
const title = tForGuild(i18n, input.guild, "logsRuntime.embed.title", {
|
const title = tForGuild(i18n, input.guild, "logsRuntime.embed.title", {
|
||||||
event: input.eventKey,
|
event: input.eventKey,
|
||||||
});
|
});
|
||||||
const detailsTitle = tForGuild(i18n, input.guild, "logsRuntime.embed.detailsField");
|
const detailsTitle = tForGuild(
|
||||||
|
i18n,
|
||||||
|
input.guild,
|
||||||
|
"logsRuntime.embed.detailsField",
|
||||||
|
);
|
||||||
|
|
||||||
void logEventService.dispatchEvent(client, {
|
void logEventService
|
||||||
eventKey: input.eventKey,
|
.dispatchEvent(client, {
|
||||||
guildId,
|
eventKey: input.eventKey,
|
||||||
summary: input.summary,
|
guildId,
|
||||||
...(input.details && input.details.length > 0 ? { details: input.details } : {}),
|
summary: input.summary,
|
||||||
...(input.color !== undefined ? { color: input.color } : {}),
|
...(input.details && input.details.length > 0
|
||||||
title,
|
? { details: input.details }
|
||||||
detailsTitle,
|
: {}),
|
||||||
}).catch((error) => {
|
...(input.color !== undefined ? { color: input.color } : {}),
|
||||||
logger.error({ eventKey: input.eventKey, guildId, err: error }, "failed to dispatch runtime log event");
|
title,
|
||||||
});
|
detailsTitle,
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
{ eventKey: input.eventKey, guildId, err: error },
|
||||||
|
"failed to dispatch runtime log event",
|
||||||
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const registerLogRuntimeEvents = (
|
export const registerLogRuntimeEvents = (
|
||||||
@@ -110,10 +123,15 @@ export const registerLogRuntimeEvents = (
|
|||||||
emitRuntimeLog(client, logEventService, i18n, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "messageCreate",
|
eventKey: "messageCreate",
|
||||||
guild: message.guild,
|
guild: message.guild,
|
||||||
summary: tForGuild(i18n, message.guild, "logsRuntime.summaries.messageCreate", {
|
summary: tForGuild(
|
||||||
user: `<@${message.author.id}>`,
|
i18n,
|
||||||
channel: `<#${message.channelId}>`,
|
message.guild,
|
||||||
}),
|
"logsRuntime.summaries.messageCreate",
|
||||||
|
{
|
||||||
|
user: `<@${message.author.id}>`,
|
||||||
|
channel: `<#${message.channelId}>`,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, message.guild, "messageId", { value: message.id }),
|
detailLine(i18n, message.guild, "messageId", { value: message.id }),
|
||||||
detailLine(i18n, message.guild, "author", {
|
detailLine(i18n, message.guild, "author", {
|
||||||
@@ -187,9 +205,14 @@ export const registerLogRuntimeEvents = (
|
|||||||
eventKey: "messageBulkDelete",
|
eventKey: "messageBulkDelete",
|
||||||
guild,
|
guild,
|
||||||
guildId: guild?.id,
|
guildId: guild?.id,
|
||||||
summary: tForGuild(i18n, guild, "logsRuntime.summaries.messageBulkDelete", {
|
summary: tForGuild(
|
||||||
count: messages.size,
|
i18n,
|
||||||
}),
|
guild,
|
||||||
|
"logsRuntime.summaries.messageBulkDelete",
|
||||||
|
{
|
||||||
|
count: messages.size,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, guild, "channelId", {
|
detailLine(i18n, guild, "channelId", {
|
||||||
value: first?.channelId ?? unknown,
|
value: first?.channelId ?? unknown,
|
||||||
@@ -204,9 +227,14 @@ export const registerLogRuntimeEvents = (
|
|||||||
emitRuntimeLog(client, logEventService, i18n, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "guildMemberAdd",
|
eventKey: "guildMemberAdd",
|
||||||
guild: member.guild,
|
guild: member.guild,
|
||||||
summary: tForGuild(i18n, member.guild, "logsRuntime.summaries.guildMemberAdd", {
|
summary: tForGuild(
|
||||||
user: `<@${member.user.id}>`,
|
i18n,
|
||||||
}),
|
member.guild,
|
||||||
|
"logsRuntime.summaries.guildMemberAdd",
|
||||||
|
{
|
||||||
|
user: `<@${member.user.id}>`,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, member.guild, "user", {
|
detailLine(i18n, member.guild, "user", {
|
||||||
tag: member.user.tag,
|
tag: member.user.tag,
|
||||||
@@ -221,9 +249,14 @@ export const registerLogRuntimeEvents = (
|
|||||||
emitRuntimeLog(client, logEventService, i18n, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "guildMemberRemove",
|
eventKey: "guildMemberRemove",
|
||||||
guild: member.guild,
|
guild: member.guild,
|
||||||
summary: tForGuild(i18n, member.guild, "logsRuntime.summaries.guildMemberRemove", {
|
summary: tForGuild(
|
||||||
user: `<@${member.user.id}>`,
|
i18n,
|
||||||
}),
|
member.guild,
|
||||||
|
"logsRuntime.summaries.guildMemberRemove",
|
||||||
|
{
|
||||||
|
user: `<@${member.user.id}>`,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, member.guild, "user", {
|
detailLine(i18n, member.guild, "user", {
|
||||||
tag: member.user.tag,
|
tag: member.user.tag,
|
||||||
@@ -235,14 +268,23 @@ export const registerLogRuntimeEvents = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.GuildMemberUpdate, (oldMember, newMember) => {
|
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, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "guildMemberUpdate",
|
eventKey: "guildMemberUpdate",
|
||||||
guild: newMember.guild,
|
guild: newMember.guild,
|
||||||
summary: tForGuild(i18n, newMember.guild, "logsRuntime.summaries.guildMemberUpdate", {
|
summary: tForGuild(
|
||||||
user: `<@${newMember.user.id}>`,
|
i18n,
|
||||||
}),
|
newMember.guild,
|
||||||
|
"logsRuntime.summaries.guildMemberUpdate",
|
||||||
|
{
|
||||||
|
user: `<@${newMember.user.id}>`,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, newMember.guild, "user", {
|
detailLine(i18n, newMember.guild, "user", {
|
||||||
tag: newMember.user.tag,
|
tag: newMember.user.tag,
|
||||||
@@ -267,27 +309,52 @@ export const registerLogRuntimeEvents = (
|
|||||||
const guild = interaction.guild;
|
const guild = interaction.guild;
|
||||||
const unknown = tForGuild(i18n, guild, "logsRuntime.placeholders.unknown");
|
const unknown = tForGuild(i18n, guild, "logsRuntime.placeholders.unknown");
|
||||||
|
|
||||||
let summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionGeneric", {
|
let summary = tForGuild(
|
||||||
user: `<@${interaction.user.id}>`,
|
i18n,
|
||||||
});
|
guild,
|
||||||
|
"logsRuntime.summaries.interactionGeneric",
|
||||||
|
{
|
||||||
|
user: `<@${interaction.user.id}>`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (interaction.isChatInputCommand()) {
|
if (interaction.isChatInputCommand()) {
|
||||||
summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionSlash", {
|
summary = tForGuild(
|
||||||
command: interaction.commandName,
|
i18n,
|
||||||
user: `<@${interaction.user.id}>`,
|
guild,
|
||||||
});
|
"logsRuntime.summaries.interactionSlash",
|
||||||
|
{
|
||||||
|
command: interaction.commandName,
|
||||||
|
user: `<@${interaction.user.id}>`,
|
||||||
|
},
|
||||||
|
);
|
||||||
} else if (interaction.isButton()) {
|
} else if (interaction.isButton()) {
|
||||||
summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionButton", {
|
summary = tForGuild(
|
||||||
user: `<@${interaction.user.id}>`,
|
i18n,
|
||||||
});
|
guild,
|
||||||
|
"logsRuntime.summaries.interactionButton",
|
||||||
|
{
|
||||||
|
user: `<@${interaction.user.id}>`,
|
||||||
|
},
|
||||||
|
);
|
||||||
} else if (interaction.isStringSelectMenu()) {
|
} else if (interaction.isStringSelectMenu()) {
|
||||||
summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionSelect", {
|
summary = tForGuild(
|
||||||
user: `<@${interaction.user.id}>`,
|
i18n,
|
||||||
});
|
guild,
|
||||||
|
"logsRuntime.summaries.interactionSelect",
|
||||||
|
{
|
||||||
|
user: `<@${interaction.user.id}>`,
|
||||||
|
},
|
||||||
|
);
|
||||||
} else if (interaction.isModalSubmit()) {
|
} else if (interaction.isModalSubmit()) {
|
||||||
summary = tForGuild(i18n, guild, "logsRuntime.summaries.interactionModal", {
|
summary = tForGuild(
|
||||||
user: `<@${interaction.user.id}>`,
|
i18n,
|
||||||
});
|
guild,
|
||||||
|
"logsRuntime.summaries.interactionModal",
|
||||||
|
{
|
||||||
|
user: `<@${interaction.user.id}>`,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitRuntimeLog(client, logEventService, i18n, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
@@ -313,9 +380,14 @@ export const registerLogRuntimeEvents = (
|
|||||||
emitRuntimeLog(client, logEventService, i18n, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "channelCreate",
|
eventKey: "channelCreate",
|
||||||
guild: channel.guild,
|
guild: channel.guild,
|
||||||
summary: tForGuild(i18n, channel.guild, "logsRuntime.summaries.channelCreate", {
|
summary: tForGuild(
|
||||||
channel: `#${channel.name}`,
|
i18n,
|
||||||
}),
|
channel.guild,
|
||||||
|
"logsRuntime.summaries.channelCreate",
|
||||||
|
{
|
||||||
|
channel: `#${channel.name}`,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, channel.guild, "channelId", { value: channel.id }),
|
detailLine(i18n, channel.guild, "channelId", { value: channel.id }),
|
||||||
detailLine(i18n, channel.guild, "type", { value: channel.type }),
|
detailLine(i18n, channel.guild, "type", { value: channel.type }),
|
||||||
@@ -332,9 +404,14 @@ export const registerLogRuntimeEvents = (
|
|||||||
emitRuntimeLog(client, logEventService, i18n, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "channelDelete",
|
eventKey: "channelDelete",
|
||||||
guild: channel.guild,
|
guild: channel.guild,
|
||||||
summary: tForGuild(i18n, channel.guild, "logsRuntime.summaries.channelDelete", {
|
summary: tForGuild(
|
||||||
channel: `#${channel.name}`,
|
i18n,
|
||||||
}),
|
channel.guild,
|
||||||
|
"logsRuntime.summaries.channelDelete",
|
||||||
|
{
|
||||||
|
channel: `#${channel.name}`,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, channel.guild, "channelId", { value: channel.id }),
|
detailLine(i18n, channel.guild, "channelId", { value: channel.id }),
|
||||||
detailLine(i18n, channel.guild, "type", { value: channel.type }),
|
detailLine(i18n, channel.guild, "type", { value: channel.type }),
|
||||||
@@ -348,19 +425,32 @@ export const registerLogRuntimeEvents = (
|
|||||||
return;
|
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;
|
const oldName = "name" in oldChannel ? oldChannel.name : unknown;
|
||||||
|
|
||||||
emitRuntimeLog(client, logEventService, i18n, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "channelUpdate",
|
eventKey: "channelUpdate",
|
||||||
guild: newChannel.guild,
|
guild: newChannel.guild,
|
||||||
summary: tForGuild(i18n, newChannel.guild, "logsRuntime.summaries.channelUpdate", {
|
summary: tForGuild(
|
||||||
channel: `#${newChannel.name}`,
|
i18n,
|
||||||
}),
|
newChannel.guild,
|
||||||
|
"logsRuntime.summaries.channelUpdate",
|
||||||
|
{
|
||||||
|
channel: `#${newChannel.name}`,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
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, "oldName", { value: oldName }),
|
||||||
detailLine(i18n, newChannel.guild, "newName", { value: newChannel.name }),
|
detailLine(i18n, newChannel.guild, "newName", {
|
||||||
|
value: newChannel.name,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
color: 0xfee75c,
|
color: 0xfee75c,
|
||||||
});
|
});
|
||||||
@@ -394,9 +484,14 @@ export const registerLogRuntimeEvents = (
|
|||||||
emitRuntimeLog(client, logEventService, i18n, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "roleUpdate",
|
eventKey: "roleUpdate",
|
||||||
guild: newRole.guild,
|
guild: newRole.guild,
|
||||||
summary: tForGuild(i18n, newRole.guild, "logsRuntime.summaries.roleUpdate", {
|
summary: tForGuild(
|
||||||
role: `@${newRole.name}`,
|
i18n,
|
||||||
}),
|
newRole.guild,
|
||||||
|
"logsRuntime.summaries.roleUpdate",
|
||||||
|
{
|
||||||
|
role: `@${newRole.name}`,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, newRole.guild, "roleId", { value: newRole.id }),
|
detailLine(i18n, newRole.guild, "roleId", { value: newRole.id }),
|
||||||
detailLine(i18n, newRole.guild, "oldName", { value: oldRole.name }),
|
detailLine(i18n, newRole.guild, "oldName", { value: oldRole.name }),
|
||||||
@@ -411,10 +506,19 @@ export const registerLogRuntimeEvents = (
|
|||||||
eventKey: "threadCreate",
|
eventKey: "threadCreate",
|
||||||
guild: thread.guild ?? null,
|
guild: thread.guild ?? null,
|
||||||
guildId: thread.guild?.id,
|
guildId: thread.guild?.id,
|
||||||
summary: tForGuild(i18n, thread.guild ?? null, "logsRuntime.summaries.threadCreate", {
|
summary: tForGuild(
|
||||||
thread: thread.name,
|
i18n,
|
||||||
}),
|
thread.guild ?? null,
|
||||||
details: [detailLine(i18n, thread.guild ?? null, "threadId", { value: thread.id })],
|
"logsRuntime.summaries.threadCreate",
|
||||||
|
{
|
||||||
|
thread: thread.name,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
details: [
|
||||||
|
detailLine(i18n, thread.guild ?? null, "threadId", {
|
||||||
|
value: thread.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
color: 0x57f287,
|
color: 0x57f287,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -424,10 +528,19 @@ export const registerLogRuntimeEvents = (
|
|||||||
eventKey: "threadDelete",
|
eventKey: "threadDelete",
|
||||||
guild: thread.guild ?? null,
|
guild: thread.guild ?? null,
|
||||||
guildId: thread.guild?.id,
|
guildId: thread.guild?.id,
|
||||||
summary: tForGuild(i18n, thread.guild ?? null, "logsRuntime.summaries.threadDelete", {
|
summary: tForGuild(
|
||||||
thread: thread.name,
|
i18n,
|
||||||
}),
|
thread.guild ?? null,
|
||||||
details: [detailLine(i18n, thread.guild ?? null, "threadId", { value: thread.id })],
|
"logsRuntime.summaries.threadDelete",
|
||||||
|
{
|
||||||
|
thread: thread.name,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
details: [
|
||||||
|
detailLine(i18n, thread.guild ?? null, "threadId", {
|
||||||
|
value: thread.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
color: 0xed4245,
|
color: 0xed4245,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -437,28 +550,48 @@ export const registerLogRuntimeEvents = (
|
|||||||
eventKey: "threadUpdate",
|
eventKey: "threadUpdate",
|
||||||
guild: newThread.guild ?? null,
|
guild: newThread.guild ?? null,
|
||||||
guildId: newThread.guild?.id,
|
guildId: newThread.guild?.id,
|
||||||
summary: tForGuild(i18n, newThread.guild ?? null, "logsRuntime.summaries.threadUpdate", {
|
summary: tForGuild(
|
||||||
thread: newThread.name,
|
i18n,
|
||||||
}),
|
newThread.guild ?? null,
|
||||||
|
"logsRuntime.summaries.threadUpdate",
|
||||||
|
{
|
||||||
|
thread: newThread.name,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, newThread.guild ?? null, "threadId", { value: newThread.id }),
|
detailLine(i18n, newThread.guild ?? null, "threadId", {
|
||||||
detailLine(i18n, newThread.guild ?? null, "oldName", { value: oldThread.name }),
|
value: newThread.id,
|
||||||
detailLine(i18n, newThread.guild ?? null, "newName", { value: newThread.name }),
|
}),
|
||||||
|
detailLine(i18n, newThread.guild ?? null, "oldName", {
|
||||||
|
value: oldThread.name,
|
||||||
|
}),
|
||||||
|
detailLine(i18n, newThread.guild ?? null, "newName", {
|
||||||
|
value: newThread.name,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
color: 0xfee75c,
|
color: 0xfee75c,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.GuildEmojiCreate, (emoji) => {
|
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, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "emojiCreate",
|
eventKey: "emojiCreate",
|
||||||
guild: emoji.guild ?? null,
|
guild: emoji.guild ?? null,
|
||||||
guildId: emoji.guild?.id,
|
guildId: emoji.guild?.id,
|
||||||
summary: tForGuild(i18n, emoji.guild ?? null, "logsRuntime.summaries.emojiCreate", {
|
summary: tForGuild(
|
||||||
emoji: emoji.name ?? unknown,
|
i18n,
|
||||||
}),
|
emoji.guild ?? null,
|
||||||
|
"logsRuntime.summaries.emojiCreate",
|
||||||
|
{
|
||||||
|
emoji: emoji.name ?? unknown,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, emoji.guild ?? null, "emojiId", {
|
detailLine(i18n, emoji.guild ?? null, "emojiId", {
|
||||||
value: emoji.id ?? unknown,
|
value: emoji.id ?? unknown,
|
||||||
@@ -469,15 +602,24 @@ export const registerLogRuntimeEvents = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.GuildEmojiDelete, (emoji) => {
|
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, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "emojiDelete",
|
eventKey: "emojiDelete",
|
||||||
guild: emoji.guild ?? null,
|
guild: emoji.guild ?? null,
|
||||||
guildId: emoji.guild?.id,
|
guildId: emoji.guild?.id,
|
||||||
summary: tForGuild(i18n, emoji.guild ?? null, "logsRuntime.summaries.emojiDelete", {
|
summary: tForGuild(
|
||||||
emoji: emoji.name ?? unknown,
|
i18n,
|
||||||
}),
|
emoji.guild ?? null,
|
||||||
|
"logsRuntime.summaries.emojiDelete",
|
||||||
|
{
|
||||||
|
emoji: emoji.name ?? unknown,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, emoji.guild ?? null, "emojiId", {
|
detailLine(i18n, emoji.guild ?? null, "emojiId", {
|
||||||
value: emoji.id ?? unknown,
|
value: emoji.id ?? unknown,
|
||||||
@@ -488,15 +630,24 @@ export const registerLogRuntimeEvents = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.GuildEmojiUpdate, (oldEmoji, newEmoji) => {
|
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, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "emojiUpdate",
|
eventKey: "emojiUpdate",
|
||||||
guild: newEmoji.guild ?? null,
|
guild: newEmoji.guild ?? null,
|
||||||
guildId: newEmoji.guild?.id,
|
guildId: newEmoji.guild?.id,
|
||||||
summary: tForGuild(i18n, newEmoji.guild ?? null, "logsRuntime.summaries.emojiUpdate", {
|
summary: tForGuild(
|
||||||
emoji: newEmoji.name ?? unknown,
|
i18n,
|
||||||
}),
|
newEmoji.guild ?? null,
|
||||||
|
"logsRuntime.summaries.emojiUpdate",
|
||||||
|
{
|
||||||
|
emoji: newEmoji.name ?? unknown,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, newEmoji.guild ?? null, "emojiId", {
|
detailLine(i18n, newEmoji.guild ?? null, "emojiId", {
|
||||||
value: newEmoji.id ?? unknown,
|
value: newEmoji.id ?? unknown,
|
||||||
@@ -532,9 +683,14 @@ export const registerLogRuntimeEvents = (
|
|||||||
emitRuntimeLog(client, logEventService, i18n, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "guildUnavailable",
|
eventKey: "guildUnavailable",
|
||||||
guild,
|
guild,
|
||||||
summary: tForGuild(i18n, guild, "logsRuntime.summaries.guildUnavailable", {
|
summary: tForGuild(
|
||||||
guild: guild.name,
|
i18n,
|
||||||
}),
|
guild,
|
||||||
|
"logsRuntime.summaries.guildUnavailable",
|
||||||
|
{
|
||||||
|
guild: guild.name,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [detailLine(i18n, guild, "guildId", { value: guild.id })],
|
details: [detailLine(i18n, guild, "guildId", { value: guild.id })],
|
||||||
color: 0xed4245,
|
color: 0xed4245,
|
||||||
});
|
});
|
||||||
@@ -562,9 +718,14 @@ export const registerLogRuntimeEvents = (
|
|||||||
emitRuntimeLog(client, logEventService, i18n, {
|
emitRuntimeLog(client, logEventService, i18n, {
|
||||||
eventKey: "guildBanRemove",
|
eventKey: "guildBanRemove",
|
||||||
guild: ban.guild,
|
guild: ban.guild,
|
||||||
summary: tForGuild(i18n, ban.guild, "logsRuntime.summaries.guildBanRemove", {
|
summary: tForGuild(
|
||||||
user: `<@${ban.user.id}>`,
|
i18n,
|
||||||
}),
|
ban.guild,
|
||||||
|
"logsRuntime.summaries.guildBanRemove",
|
||||||
|
{
|
||||||
|
user: `<@${ban.user.id}>`,
|
||||||
|
},
|
||||||
|
),
|
||||||
details: [
|
details: [
|
||||||
detailLine(i18n, ban.guild, "user", {
|
detailLine(i18n, ban.guild, "user", {
|
||||||
tag: ban.user.tag,
|
tag: ban.user.tag,
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ const log = createScopedLogger("event:messageCreate");
|
|||||||
* @param client - instance du Client Discord
|
* @param client - instance du Client Discord
|
||||||
* @param onPrefixMessage - fonction à appeler pour traiter les messages (préfixe)
|
* @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) => {
|
client.on(Events.MessageCreate, (message: Message) => {
|
||||||
void onPrefixMessage(message).catch((error) => {
|
void onPrefixMessage(message).catch((error) => {
|
||||||
log.error({ err: error }, "handler failed");
|
log.error({ err: error }, "handler failed");
|
||||||
|
|||||||
@@ -28,17 +28,25 @@ export const registerClientReady = (
|
|||||||
|
|
||||||
const botId = client.user?.id;
|
const botId = client.user?.id;
|
||||||
if (!botId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const restoredByLeader = await leaderCoordinator.runIfLeader("presence-restore", botId, async () => {
|
const restoredByLeader = await leaderCoordinator.runIfLeader(
|
||||||
await presenceService.restoreFromStorage(client);
|
"presence-restore",
|
||||||
});
|
botId,
|
||||||
|
async () => {
|
||||||
|
await presenceService.restoreFromStorage(client);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!restoredByLeader) {
|
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) {
|
} catch (error) {
|
||||||
log.error({ err: error }, "failed to restore bot presence");
|
log.error({ err: error }, "failed to restore bot presence");
|
||||||
@@ -46,31 +54,39 @@ export const registerClientReady = (
|
|||||||
|
|
||||||
if (env.AUTO_DEPLOY_SLASH) {
|
if (env.AUTO_DEPLOY_SLASH) {
|
||||||
if (!runtimeAuth) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", botId, async () => {
|
const deployedByLeader = await leaderCoordinator.runIfLeader(
|
||||||
const result = await deployApplicationCommands({
|
"slash-deploy",
|
||||||
token: runtimeAuth.token,
|
botId,
|
||||||
clientId: runtimeAuth.clientId,
|
async () => {
|
||||||
registry,
|
const result = await deployApplicationCommands({
|
||||||
i18n,
|
token: runtimeAuth.token,
|
||||||
...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}),
|
clientId: runtimeAuth.clientId,
|
||||||
});
|
registry,
|
||||||
|
i18n,
|
||||||
|
...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
{
|
{
|
||||||
scope: result.scope,
|
scope: result.scope,
|
||||||
count: result.count,
|
count: result.count,
|
||||||
},
|
},
|
||||||
"slash sync completed",
|
"slash sync completed",
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!deployedByLeader) {
|
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) {
|
} catch (error) {
|
||||||
log.error({ err: error }, "slash sync failed");
|
log.error({ err: error }, "slash sync failed");
|
||||||
|
|||||||
@@ -32,23 +32,28 @@ export const LOG_EVENT_DEFINITIONS: readonly LogEventDefinition[] = [
|
|||||||
{ key: "inviteDelete", category: "invite" },
|
{ 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_EVENT_KEYS_SET = new Set(LOG_EVENT_KEYS);
|
||||||
|
|
||||||
export const LOG_CHANNEL_NAME_BY_CATEGORY: Record<LogEventCategoryKey, string> = {
|
export const LOG_CHANNEL_NAME_BY_CATEGORY: Record<LogEventCategoryKey, string> =
|
||||||
message: "logs-message",
|
{
|
||||||
member: "logs-member",
|
message: "logs-message",
|
||||||
interaction: "logs-interaction",
|
member: "logs-member",
|
||||||
channel: "logs-channel",
|
interaction: "logs-interaction",
|
||||||
role: "logs-role",
|
channel: "logs-channel",
|
||||||
thread: "logs-thread",
|
role: "logs-role",
|
||||||
emoji: "logs-emoji",
|
thread: "logs-thread",
|
||||||
guild: "logs-guild",
|
emoji: "logs-emoji",
|
||||||
moderation: "logs-moderation",
|
guild: "logs-guild",
|
||||||
invite: "logs-invite",
|
moderation: "logs-moderation",
|
||||||
};
|
invite: "logs-invite",
|
||||||
|
};
|
||||||
|
|
||||||
export const LOG_EVENT_CATEGORY_KEYS = Object.keys(LOG_CHANNEL_NAME_BY_CATEGORY) as LogEventCategoryKey[];
|
export const LOG_EVENT_CATEGORY_KEYS = Object.keys(
|
||||||
|
LOG_CHANNEL_NAME_BY_CATEGORY,
|
||||||
|
) as LogEventCategoryKey[];
|
||||||
|
|
||||||
export const LOGS_PANEL_EVENTS_PER_PAGE = 15;
|
export const LOGS_PANEL_EVENTS_PER_PAGE = 15;
|
||||||
@@ -70,7 +70,11 @@ const clampPageIndex = (index: number, pageCount: number): number => {
|
|||||||
return index;
|
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 pageEvents = (uiState: LogPanelUiState): LogEventDefinition[] => {
|
||||||
const totalPages = pageCount();
|
const totalPages = pageCount();
|
||||||
@@ -79,11 +83,17 @@ const pageEvents = (uiState: LogPanelUiState): LogEventDefinition[] => {
|
|||||||
return LOG_EVENT_DEFINITIONS.slice(start, start + LOGS_PANEL_EVENTS_PER_PAGE);
|
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");
|
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}`);
|
return ctx.ct(`ui.events.${eventKey}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,22 +104,34 @@ const panelContent = (
|
|||||||
): string => {
|
): string => {
|
||||||
const totalPages = pageCount();
|
const totalPages = pageCount();
|
||||||
const currentEvents = pageEvents(uiState);
|
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 = [
|
const lines = [
|
||||||
`## ${ctx.ct("ui.embed.title")}`,
|
`## ${ctx.ct("ui.embed.title")}`,
|
||||||
ctx.ct("ui.embed.description"),
|
ctx.ct("ui.embed.description"),
|
||||||
"",
|
"",
|
||||||
ctx.ct("ui.pageLabel", { page: uiState.pageIndex + 1, pageCount: totalPages }),
|
ctx.ct("ui.pageLabel", {
|
||||||
ctx.ct("ui.enabledCountLabel", { enabledCount, totalCount: LOG_EVENT_KEYS.length }),
|
page: uiState.pageIndex + 1,
|
||||||
|
pageCount: totalPages,
|
||||||
|
}),
|
||||||
|
ctx.ct("ui.enabledCountLabel", {
|
||||||
|
enabledCount,
|
||||||
|
totalCount: LOG_EVENT_KEYS.length,
|
||||||
|
}),
|
||||||
"",
|
"",
|
||||||
`${ctx.ct("ui.eventsHeader")}:`,
|
`${ctx.ct("ui.eventsHeader")}:`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const definition of currentEvents) {
|
for (const definition of currentEvents) {
|
||||||
const eventConfig = state[definition.key];
|
const eventConfig = state[definition.key];
|
||||||
const channelDisplay = eventConfig.channelId ? `<#${eventConfig.channelId}>` : ctx.ct("ui.channelNotConfigured");
|
const channelDisplay = eventConfig.channelId
|
||||||
lines.push(`- ${eventLabel(ctx, definition.key)} | ${eventStatusLabel(ctx, eventConfig.enabled)} | ${ctx.ct("ui.channelLabel")}: ${channelDisplay}`);
|
? `<#${eventConfig.channelId}>`
|
||||||
|
: ctx.ct("ui.channelNotConfigured");
|
||||||
|
lines.push(
|
||||||
|
`- ${eventLabel(ctx, definition.key)} | ${eventStatusLabel(ctx, eventConfig.enabled)} | ${ctx.ct("ui.channelLabel")}: ${channelDisplay}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.feedback) {
|
if (uiState.feedback) {
|
||||||
@@ -160,9 +182,15 @@ const buildContainer = (
|
|||||||
.setDisabled(disabled || uiState.pageIndex >= totalPages - 1);
|
.setDisabled(disabled || uiState.pageIndex >= totalPages - 1);
|
||||||
|
|
||||||
const container = new ContainerBuilder();
|
const container = new ContainerBuilder();
|
||||||
container.addTextDisplayComponents(new TextDisplayBuilder().setContent(panelContent(ctx, state, uiState)));
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(panelContent(ctx, state, uiState)),
|
||||||
|
);
|
||||||
container.addActionRowComponents(
|
container.addActionRowComponents(
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(enableAllButton, disableAllButton, createChannelsButton),
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
enableAllButton,
|
||||||
|
disableAllButton,
|
||||||
|
createChannelsButton,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const definitionChunk of chunk(currentEvents, 5)) {
|
for (const definitionChunk of chunk(currentEvents, 5)) {
|
||||||
@@ -176,15 +204,22 @@ const buildContainer = (
|
|||||||
return new ButtonBuilder()
|
return new ButtonBuilder()
|
||||||
.setCustomId(customIds.toggleButtonsByEvent[definition.key])
|
.setCustomId(customIds.toggleButtonsByEvent[definition.key])
|
||||||
.setLabel(label)
|
.setLabel(label)
|
||||||
.setStyle(eventConfig.enabled ? ButtonStyle.Danger : ButtonStyle.Success)
|
.setStyle(
|
||||||
|
eventConfig.enabled ? ButtonStyle.Danger : ButtonStyle.Success,
|
||||||
|
)
|
||||||
.setDisabled(disabled);
|
.setDisabled(disabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
container.addActionRowComponents(new ActionRowBuilder<ButtonBuilder>().addComponents(...rowButtons));
|
container.addActionRowComponents(
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(...rowButtons),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
container.addActionRowComponents(
|
container.addActionRowComponents(
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(previousPageButton, nextPageButton),
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
previousPageButton,
|
||||||
|
nextPageButton,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return container;
|
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(
|
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) => {
|
export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
||||||
return async (ctx: CommandExecutionContext): Promise<void> => {
|
return async (ctx: CommandExecutionContext): Promise<void> => {
|
||||||
|
|
||||||
if (!ctx.guild) {
|
if (!ctx.guild) {
|
||||||
await ctx.reply(ctx.ct("responses.guildOnly"));
|
await ctx.reply(ctx.ct("responses.guildOnly"));
|
||||||
return;
|
return;
|
||||||
@@ -232,7 +271,11 @@ export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ownerId = ctx.user.id;
|
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> => {
|
const disablePanel = async (): Promise<void> => {
|
||||||
await replyMessage
|
await replyMessage
|
||||||
@@ -243,8 +286,13 @@ export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
|
const collector = replyMessage.createMessageComponentCollector({
|
||||||
await logEventService.replacePanelSession(sessionKey, { collector, disable: disablePanel });
|
time: 15 * 60_000,
|
||||||
|
});
|
||||||
|
await logEventService.replacePanelSession(sessionKey, {
|
||||||
|
collector,
|
||||||
|
disable: disablePanel,
|
||||||
|
});
|
||||||
|
|
||||||
collector.on("collect", async (interaction) => {
|
collector.on("collect", async (interaction) => {
|
||||||
if (interaction.user.id !== ownerId) {
|
if (interaction.user.id !== ownerId) {
|
||||||
@@ -289,7 +337,11 @@ export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
|||||||
if (interaction.customId === customIds.createChannelsButton) {
|
if (interaction.customId === customIds.createChannelsButton) {
|
||||||
await interaction.deferUpdate();
|
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) {
|
if (result.failedCategories.length === 0) {
|
||||||
uiState.feedback = ctx.ct("responses.channelsCreated", {
|
uiState.feedback = ctx.ct("responses.channelsCreated", {
|
||||||
created: result.createdCount,
|
created: result.createdCount,
|
||||||
@@ -356,17 +408,21 @@ export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
|||||||
logger.error({ err: error }, "interaction failed");
|
logger.error({ err: error }, "interaction failed");
|
||||||
|
|
||||||
if (!interaction.replied && !interaction.deferred) {
|
if (!interaction.replied && !interaction.deferred) {
|
||||||
await interaction.reply({
|
await interaction
|
||||||
content: ctx.t("errors.execution"),
|
.reply({
|
||||||
flags: [MessageFlags.Ephemeral],
|
content: ctx.t("errors.execution"),
|
||||||
}).catch(() => undefined);
|
flags: [MessageFlags.Ephemeral],
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.followUp({
|
await interaction
|
||||||
content: ctx.t("errors.execution"),
|
.followUp({
|
||||||
flags: [MessageFlags.Ephemeral],
|
content: ctx.t("errors.execution"),
|
||||||
}).catch(() => undefined);
|
flags: [MessageFlags.Ephemeral],
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,16 @@ import type {
|
|||||||
|
|
||||||
export interface LogEventRepository {
|
export interface LogEventRepository {
|
||||||
listByBotGuild(botId: string, guildId: string): Promise<LogEventRow[]>;
|
listByBotGuild(botId: string, guildId: string): Promise<LogEventRow[]>;
|
||||||
upsertByBotGuildEvent(botId: string, guildId: string, eventKey: LogEventKey, config: LogEventConfig): Promise<void>;
|
upsertByBotGuildEvent(
|
||||||
upsertManyByBotGuildEvents(botId: string, guildId: string, entries: readonly LogEventRepositoryEntry[]): Promise<void>;
|
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>;
|
deleteByBotGuild(botId: string, guildId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -34,22 +34,33 @@ const LOG_CHANNEL_CATEGORY_NAME = "logs";
|
|||||||
const LOG_CHANNEL_NAME_PREFIX = "";
|
const LOG_CHANNEL_NAME_PREFIX = "";
|
||||||
const LEGACY_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") {
|
if (!value || typeof value !== "object") {
|
||||||
return false;
|
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 {
|
const hasPermissionsFor = (
|
||||||
permissionsFor: (member: unknown) => { has: (permission: unknown) => boolean } | null;
|
value: unknown,
|
||||||
|
): value is {
|
||||||
|
permissionsFor: (
|
||||||
|
member: unknown,
|
||||||
|
) => { has: (permission: unknown) => boolean } | null;
|
||||||
} => {
|
} => {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return false;
|
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 => {
|
const clampField = (value: string, maxLength: number): string => {
|
||||||
@@ -66,7 +77,8 @@ const buildChannelName = (category: LogEventCategoryKey): string => {
|
|||||||
|
|
||||||
export class LogEventService {
|
export class LogEventService {
|
||||||
private readonly stateCache = new Map<string, LogEventStateByKey>();
|
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) {}
|
public constructor(private readonly repository: LogEventRepository) {}
|
||||||
|
|
||||||
@@ -74,19 +86,32 @@ export class LogEventService {
|
|||||||
return client.user?.id ?? null;
|
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}`;
|
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);
|
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);
|
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);
|
const botId = this.resolveBotId(client);
|
||||||
if (!botId) {
|
if (!botId) {
|
||||||
return createDefaultLogEventState();
|
return createDefaultLogEventState();
|
||||||
@@ -105,22 +130,31 @@ export class LogEventService {
|
|||||||
return cloneLogEventState(state);
|
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);
|
const botId = this.resolveBotId(client);
|
||||||
if (!botId) {
|
if (!botId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries: LogEventRepositoryEntry[] = LOG_EVENT_KEYS.map((eventKey) => ({
|
const entries: LogEventRepositoryEntry[] = LOG_EVENT_KEYS.map(
|
||||||
eventKey,
|
(eventKey) => ({
|
||||||
config: {
|
eventKey,
|
||||||
enabled: state[eventKey].enabled,
|
config: {
|
||||||
channelId: state[eventKey].channelId,
|
enabled: state[eventKey].enabled,
|
||||||
},
|
channelId: state[eventKey].channelId,
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await this.repository.upsertManyByBotGuildEvents(botId, guildId, entries);
|
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(
|
public async createCategoryChannels(
|
||||||
@@ -139,7 +173,10 @@ export class LogEventService {
|
|||||||
return false;
|
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) {
|
if (!logCategory) {
|
||||||
@@ -149,7 +186,10 @@ export class LogEventService {
|
|||||||
type: ChannelType.GuildCategory,
|
type: ChannelType.GuildCategory,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 {
|
return {
|
||||||
createdCount,
|
createdCount,
|
||||||
reusedCount,
|
reusedCount,
|
||||||
@@ -167,34 +207,45 @@ export class LogEventService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return channel.type === ChannelType.GuildText && channel.name === expectedName;
|
return (
|
||||||
|
channel.type === ChannelType.GuildText &&
|
||||||
|
channel.name === expectedName
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingByLegacyName = existingByExpectedName
|
const existingByLegacyName = existingByExpectedName
|
||||||
? null
|
? null
|
||||||
: existingChannels.find((channel) => {
|
: existingChannels.find((channel) => {
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return channel.type === ChannelType.GuildText && (
|
return (
|
||||||
channel.name === legacyName
|
channel.type === ChannelType.GuildText &&
|
||||||
|| channel.name === `${LEGACY_LOG_CHANNEL_NAME_PREFIX}${legacyName}`
|
(channel.name === legacyName ||
|
||||||
);
|
channel.name ===
|
||||||
});
|
`${LEGACY_LOG_CHANNEL_NAME_PREFIX}${legacyName}`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const existing = existingByExpectedName ?? existingByLegacyName;
|
const existing = existingByExpectedName ?? existingByLegacyName;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.name !== expectedName) {
|
if (existing.name !== expectedName) {
|
||||||
await existing.setName(expectedName).catch((error) => {
|
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) {
|
if (existing.parentId !== logCategory.id) {
|
||||||
await existing.setParent(logCategory.id).catch((error) => {
|
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);
|
categoryToChannelId.set(category, created.id);
|
||||||
createdCount += 1;
|
createdCount += 1;
|
||||||
} catch (error) {
|
} 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);
|
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);
|
const botId = this.resolveBotId(client);
|
||||||
if (!botId) {
|
if (!botId) {
|
||||||
return;
|
return;
|
||||||
@@ -276,13 +333,16 @@ export class LogEventService {
|
|||||||
return;
|
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) {
|
if (!guild) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = guild.channels.cache.get(eventConfig.channelId)
|
const channel =
|
||||||
?? await guild.channels.fetch(eventConfig.channelId).catch(() => null);
|
guild.channels.cache.get(eventConfig.channelId) ??
|
||||||
|
(await guild.channels.fetch(eventConfig.channelId).catch(() => null));
|
||||||
|
|
||||||
if (!channel || !hasSendMethod(channel)) {
|
if (!channel || !hasSendMethod(channel)) {
|
||||||
return;
|
return;
|
||||||
@@ -291,7 +351,11 @@ export class LogEventService {
|
|||||||
const me = guild.members.me;
|
const me = guild.members.me;
|
||||||
if (me && hasPermissionsFor(channel)) {
|
if (me && hasPermissionsFor(channel)) {
|
||||||
const permissions = channel.permissionsFor(me);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,7 +367,10 @@ export class LogEventService {
|
|||||||
.setTimestamp(new Date());
|
.setTimestamp(new Date());
|
||||||
|
|
||||||
if (input.details && input.details.length > 0) {
|
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) {
|
if (detailsValue.length > 0) {
|
||||||
embed.addFields({
|
embed.addFields({
|
||||||
name: input.detailsTitle ?? "details",
|
name: input.detailsTitle ?? "details",
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ const log = createScopedLogger("command:memberMessagesPanel");
|
|||||||
|
|
||||||
const panelSessions = new ComponentSessionRegistry<MemberMessagePanelSession>();
|
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}`;
|
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");
|
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`);
|
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);
|
const roleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds);
|
||||||
if (roleIds.length === 0) {
|
if (roleIds.length === 0) {
|
||||||
return ctx.ct("ui.autoRolesNotConfigured");
|
return ctx.ct("ui.autoRolesNotConfigured");
|
||||||
@@ -79,7 +93,9 @@ const panelContent = (
|
|||||||
config: MemberMessageConfig,
|
config: MemberMessageConfig,
|
||||||
uiState: MemberMessagePanelUiState,
|
uiState: MemberMessagePanelUiState,
|
||||||
): string => {
|
): string => {
|
||||||
const channelDisplay = config.channelId ? `<#${config.channelId}>` : ctx.ct("ui.channelNotConfigured");
|
const channelDisplay = config.channelId
|
||||||
|
? `<#${config.channelId}>`
|
||||||
|
: ctx.ct("ui.channelNotConfigured");
|
||||||
const lines = [
|
const lines = [
|
||||||
`## ${ctx.ct("ui.embed.title")}`,
|
`## ${ctx.ct("ui.embed.title")}`,
|
||||||
ctx.ct("ui.embed.description"),
|
ctx.ct("ui.embed.description"),
|
||||||
@@ -90,15 +106,23 @@ const panelContent = (
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (kind === "welcome") {
|
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) {
|
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) {
|
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");
|
return lines.join("\n");
|
||||||
@@ -119,14 +143,30 @@ const buildContainer = (
|
|||||||
.setDisabled(disabled);
|
.setDisabled(disabled);
|
||||||
|
|
||||||
const channelButton = new ButtonBuilder()
|
const channelButton = new ButtonBuilder()
|
||||||
.setCustomId(uiState.channelPickerOpen ? customIds.channelCancelButton : customIds.channelButton)
|
.setCustomId(
|
||||||
.setLabel(uiState.channelPickerOpen ? ctx.ct("ui.buttons.channelCancel") : ctx.ct("ui.buttons.channel"))
|
uiState.channelPickerOpen
|
||||||
|
? customIds.channelCancelButton
|
||||||
|
: customIds.channelButton,
|
||||||
|
)
|
||||||
|
.setLabel(
|
||||||
|
uiState.channelPickerOpen
|
||||||
|
? ctx.ct("ui.buttons.channelCancel")
|
||||||
|
: ctx.ct("ui.buttons.channel"),
|
||||||
|
)
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setDisabled(disabled);
|
.setDisabled(disabled);
|
||||||
|
|
||||||
const roleButton = new ButtonBuilder()
|
const roleButton = new ButtonBuilder()
|
||||||
.setCustomId(uiState.rolePickerOpen ? customIds.roleCancelButton : customIds.roleButton)
|
.setCustomId(
|
||||||
.setLabel(uiState.rolePickerOpen ? ctx.ct("ui.buttons.rolesCancel") : ctx.ct("ui.buttons.roles"))
|
uiState.rolePickerOpen
|
||||||
|
? customIds.roleCancelButton
|
||||||
|
: customIds.roleButton,
|
||||||
|
)
|
||||||
|
.setLabel(
|
||||||
|
uiState.rolePickerOpen
|
||||||
|
? ctx.ct("ui.buttons.rolesCancel")
|
||||||
|
: ctx.ct("ui.buttons.roles"),
|
||||||
|
)
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setDisabled(disabled);
|
.setDisabled(disabled);
|
||||||
|
|
||||||
@@ -159,7 +199,9 @@ const buildContainer = (
|
|||||||
|
|
||||||
const container = new ContainerBuilder();
|
const container = new ContainerBuilder();
|
||||||
container.addTextDisplayComponents(
|
container.addTextDisplayComponents(
|
||||||
new TextDisplayBuilder().setContent(panelContent(ctx, kind, config, uiState)),
|
new TextDisplayBuilder().setContent(
|
||||||
|
panelContent(ctx, kind, config, uiState),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (kind === "welcome") {
|
if (kind === "welcome") {
|
||||||
@@ -174,7 +216,11 @@ const buildContainer = (
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
container.addActionRowComponents(
|
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);
|
.setChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement);
|
||||||
|
|
||||||
container.addActionRowComponents(
|
container.addActionRowComponents(
|
||||||
new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(channelSelect),
|
new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(
|
||||||
|
channelSelect,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,12 +323,16 @@ export const createMemberMessagePanelExecute = (
|
|||||||
await replyMessage
|
await replyMessage
|
||||||
.edit({
|
.edit({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, kind, customIds, config, uiState, true)],
|
components: [
|
||||||
|
buildContainer(ctx, kind, customIds, config, uiState, true),
|
||||||
|
],
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
|
const collector = replyMessage.createMessageComponentCollector({
|
||||||
|
time: 15 * 60_000,
|
||||||
|
});
|
||||||
await panelSessions.replace(sessionKey, {
|
await panelSessions.replace(sessionKey, {
|
||||||
collector,
|
collector,
|
||||||
disable: disablePanel,
|
disable: disablePanel,
|
||||||
@@ -302,7 +354,9 @@ export const createMemberMessagePanelExecute = (
|
|||||||
await saveConfig();
|
await saveConfig();
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
components: [
|
||||||
|
buildContainer(ctx, kind, customIds, config, uiState),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -312,7 +366,9 @@ export const createMemberMessagePanelExecute = (
|
|||||||
uiState.rolePickerOpen = false;
|
uiState.rolePickerOpen = false;
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
components: [
|
||||||
|
buildContainer(ctx, kind, customIds, config, uiState),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -321,36 +377,53 @@ export const createMemberMessagePanelExecute = (
|
|||||||
uiState.channelPickerOpen = false;
|
uiState.channelPickerOpen = false;
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
components: [
|
||||||
|
buildContainer(ctx, kind, customIds, config, uiState),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === "welcome" && interaction.customId === customIds.roleButton) {
|
if (
|
||||||
|
kind === "welcome" &&
|
||||||
|
interaction.customId === customIds.roleButton
|
||||||
|
) {
|
||||||
uiState.rolePickerOpen = true;
|
uiState.rolePickerOpen = true;
|
||||||
uiState.channelPickerOpen = false;
|
uiState.channelPickerOpen = false;
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
components: [
|
||||||
|
buildContainer(ctx, kind, customIds, config, uiState),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === "welcome" && interaction.customId === customIds.roleCancelButton) {
|
if (
|
||||||
|
kind === "welcome" &&
|
||||||
|
interaction.customId === customIds.roleCancelButton
|
||||||
|
) {
|
||||||
uiState.rolePickerOpen = false;
|
uiState.rolePickerOpen = false;
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
components: [
|
||||||
|
buildContainer(ctx, kind, customIds, config, uiState),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === "welcome" && interaction.customId === customIds.roleClearButton) {
|
if (
|
||||||
|
kind === "welcome" &&
|
||||||
|
interaction.customId === customIds.roleClearButton
|
||||||
|
) {
|
||||||
config.autoRoleIds = [];
|
config.autoRoleIds = [];
|
||||||
await saveConfig();
|
await saveConfig();
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, kind, customIds, config, uiState)],
|
components: [
|
||||||
|
buildContainer(ctx, kind, customIds, config, uiState),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -369,7 +442,9 @@ export const createMemberMessagePanelExecute = (
|
|||||||
if (testResult.sent) {
|
if (testResult.sent) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
content: ctx.ct("responses.testSuccess", {
|
content: ctx.ct("responses.testSuccess", {
|
||||||
channel: testResult.channelId ? `<#${testResult.channelId}>` : ctx.ct("ui.channelNotConfigured"),
|
channel: testResult.channelId
|
||||||
|
? `<#${testResult.channelId}>`
|
||||||
|
: ctx.ct("ui.channelNotConfigured"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -388,7 +463,10 @@ export const createMemberMessagePanelExecute = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.isStringSelectMenu() && interaction.customId === customIds.typeSelect) {
|
if (
|
||||||
|
interaction.isStringSelectMenu() &&
|
||||||
|
interaction.customId === customIds.typeSelect
|
||||||
|
) {
|
||||||
const nextType = interaction.values[0];
|
const nextType = interaction.values[0];
|
||||||
if (!nextType || !isMemberMessageRenderTypeValue(nextType)) {
|
if (!nextType || !isMemberMessageRenderTypeValue(nextType)) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
@@ -407,8 +485,15 @@ export const createMemberMessagePanelExecute = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === "welcome" && interaction.isRoleSelectMenu() && interaction.customId === customIds.roleSelect) {
|
if (
|
||||||
config.autoRoleIds = sanitizeMemberMessageRoleIds([...config.autoRoleIds, ...interaction.values]);
|
kind === "welcome" &&
|
||||||
|
interaction.isRoleSelectMenu() &&
|
||||||
|
interaction.customId === customIds.roleSelect
|
||||||
|
) {
|
||||||
|
config.autoRoleIds = sanitizeMemberMessageRoleIds([
|
||||||
|
...config.autoRoleIds,
|
||||||
|
...interaction.values,
|
||||||
|
]);
|
||||||
uiState.rolePickerOpen = false;
|
uiState.rolePickerOpen = false;
|
||||||
await saveConfig();
|
await saveConfig();
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
@@ -418,7 +503,10 @@ export const createMemberMessagePanelExecute = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.isChannelSelectMenu() && interaction.customId === customIds.channelSelect) {
|
if (
|
||||||
|
interaction.isChannelSelectMenu() &&
|
||||||
|
interaction.customId === customIds.channelSelect
|
||||||
|
) {
|
||||||
const channelId = interaction.values[0];
|
const channelId = interaction.values[0];
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
@@ -446,17 +534,21 @@ export const createMemberMessagePanelExecute = (
|
|||||||
log.error({ kind, err: error }, "interaction failed");
|
log.error({ kind, err: error }, "interaction failed");
|
||||||
|
|
||||||
if (!interaction.replied && !interaction.deferred) {
|
if (!interaction.replied && !interaction.deferred) {
|
||||||
await interaction.reply({
|
await interaction
|
||||||
content: ctx.t("errors.execution"),
|
.reply({
|
||||||
flags: [MessageFlags.Ephemeral],
|
content: ctx.t("errors.execution"),
|
||||||
}).catch(() => undefined);
|
flags: [MessageFlags.Ephemeral],
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.followUp({
|
await interaction
|
||||||
content: ctx.t("errors.execution"),
|
.followUp({
|
||||||
flags: [MessageFlags.Ephemeral],
|
content: ctx.t("errors.execution"),
|
||||||
}).catch(() => undefined);
|
flags: [MessageFlags.Ephemeral],
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ const wrapText = (
|
|||||||
maxWidth: number,
|
maxWidth: number,
|
||||||
maxLines: number,
|
maxLines: number,
|
||||||
): string[] => {
|
): 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) {
|
if (words.length === 0) {
|
||||||
return [""];
|
return [""];
|
||||||
}
|
}
|
||||||
@@ -78,11 +81,15 @@ const wrapText = (
|
|||||||
const lastLine = lines[maxLines - 1];
|
const lastLine = lines[maxLines - 1];
|
||||||
if (lastLine && lines.length === maxLines) {
|
if (lastLine && lines.length === maxLines) {
|
||||||
let value = lastLine;
|
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);
|
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;
|
return lines;
|
||||||
@@ -95,7 +102,13 @@ const drawAvatar = async (
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
context.fillStyle = "#1f2937";
|
context.fillStyle = "#1f2937";
|
||||||
context.beginPath();
|
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.closePath();
|
||||||
context.fill();
|
context.fill();
|
||||||
|
|
||||||
@@ -103,7 +116,13 @@ const drawAvatar = async (
|
|||||||
const avatar = await loadImage(avatarUrl);
|
const avatar = await loadImage(avatarUrl);
|
||||||
context.save();
|
context.save();
|
||||||
context.beginPath();
|
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.closePath();
|
||||||
context.clip();
|
context.clip();
|
||||||
context.drawImage(avatar, AVATAR_X, AVATAR_Y, AVATAR_SIZE, AVATAR_SIZE);
|
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.font = `700 92px ${FONT_FAMILY}`;
|
||||||
context.textAlign = "center";
|
context.textAlign = "center";
|
||||||
context.textBaseline = "middle";
|
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.textAlign = "left";
|
||||||
context.textBaseline = "alphabetic";
|
context.textBaseline = "alphabetic";
|
||||||
}
|
}
|
||||||
@@ -122,12 +145,20 @@ const drawAvatar = async (
|
|||||||
context.strokeStyle = "rgba(248, 250, 252, 0.9)";
|
context.strokeStyle = "rgba(248, 250, 252, 0.9)";
|
||||||
context.lineWidth = 6;
|
context.lineWidth = 6;
|
||||||
context.beginPath();
|
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.closePath();
|
||||||
context.stroke();
|
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 canvas = createCanvas(CARD_WIDTH, CARD_HEIGHT);
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
|
|
||||||
@@ -153,12 +184,26 @@ export const renderMemberMessageImage = async (input: MemberMessageImageInput):
|
|||||||
|
|
||||||
await drawAvatar(context, input.username, input.avatarUrl);
|
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.font = `700 ${headlineSize}px ${FONT_FAMILY}`;
|
||||||
context.fillStyle = "#f8fafc";
|
context.fillStyle = "#f8fafc";
|
||||||
context.fillText(input.title, TEXT_X, 116);
|
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.font = `500 ${subtitleSize}px ${FONT_FAMILY}`;
|
||||||
context.fillStyle = "#e5e7eb";
|
context.fillStyle = "#e5e7eb";
|
||||||
|
|
||||||
@@ -169,7 +214,14 @@ export const renderMemberMessageImage = async (input: MemberMessageImageInput):
|
|||||||
context.fillText(line, TEXT_X, subtitleStartY + index * subtitleLineHeight);
|
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.font = `700 ${usernameSize}px ${FONT_FAMILY}`;
|
||||||
context.fillStyle = input.kind === "welcome" ? "#93c5fd" : "#fda4af";
|
context.fillStyle = input.kind === "welcome" ? "#93c5fd" : "#fda4af";
|
||||||
context.fillText(input.username, TEXT_X, CARD_HEIGHT - 34);
|
context.fillText(input.username, TEXT_X, CARD_HEIGHT - 34);
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ import type {
|
|||||||
} from "../../types/memberMessages.js";
|
} from "../../types/memberMessages.js";
|
||||||
|
|
||||||
export interface MemberMessageRepository {
|
export interface MemberMessageRepository {
|
||||||
getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise<MemberMessageConfig>;
|
getByBotGuildKind(
|
||||||
upsertByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind, config: MemberMessageConfig): Promise<void>;
|
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>;
|
deleteByBotGuild(botId: string, guildId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ const hasSendMethod = (value: unknown): value is SendableChannel => {
|
|||||||
return false;
|
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 } => {
|
const hasCode = (error: unknown): error is { code: number } => {
|
||||||
@@ -46,10 +48,15 @@ const hasCode = (error: unknown): error is { code: number } => {
|
|||||||
return false;
|
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}>`,
|
user: `<@${user.id}>`,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
guild: guild.name,
|
guild: guild.name,
|
||||||
@@ -87,12 +94,17 @@ const resolveNonEmptyTemplate = (
|
|||||||
return fallback.length > 0 ? fallback : key;
|
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) {
|
if (roleId === member.guild.id) {
|
||||||
return null;
|
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) {
|
if (!role || !role.editable) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -109,10 +121,11 @@ const buildMemberMessagePayload = async (
|
|||||||
user: User,
|
user: User,
|
||||||
): Promise<MessageCreateOptions> => {
|
): Promise<MessageCreateOptions> => {
|
||||||
const vars = messageTemplateVars(guild, user);
|
const vars = messageTemplateVars(guild, user);
|
||||||
const allowedMentions: NonNullable<MessageCreateOptions["allowedMentions"]> = {
|
const allowedMentions: NonNullable<MessageCreateOptions["allowedMentions"]> =
|
||||||
parse: [],
|
{
|
||||||
users: [user.id],
|
parse: [],
|
||||||
};
|
users: [user.id],
|
||||||
|
};
|
||||||
|
|
||||||
if (renderType === "simple") {
|
if (renderType === "simple") {
|
||||||
return {
|
return {
|
||||||
@@ -128,7 +141,9 @@ const buildMemberMessagePayload = async (
|
|||||||
new EmbedBuilder()
|
new EmbedBuilder()
|
||||||
.setColor(messageColor(kind))
|
.setColor(messageColor(kind))
|
||||||
.setTitle(resolveTemplate(i18n, lang, kind, "embedTitle", vars))
|
.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 imageTitle = resolveNonEmptyTemplate(
|
||||||
const imageSubtitle = resolveNonEmptyTemplate(i18n, lang, kind, "imageDescription", vars);
|
i18n,
|
||||||
|
lang,
|
||||||
|
kind,
|
||||||
|
"imageTitle",
|
||||||
|
vars,
|
||||||
|
);
|
||||||
|
const imageSubtitle = resolveNonEmptyTemplate(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
kind,
|
||||||
|
"imageDescription",
|
||||||
|
vars,
|
||||||
|
);
|
||||||
|
|
||||||
const imageBuffer = await renderMemberMessageImage({
|
const imageBuffer = await renderMemberMessageImage({
|
||||||
kind,
|
kind,
|
||||||
@@ -171,11 +198,17 @@ const buildMemberMessagePayload = async (
|
|||||||
export class MemberMessageService {
|
export class MemberMessageService {
|
||||||
public constructor(private readonly repository: MemberMessageRepository) {}
|
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;
|
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);
|
return this.repository.getByBotGuildKind(botId, guildId, kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +225,9 @@ export class MemberMessageService {
|
|||||||
await this.repository.deleteByBotGuild(botId, guildId);
|
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);
|
const botId = this.resolveBotId(input.client);
|
||||||
if (!botId) {
|
if (!botId) {
|
||||||
return {
|
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);
|
const configuredRoleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds);
|
||||||
|
|
||||||
if (configuredRoleIds.length === 0) {
|
if (configuredRoleIds.length === 0) {
|
||||||
@@ -239,11 +278,17 @@ export class MemberMessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolvedRoleIds = await Promise.all(
|
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 appliedRoleIds = resolvedRoleIds.filter(
|
||||||
const skippedRoleIds = configuredRoleIds.filter((roleId) => !appliedRoleIds.includes(roleId));
|
(roleId): roleId is string => roleId !== null,
|
||||||
|
);
|
||||||
|
const skippedRoleIds = configuredRoleIds.filter(
|
||||||
|
(roleId) => !appliedRoleIds.includes(roleId),
|
||||||
|
);
|
||||||
|
|
||||||
if (appliedRoleIds.length === 0) {
|
if (appliedRoleIds.length === 0) {
|
||||||
return {
|
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);
|
const botId = this.resolveBotId(input.client);
|
||||||
if (!botId) {
|
if (!botId) {
|
||||||
return {
|
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) {
|
if (!input.ignoreEnabled && !config.enabled) {
|
||||||
return {
|
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) {
|
if (!channel) {
|
||||||
return {
|
return {
|
||||||
sent: false,
|
sent: false,
|
||||||
@@ -331,9 +384,17 @@ export class MemberMessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const me = input.guild.members.me;
|
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);
|
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 {
|
return {
|
||||||
sent: false,
|
sent: false,
|
||||||
reason: "missing_permissions",
|
reason: "missing_permissions",
|
||||||
@@ -343,7 +404,14 @@ export class MemberMessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lang = input.i18n.resolveLang(input.guild.preferredLocale ?? null);
|
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 {
|
try {
|
||||||
await channel.send(payload);
|
await channel.send(payload);
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ const createCustomIds = (): PresenceCustomIds => {
|
|||||||
const statusLabel = (ctx: CommandExecutionContext, status: string): string =>
|
const statusLabel = (ctx: CommandExecutionContext, status: string): string =>
|
||||||
ctx.ct(`ui.status.options.${status}.label`);
|
ctx.ct(`ui.status.options.${status}.label`);
|
||||||
|
|
||||||
const activityLabel = (ctx: CommandExecutionContext, activityType: string): string =>
|
const activityLabel = (
|
||||||
ctx.ct(`ui.activity.options.${activityType}.label`);
|
ctx: CommandExecutionContext,
|
||||||
|
activityType: string,
|
||||||
|
): string => ctx.ct(`ui.activity.options.${activityType}.label`);
|
||||||
|
|
||||||
const panelContent = (
|
const panelContent = (
|
||||||
ctx: CommandExecutionContext,
|
ctx: CommandExecutionContext,
|
||||||
@@ -60,8 +62,13 @@ const panelContent = (
|
|||||||
|
|
||||||
const templateText = service.getActiveTemplateText(state, runtimeState);
|
const templateText = service.getActiveTemplateText(state, runtimeState);
|
||||||
const activityPreview = service.renderPreview(ctx.client, templateText);
|
const activityPreview = service.renderPreview(ctx.client, templateText);
|
||||||
const activityTexts = state.activity.texts.map((text, index) => `${index + 1}. ${text}`).join(" | ");
|
const activityTexts = state.activity.texts
|
||||||
const currentIndex = Math.min(runtimeState.activePresenceTextIndex + 1, state.activity.texts.length);
|
.map((text, index) => `${index + 1}. ${text}`)
|
||||||
|
.join(" | ");
|
||||||
|
const currentIndex = Math.min(
|
||||||
|
runtimeState.activePresenceTextIndex + 1,
|
||||||
|
state.activity.texts.length,
|
||||||
|
);
|
||||||
|
|
||||||
const summary = ctx.ct("responses.panel", {
|
const summary = ctx.ct("responses.panel", {
|
||||||
status: statusLabel(ctx, state.status),
|
status: statusLabel(ctx, state.status),
|
||||||
@@ -138,16 +145,23 @@ const buildContainer = (
|
|||||||
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(statusSelect),
|
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(statusSelect),
|
||||||
);
|
);
|
||||||
container.addActionRowComponents(
|
container.addActionRowComponents(
|
||||||
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(activitySelect),
|
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
activitySelect,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
container.addActionRowComponents(
|
container.addActionRowComponents(
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(textButton, intervalButton),
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
textButton,
|
||||||
|
intervalButton,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createPresenceCommandExecute = (presenceService: PresenceService) => {
|
export const createPresenceCommandExecute = (
|
||||||
|
presenceService: PresenceService,
|
||||||
|
) => {
|
||||||
return async (ctx: CommandExecutionContext): Promise<void> => {
|
return async (ctx: CommandExecutionContext): Promise<void> => {
|
||||||
const state = await presenceService.loadState(ctx.client);
|
const state = await presenceService.loadState(ctx.client);
|
||||||
presenceService.applyState(ctx.client, state);
|
presenceService.applyState(ctx.client, state);
|
||||||
@@ -172,13 +186,20 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
|||||||
await replyMessage
|
await replyMessage
|
||||||
.edit({
|
.edit({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, presenceService, state, customIds, true)],
|
components: [
|
||||||
|
buildContainer(ctx, presenceService, state, customIds, true),
|
||||||
|
],
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
|
const collector = replyMessage.createMessageComponentCollector({
|
||||||
await presenceService.replacePanelSession(sessionKey, { collector, disable: disablePanel });
|
time: 15 * 60_000,
|
||||||
|
});
|
||||||
|
await presenceService.replacePanelSession(sessionKey, {
|
||||||
|
collector,
|
||||||
|
disable: disablePanel,
|
||||||
|
});
|
||||||
|
|
||||||
collector.on("collect", async (interaction) => {
|
collector.on("collect", async (interaction) => {
|
||||||
if (interaction.user.id !== ownerId) {
|
if (interaction.user.id !== ownerId) {
|
||||||
@@ -194,7 +215,10 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
|||||||
if (interaction.customId === customIds.statusSelect) {
|
if (interaction.customId === customIds.statusSelect) {
|
||||||
const nextStatus = interaction.values[0];
|
const nextStatus = interaction.values[0];
|
||||||
if (!nextStatus || !isPresenceStatusValue(nextStatus)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +226,9 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
|||||||
await presenceService.persistAndApply(ctx.client, state);
|
await presenceService.persistAndApply(ctx.client, state);
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, presenceService, state, customIds)],
|
components: [
|
||||||
|
buildContainer(ctx, presenceService, state, customIds),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -210,7 +236,10 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
|||||||
if (interaction.customId === customIds.activitySelect) {
|
if (interaction.customId === customIds.activitySelect) {
|
||||||
const nextType = interaction.values[0];
|
const nextType = interaction.values[0];
|
||||||
if (!nextType || !isPresenceActivityTypeValue(nextType)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,12 +247,17 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
|||||||
await presenceService.persistAndApply(ctx.client, state);
|
await presenceService.persistAndApply(ctx.client, state);
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, presenceService, state, customIds)],
|
components: [
|
||||||
|
buildContainer(ctx, presenceService, state, customIds),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
|
await interaction.reply({
|
||||||
|
content: ctx.ct("responses.invalidSelection"),
|
||||||
|
flags: [MessageFlags.Ephemeral],
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +285,14 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
|||||||
const submitted = await interaction.awaitModalSubmit({
|
const submitted = await interaction.awaitModalSubmit({
|
||||||
time: 120_000,
|
time: 120_000,
|
||||||
filter: (modalInteraction) =>
|
filter: (modalInteraction) =>
|
||||||
modalInteraction.customId === customIds.textModal
|
modalInteraction.customId === customIds.textModal &&
|
||||||
&& modalInteraction.user.id === ownerId,
|
modalInteraction.user.id === ownerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextTexts = sanitizeActivityTexts(
|
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;
|
state.activity.texts = nextTexts;
|
||||||
@@ -269,13 +305,17 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
|||||||
|
|
||||||
await replyMessage.edit({
|
await replyMessage.edit({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, presenceService, state, customIds)],
|
components: [
|
||||||
|
buildContainer(ctx, presenceService, state, customIds),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
await interaction.followUp({
|
await interaction
|
||||||
content: ctx.ct("responses.modalTimeout"),
|
.followUp({
|
||||||
flags: [MessageFlags.Ephemeral],
|
content: ctx.ct("responses.modalTimeout"),
|
||||||
}).catch(() => undefined);
|
flags: [MessageFlags.Ephemeral],
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -304,11 +344,13 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
|||||||
const submitted = await interaction.awaitModalSubmit({
|
const submitted = await interaction.awaitModalSubmit({
|
||||||
time: 120_000,
|
time: 120_000,
|
||||||
filter: (modalInteraction) =>
|
filter: (modalInteraction) =>
|
||||||
modalInteraction.customId === customIds.intervalModal
|
modalInteraction.customId === customIds.intervalModal &&
|
||||||
&& modalInteraction.user.id === ownerId,
|
modalInteraction.user.id === ownerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawSeconds = submitted.fields.getTextInputValue(customIds.intervalInput).trim();
|
const rawSeconds = submitted.fields
|
||||||
|
.getTextInputValue(customIds.intervalInput)
|
||||||
|
.trim();
|
||||||
if (!/^\d+$/.test(rawSeconds)) {
|
if (!/^\d+$/.test(rawSeconds)) {
|
||||||
await submitted.reply({
|
await submitted.reply({
|
||||||
content: ctx.ct("responses.invalidInterval", {
|
content: ctx.ct("responses.invalidInterval", {
|
||||||
@@ -339,33 +381,47 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
|
|||||||
|
|
||||||
await replyMessage.edit({
|
await replyMessage.edit({
|
||||||
flags: MessageFlags.IsComponentsV2,
|
flags: MessageFlags.IsComponentsV2,
|
||||||
components: [buildContainer(ctx, presenceService, state, customIds)],
|
components: [
|
||||||
|
buildContainer(ctx, presenceService, state, customIds),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
await interaction.followUp({
|
await interaction
|
||||||
content: ctx.ct("responses.modalTimeout"),
|
.followUp({
|
||||||
flags: [MessageFlags.Ephemeral],
|
content: ctx.ct("responses.modalTimeout"),
|
||||||
}).catch(() => undefined);
|
flags: [MessageFlags.Ephemeral],
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
|
await interaction.reply({
|
||||||
|
content: ctx.ct("responses.invalidSelection"),
|
||||||
|
flags: [MessageFlags.Ephemeral],
|
||||||
|
});
|
||||||
return;
|
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) {
|
} catch (error) {
|
||||||
log.error({ err: error }, "interaction failed");
|
log.error({ err: error }, "interaction failed");
|
||||||
const fallback = ctx.t("errors.execution");
|
const fallback = ctx.t("errors.execution");
|
||||||
|
|
||||||
if (!interaction.replied && !interaction.deferred) {
|
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;
|
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 { PresenceRepository } from "./repository.js";
|
||||||
import type { PresencePanelSession, PresenceRuntimeState } from "./types.js";
|
import type { PresencePanelSession, PresenceRuntimeState } from "./types.js";
|
||||||
|
|
||||||
const DISCORD_ACTIVITY_TYPES: Record<PresenceActivityTypeValue, ActivityType> = {
|
const DISCORD_ACTIVITY_TYPES: Record<PresenceActivityTypeValue, ActivityType> =
|
||||||
PLAYING: ActivityType.Playing,
|
{
|
||||||
STREAMING: ActivityType.Streaming,
|
PLAYING: ActivityType.Playing,
|
||||||
WATCHING: ActivityType.Watching,
|
STREAMING: ActivityType.Streaming,
|
||||||
LISTENING: ActivityType.Listening,
|
WATCHING: ActivityType.Watching,
|
||||||
COMPETING: ActivityType.Competing,
|
LISTENING: ActivityType.Listening,
|
||||||
CUSTOM: ActivityType.Custom,
|
COMPETING: ActivityType.Competing,
|
||||||
};
|
CUSTOM: ActivityType.Custom,
|
||||||
|
};
|
||||||
|
|
||||||
const createRuntimeState = (): PresenceRuntimeState => ({
|
const createRuntimeState = (): PresenceRuntimeState => ({
|
||||||
dynamicPresenceRefreshTimer: null,
|
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;
|
return status === "streaming" ? "online" : status;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class PresenceService {
|
export class PresenceService {
|
||||||
private readonly runtimeByBotId = new Map<string, PresenceRuntimeState>();
|
private readonly runtimeByBotId = new Map<string, PresenceRuntimeState>();
|
||||||
private readonly panelSessions = new ComponentSessionRegistry<PresencePanelSession>();
|
private readonly panelSessions =
|
||||||
|
new ComponentSessionRegistry<PresencePanelSession>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly repository: PresenceRepository,
|
private readonly repository: PresenceRepository,
|
||||||
@@ -76,20 +80,33 @@ export class PresenceService {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
public normalizeState(state: PresenceState, runtimeState: PresenceRuntimeState): void {
|
public normalizeState(
|
||||||
|
state: PresenceState,
|
||||||
|
runtimeState: PresenceRuntimeState,
|
||||||
|
): void {
|
||||||
const activityTexts = sanitizeActivityTexts(state.activity.texts);
|
const activityTexts = sanitizeActivityTexts(state.activity.texts);
|
||||||
state.activity.texts = activityTexts;
|
state.activity.texts = activityTexts;
|
||||||
state.activity.text = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
|
state.activity.text =
|
||||||
state.activity.rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
|
activityTexts[0] ?? sanitizeActivityText(state.activity.text);
|
||||||
|
state.activity.rotationIntervalSeconds =
|
||||||
|
sanitizePresenceRotationIntervalSeconds(
|
||||||
|
state.activity.rotationIntervalSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
if (runtimeState.activePresenceTextIndex >= activityTexts.length) {
|
if (runtimeState.activePresenceTextIndex >= activityTexts.length) {
|
||||||
runtimeState.activePresenceTextIndex = 0;
|
runtimeState.activePresenceTextIndex = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getActiveTemplateText(state: PresenceState, runtimeState: PresenceRuntimeState): string {
|
public getActiveTemplateText(
|
||||||
|
state: PresenceState,
|
||||||
|
runtimeState: PresenceRuntimeState,
|
||||||
|
): string {
|
||||||
this.normalizeState(state, runtimeState);
|
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 {
|
public renderPreview(client: Client, templateText: string): string {
|
||||||
@@ -111,7 +128,10 @@ export class PresenceService {
|
|||||||
this.syncDynamicPresenceTimers(client, state, runtimeState);
|
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);
|
this.applyState(client, state);
|
||||||
|
|
||||||
const botId = this.resolveBotId(client);
|
const botId = this.resolveBotId(client);
|
||||||
@@ -134,11 +154,17 @@ export class PresenceService {
|
|||||||
return `${this.resolveBotId(client) ?? "unbound"}:${userId}`;
|
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);
|
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);
|
this.panelSessions.deleteIfCollectorMatch(key, collector);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +177,11 @@ export class PresenceService {
|
|||||||
await this.panelSessions.stopAll("shutdown");
|
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) {
|
if (!client.user) {
|
||||||
return;
|
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);
|
this.normalizeState(state, runtimeState);
|
||||||
clearRuntimeTimers(runtimeState);
|
clearRuntimeTimers(runtimeState);
|
||||||
|
|
||||||
@@ -213,14 +247,18 @@ export class PresenceService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
runtimeState.activePresenceTextIndex = (runtimeState.activePresenceTextIndex + 1) % state.activity.texts.length;
|
runtimeState.activePresenceTextIndex =
|
||||||
|
(runtimeState.activePresenceTextIndex + 1) %
|
||||||
|
state.activity.texts.length;
|
||||||
this.applyPresenceState(client, state, runtimeState);
|
this.applyPresenceState(client, state, runtimeState);
|
||||||
}, state.activity.rotationIntervalSeconds * 1_000);
|
}, state.activity.rotationIntervalSeconds * 1_000);
|
||||||
|
|
||||||
runtimeState.presenceRotationTimer.unref?.();
|
runtimeState.presenceRotationTimer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasKnownVariable = state.activity.texts.some((templateText) => containsPresenceTemplateVariables(templateText));
|
const hasKnownVariable = state.activity.texts.some((templateText) =>
|
||||||
|
containsPresenceTemplateVariables(templateText),
|
||||||
|
);
|
||||||
if (!hasKnownVariable) {
|
if (!hasKnownVariable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { Client } from "discord.js";
|
import type { Client } from "discord.js";
|
||||||
|
|
||||||
import { env } from "../../config/env.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";
|
import { sanitizeActivityText } from "../../validators/presence.js";
|
||||||
|
|
||||||
export const PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS = 60_000;
|
export const PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS = 60_000;
|
||||||
@@ -53,11 +56,19 @@ const formatUptime = (totalSeconds: number): string => {
|
|||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildPresenceTemplateValues = (client: Client): Record<string, string> => {
|
const buildPresenceTemplateValues = (
|
||||||
|
client: Client,
|
||||||
|
): Record<string, string> => {
|
||||||
const guildCount = client.guilds.cache.size;
|
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 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 {
|
return {
|
||||||
bot_name: client.user?.username ?? "bot",
|
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 sanitizedTemplate = sanitizeActivityText(template);
|
||||||
const rendered = renderTemplate(sanitizedTemplate, buildPresenceTemplateValues(client), {
|
const rendered = renderTemplate(
|
||||||
aliases: PRESENCE_TEMPLATE_VARIABLE_ALIASES,
|
sanitizedTemplate,
|
||||||
keepUnknown: true,
|
buildPresenceTemplateValues(client),
|
||||||
});
|
{
|
||||||
|
aliases: PRESENCE_TEMPLATE_VARIABLE_ALIASES,
|
||||||
|
keepUnknown: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return sanitizeActivityText(rendered);
|
return sanitizeActivityText(rendered);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const containsPresenceTemplateVariables = (template: string): boolean =>
|
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 =>
|
export const getPresenceTemplateHelpText = (): string =>
|
||||||
PRESENCE_VISIBLE_TEMPLATE_VARIABLES.map((name) => `{{${name}}}`).join(", ");
|
PRESENCE_VISIBLE_TEMPLATE_VARIABLES.map((name) => `{{${name}}}`).join(", ");
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import type {
|
|||||||
TransportContext,
|
TransportContext,
|
||||||
TranslationVars,
|
TranslationVars,
|
||||||
} from "../types/command.js";
|
} 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 type { I18nService } from "../i18n/index.js";
|
||||||
import { createDiscordMemberPermissionsResolver } from "./discordPermissionResolver.js";
|
import { createDiscordMemberPermissionsResolver } from "./discordPermissionResolver.js";
|
||||||
|
|
||||||
@@ -14,7 +17,8 @@ export const createTranslator = (
|
|||||||
i18n: I18nService,
|
i18n: I18nService,
|
||||||
lang: SupportedLang,
|
lang: SupportedLang,
|
||||||
): ((key: string, vars?: TranslationVars) => string) => {
|
): ((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 = (
|
export const buildCommandExecutionContext = (
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
|
|
||||||
import type { BuildExecutionContextInput } from "../types/handlers.js";
|
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) {
|
if (!guild) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -30,13 +33,17 @@ const resolveFromInteractionMember = (
|
|||||||
|
|
||||||
const member = interaction.member;
|
const member = interaction.member;
|
||||||
if (member && typeof member === "object" && "permissions" in 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) {
|
if (rawPermissions !== undefined) {
|
||||||
try {
|
try {
|
||||||
const normalized = typeof rawPermissions === "string" || typeof rawPermissions === "number"
|
const normalized =
|
||||||
? BigInt(rawPermissions)
|
typeof rawPermissions === "string" ||
|
||||||
: rawPermissions;
|
typeof rawPermissions === "number"
|
||||||
|
? BigInt(rawPermissions)
|
||||||
|
: rawPermissions;
|
||||||
|
|
||||||
return new PermissionsBitField(normalized);
|
return new PermissionsBitField(normalized);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -72,7 +79,9 @@ export const createDiscordMemberPermissionsResolver = (
|
|||||||
return directFromMessage;
|
return directFromMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackFromGuildMember = message.guild?.members.resolve(message.author.id)?.permissions;
|
const fallbackFromGuildMember = message.guild?.members.resolve(
|
||||||
|
message.author.id,
|
||||||
|
)?.permissions;
|
||||||
if (fallbackFromGuildMember) {
|
if (fallbackFromGuildMember) {
|
||||||
return fallbackFromGuildMember;
|
return fallbackFromGuildMember;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,19 @@ const shouldThrottlePrefixPreParse = (userId: string): boolean => {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastSeenAt = prefixPreParseThrottle.get(userId);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
prefixPreParseThrottle.set(userId, now);
|
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()) {
|
for (const [trackedUserId, trackedAt] of prefixPreParseThrottle.entries()) {
|
||||||
if (now - trackedAt >= PREFIX_PRE_PARSE_THROTTLE_MAX_AGE_MS) {
|
if (now - trackedAt >= PREFIX_PRE_PARSE_THROTTLE_MAX_AGE_MS) {
|
||||||
prefixPreParseThrottle.delete(trackedUserId);
|
prefixPreParseThrottle.delete(trackedUserId);
|
||||||
@@ -49,9 +55,11 @@ const resolvePrefixLang = (
|
|||||||
fallbackLang: SupportedLang,
|
fallbackLang: SupportedLang,
|
||||||
guildPreferredLocale?: string | null,
|
guildPreferredLocale?: string | null,
|
||||||
): SupportedLang => {
|
): SupportedLang => {
|
||||||
|
|
||||||
const contextualLang = deps.i18n.resolveLang(guildPreferredLocale);
|
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) {
|
if (contextualTrigger === trigger) {
|
||||||
return contextualLang;
|
return contextualLang;
|
||||||
@@ -72,8 +80,11 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const firstSpaceIndex = content.indexOf(" ");
|
const firstSpaceIndex = content.indexOf(" ");
|
||||||
const trigger = (firstSpaceIndex === -1 ? content : content.slice(0, firstSpaceIndex)).toLowerCase();
|
const trigger = (
|
||||||
const rawArgs = firstSpaceIndex === -1 ? "" : content.slice(firstSpaceIndex + 1);
|
firstSpaceIndex === -1 ? content : content.slice(0, firstSpaceIndex)
|
||||||
|
).toLowerCase();
|
||||||
|
const rawArgs =
|
||||||
|
firstSpaceIndex === -1 ? "" : content.slice(firstSpaceIndex + 1);
|
||||||
|
|
||||||
const match = deps.registry.findByAnyPrefixTrigger(trigger);
|
const match = deps.registry.findByAnyPrefixTrigger(trigger);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -87,7 +98,13 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
|
|||||||
const reply = createPrefixReply(message);
|
const reply = createPrefixReply(message);
|
||||||
|
|
||||||
const command = match.command;
|
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 t = createTranslator(deps.i18n, lang);
|
||||||
|
|
||||||
const parsed = await parsePrefixArgs(message, command.args, rawArgs);
|
const parsed = await parsePrefixArgs(message, command.args, rawArgs);
|
||||||
@@ -97,7 +114,13 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
|
|||||||
return;
|
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 }));
|
await reply(t(firstError.key, { ...(firstError.vars ?? {}), usage }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ import type { PrefixReplyObject } from "../types/reply.js";
|
|||||||
|
|
||||||
const PREFIX_EPHEMERAL_DELETE_DELAY_MS = 10_000;
|
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: [],
|
parse: [],
|
||||||
repliedUser: false,
|
repliedUser: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable<InteractionReplyOptions["allowedMentions"]> = {
|
const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable<
|
||||||
|
InteractionReplyOptions["allowedMentions"]
|
||||||
|
> = {
|
||||||
parse: [],
|
parse: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,8 +31,14 @@ const FLAG_NAME_TO_BITS: Record<string, bigint> = {
|
|||||||
IsComponentsV2: 32768n,
|
IsComponentsV2: 32768n,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasBitfieldLike = (value: unknown): value is { bitfield: bigint | number } => {
|
const hasBitfieldLike = (
|
||||||
return Boolean(value) && typeof value === "object" && "bitfield" in (value as Record<string, unknown>);
|
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 => {
|
const toFlagBits = (flags: unknown): bigint | null => {
|
||||||
@@ -72,7 +82,9 @@ const hasEphemeralInFlags = (flags: unknown): boolean => {
|
|||||||
return bits !== null && (bits & EPHEMERAL_FLAG) !== 0n;
|
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);
|
const bits = toFlagBits(flags);
|
||||||
if (bits === null) {
|
if (bits === null) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -97,7 +109,9 @@ const scheduleDelete = (message: Message): void => {
|
|||||||
}, PREFIX_EPHEMERAL_DELETE_DELAY_MS);
|
}, PREFIX_EPHEMERAL_DELETE_DELAY_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
const withPrefixAllowedMentions = (options: MessageReplyOptions): MessageReplyOptions => {
|
const withPrefixAllowedMentions = (
|
||||||
|
options: MessageReplyOptions,
|
||||||
|
): MessageReplyOptions => {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
allowedMentions: {
|
allowedMentions: {
|
||||||
@@ -107,7 +121,9 @@ const withPrefixAllowedMentions = (options: MessageReplyOptions): MessageReplyOp
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const withSlashAllowedMentions = (options: InteractionReplyOptions): InteractionReplyOptions => {
|
const withSlashAllowedMentions = (
|
||||||
|
options: InteractionReplyOptions,
|
||||||
|
): InteractionReplyOptions => {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
allowedMentions: {
|
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 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.
|
// Drop interaction-only fields so prefix replies stay valid message payloads.
|
||||||
delete rest.fetchReply;
|
delete rest.fetchReply;
|
||||||
@@ -134,14 +154,18 @@ const toMessageReplyOptions = (payload: Exclude<ReplyPayload, string>): MessageR
|
|||||||
return rest as MessageReplyOptions;
|
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> => {
|
return async (payload: ReplyPayload): Promise<unknown> => {
|
||||||
if (typeof payload === "string") {
|
if (typeof payload === "string") {
|
||||||
return message.reply(withPrefixAllowedMentions({ content: payload }));
|
return message.reply(withPrefixAllowedMentions({ content: payload }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldDeleteAfterDelay = hasEphemeral(payload);
|
const shouldDeleteAfterDelay = hasEphemeral(payload);
|
||||||
const sent = await message.reply(withPrefixAllowedMentions(toMessageReplyOptions(payload)));
|
const sent = await message.reply(
|
||||||
|
withPrefixAllowedMentions(toMessageReplyOptions(payload)),
|
||||||
|
);
|
||||||
if (shouldDeleteAfterDelay) {
|
if (shouldDeleteAfterDelay) {
|
||||||
scheduleDelete(sent);
|
scheduleDelete(sent);
|
||||||
}
|
}
|
||||||
@@ -163,7 +187,9 @@ export const createSlashReply = (
|
|||||||
return interaction.reply(options);
|
return interaction.reply(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = withSlashAllowedMentions(payload as InteractionReplyOptions);
|
const options = withSlashAllowedMentions(
|
||||||
|
payload as InteractionReplyOptions,
|
||||||
|
);
|
||||||
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
if (interaction.replied || interaction.deferred) {
|
||||||
return interaction.followUp(options);
|
return interaction.followUp(options);
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export const createSlashHandler = (deps: SlashHandlerDeps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lang = deps.i18n.resolveLang(interaction.locale ?? interaction.guildLocale);
|
const lang = deps.i18n.resolveLang(
|
||||||
|
interaction.locale ?? interaction.guildLocale,
|
||||||
|
);
|
||||||
const reply = createSlashReply(interaction);
|
const reply = createSlashReply(interaction);
|
||||||
const t = createTranslator(deps.i18n, lang);
|
const t = createTranslator(deps.i18n, lang);
|
||||||
|
|
||||||
|
|||||||
+183
-160
@@ -2,206 +2,229 @@ import { existsSync, readFileSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
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";
|
import type { JsonObject } from "../types/i18n.js";
|
||||||
|
|
||||||
const DISCORD_LOCALE_MAP: Record<string, SupportedLang> = {
|
const DISCORD_LOCALE_MAP: Record<string, SupportedLang> = {
|
||||||
en: "en",
|
en: "en",
|
||||||
"en-us": "en",
|
"en-us": "en",
|
||||||
"en-gb": "en",
|
"en-gb": "en",
|
||||||
es: "es",
|
es: "es",
|
||||||
"es-es": "es",
|
"es-es": "es",
|
||||||
"es-419": "es",
|
"es-419": "es",
|
||||||
de: "de",
|
de: "de",
|
||||||
"de-de": "de",
|
"de-de": "de",
|
||||||
ja: "ja",
|
ja: "ja",
|
||||||
"ja-jp": "ja",
|
"ja-jp": "ja",
|
||||||
fr: "fr",
|
fr: "fr",
|
||||||
"fr-fr": "fr",
|
"fr-fr": "fr",
|
||||||
pt: "pt",
|
pt: "pt",
|
||||||
"pt-br": "pt",
|
"pt-br": "pt",
|
||||||
"pt-pt": "pt",
|
"pt-pt": "pt",
|
||||||
ru: "ru",
|
ru: "ru",
|
||||||
"ru-ru": "ru",
|
"ru-ru": "ru",
|
||||||
it: "it",
|
it: "it",
|
||||||
"it-it": "it",
|
"it-it": "it",
|
||||||
nl: "nl",
|
nl: "nl",
|
||||||
"nl-nl": "nl",
|
"nl-nl": "nl",
|
||||||
pl: "pl",
|
pl: "pl",
|
||||||
"pl-pl": "pl",
|
"pl-pl": "pl",
|
||||||
zh: "zh",
|
zh: "zh",
|
||||||
"zh-cn": "zh",
|
"zh-cn": "zh",
|
||||||
"zh-hans": "zh",
|
"zh-hans": "zh",
|
||||||
"zh-tw": "zh",
|
"zh-tw": "zh",
|
||||||
"zh-hant": "zh",
|
"zh-hant": "zh",
|
||||||
hi: "hi",
|
hi: "hi",
|
||||||
"hi-in": "hi",
|
"hi-in": "hi",
|
||||||
ar: "ar",
|
ar: "ar",
|
||||||
"ar-sa": "ar",
|
"ar-sa": "ar",
|
||||||
bn: "bn",
|
bn: "bn",
|
||||||
"bn-bd": "bn",
|
"bn-bd": "bn",
|
||||||
"bn-in": "bn",
|
"bn-in": "bn",
|
||||||
id: "id",
|
id: "id",
|
||||||
"id-id": "id",
|
"id-id": "id",
|
||||||
tr: "tr",
|
tr: "tr",
|
||||||
"tr-tr": "tr",
|
"tr-tr": "tr",
|
||||||
};
|
};
|
||||||
|
|
||||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const LOCALE_DIR_CANDIDATES = [
|
const LOCALE_DIR_CANDIDATES = [
|
||||||
path.resolve(CURRENT_DIR, "..", "..", "..", "locales"),
|
path.resolve(CURRENT_DIR, "..", "..", "..", "locales"),
|
||||||
path.resolve(process.cwd(), "apps", "bot", "locales"),
|
path.resolve(process.cwd(), "apps", "bot", "locales"),
|
||||||
path.resolve(process.cwd(), "locales"),
|
path.resolve(process.cwd(), "locales"),
|
||||||
CURRENT_DIR,
|
CURRENT_DIR,
|
||||||
path.resolve(process.cwd(), "src", "legacy", "i18n"),
|
path.resolve(process.cwd(), "src", "legacy", "i18n"),
|
||||||
path.resolve(process.cwd(), "dist", "legacy", "i18n"),
|
path.resolve(process.cwd(), "dist", "legacy", "i18n"),
|
||||||
];
|
];
|
||||||
|
|
||||||
const resolveLocaleFilePath = (lang: SupportedLang): string => {
|
const resolveLocaleFilePath = (lang: SupportedLang): string => {
|
||||||
for (const directory of LOCALE_DIR_CANDIDATES) {
|
for (const directory of LOCALE_DIR_CANDIDATES) {
|
||||||
const filePath = path.join(directory, `${lang}.json`);
|
const filePath = path.join(directory, `${lang}.json`);
|
||||||
if (existsSync(filePath)) {
|
if (existsSync(filePath)) {
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`[i18n] missing locale file for "${lang}"`);
|
throw new Error(`[i18n] missing locale file for "${lang}"`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export class I18nService {
|
export class I18nService {
|
||||||
private readonly dictionaries: Record<SupportedLang, JsonObject>;
|
private readonly dictionaries: Record<SupportedLang, JsonObject>;
|
||||||
private readonly fallbackLang: SupportedLang = "en";
|
private readonly fallbackLang: SupportedLang = "en";
|
||||||
|
|
||||||
public constructor(private readonly defaultLang: SupportedLang) {
|
public constructor(private readonly defaultLang: SupportedLang) {
|
||||||
this.dictionaries = this.loadDictionaries();
|
this.dictionaries = this.loadDictionaries();
|
||||||
}
|
}
|
||||||
|
|
||||||
public resolveLang(input?: string | null): SupportedLang {
|
public resolveLang(input?: string | null): SupportedLang {
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return this.fallbackLang;
|
return this.fallbackLang;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = input.toLowerCase();
|
const normalized = input.toLowerCase();
|
||||||
|
|
||||||
const direct = DISCORD_LOCALE_MAP[normalized];
|
const direct = DISCORD_LOCALE_MAP[normalized];
|
||||||
if (direct) {
|
if (direct) {
|
||||||
return direct;
|
return direct;
|
||||||
}
|
}
|
||||||
|
|
||||||
const short = normalized.split("-")[0];
|
const short = normalized.split("-")[0];
|
||||||
const fromShort = short ? DISCORD_LOCALE_MAP[short] : undefined;
|
const fromShort = short ? DISCORD_LOCALE_MAP[short] : undefined;
|
||||||
if (fromShort) {
|
if (fromShort) {
|
||||||
return fromShort;
|
return fromShort;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.fallbackLang;
|
return this.fallbackLang;
|
||||||
}
|
}
|
||||||
|
|
||||||
public t(lang: SupportedLang, key: string, vars: TranslationVars = {}): string {
|
public t(
|
||||||
const fromLang = this.lookup(this.dictionaries[lang], key);
|
lang: SupportedLang,
|
||||||
const fromFallback = this.lookup(this.dictionaries[this.fallbackLang], key);
|
key: string,
|
||||||
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
|
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 =
|
const template =
|
||||||
typeof fromLang === "string"
|
typeof fromLang === "string"
|
||||||
? fromLang
|
? fromLang
|
||||||
: typeof fromFallback === "string"
|
: typeof fromFallback === "string"
|
||||||
? fromFallback
|
? fromFallback
|
||||||
: typeof fromDefault === "string"
|
: typeof fromDefault === "string"
|
||||||
? fromDefault
|
? fromDefault
|
||||||
: key;
|
: key;
|
||||||
|
|
||||||
return this.format(template, vars);
|
return this.format(template, vars);
|
||||||
}
|
}
|
||||||
|
|
||||||
public commandT(lang: SupportedLang, commandName: string, relativeKey: string, vars: TranslationVars = {}): string {
|
public commandT(
|
||||||
return this.t(lang, `${this.commandBaseKey(commandName)}.${relativeKey}`, vars);
|
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 {
|
public commandName(lang: SupportedLang, commandName: string): string {
|
||||||
const key = `${this.commandBaseKey(commandName)}.name`;
|
const key = `${this.commandBaseKey(commandName)}.name`;
|
||||||
const fromLang = this.lookup(this.dictionaries[lang], key);
|
const fromLang = this.lookup(this.dictionaries[lang], key);
|
||||||
if (typeof fromLang === "string" && fromLang.length > 0) {
|
if (typeof fromLang === "string" && fromLang.length > 0) {
|
||||||
return fromLang;
|
return fromLang;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromFallback = this.lookup(this.dictionaries[this.fallbackLang], key);
|
const fromFallback = this.lookup(this.dictionaries[this.fallbackLang], key);
|
||||||
if (typeof fromFallback === "string" && fromFallback.length > 0) {
|
if (typeof fromFallback === "string" && fromFallback.length > 0) {
|
||||||
return fromFallback;
|
return fromFallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
|
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
|
||||||
if (typeof fromDefault === "string" && fromDefault.length > 0) {
|
if (typeof fromDefault === "string" && fromDefault.length > 0) {
|
||||||
return fromDefault;
|
return fromDefault;
|
||||||
}
|
}
|
||||||
|
|
||||||
return commandName;
|
return commandName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public commandTrigger(lang: SupportedLang, commandName: string): string {
|
public commandTrigger(lang: SupportedLang, commandName: string): string {
|
||||||
return this.commandName(lang, commandName).trim().toLowerCase();
|
return this.commandName(lang, commandName).trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
public commandObject(lang: SupportedLang, commandName: string): Record<string, unknown> {
|
public commandObject(
|
||||||
const key = this.commandBaseKey(commandName);
|
lang: SupportedLang,
|
||||||
const fromLang = this.lookup(this.dictionaries[lang], key);
|
commandName: string,
|
||||||
if (this.isObject(fromLang)) {
|
): Record<string, unknown> {
|
||||||
return fromLang;
|
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);
|
const fromFallback = this.lookup(this.dictionaries[this.fallbackLang], key);
|
||||||
if (this.isObject(fromFallback)) {
|
if (this.isObject(fromFallback)) {
|
||||||
return fromFallback;
|
return fromFallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
|
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
|
||||||
if (this.isObject(fromDefault)) {
|
if (this.isObject(fromDefault)) {
|
||||||
return fromDefault;
|
return fromDefault;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public format(template: string, vars: TranslationVars = {}): string {
|
public format(template: string, vars: TranslationVars = {}): string {
|
||||||
return this.interpolate(template, vars);
|
return this.interpolate(template, vars);
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadDictionaries(): Record<SupportedLang, JsonObject> {
|
private loadDictionaries(): Record<SupportedLang, JsonObject> {
|
||||||
return SUPPORTED_LANGS.reduce<Record<SupportedLang, JsonObject>>((acc, lang) => {
|
return SUPPORTED_LANGS.reduce<Record<SupportedLang, JsonObject>>(
|
||||||
const filePath = resolveLocaleFilePath(lang);
|
(acc, lang) => {
|
||||||
const raw = readFileSync(filePath, "utf-8");
|
const filePath = resolveLocaleFilePath(lang);
|
||||||
acc[lang] = JSON.parse(raw) as JsonObject;
|
const raw = readFileSync(filePath, "utf-8");
|
||||||
return acc;
|
acc[lang] = JSON.parse(raw) as JsonObject;
|
||||||
}, {} as Record<SupportedLang, JsonObject>);
|
return acc;
|
||||||
}
|
},
|
||||||
|
{} as Record<SupportedLang, JsonObject>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private lookup(source: JsonObject, key: string): unknown {
|
private lookup(source: JsonObject, key: string): unknown {
|
||||||
const parts = key.split(".");
|
const parts = key.split(".");
|
||||||
let current: unknown = source;
|
let current: unknown = source;
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
current = (current as JsonObject)[part];
|
current = (current as JsonObject)[part];
|
||||||
}
|
}
|
||||||
|
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
private commandBaseKey(commandName: string): string {
|
private commandBaseKey(commandName: string): string {
|
||||||
return `commands.${commandName}`;
|
return `commands.${commandName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isObject(value: unknown): value is Record<string, unknown> {
|
private isObject(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private interpolate(template: string, vars: TranslationVars): string {
|
private interpolate(template: string, vars: TranslationVars): string {
|
||||||
return template.replace(/\{\{(\w+)\}\}/g, (_, variable: string) => {
|
return template.replace(/\{\{(\w+)\}\}/g, (_, variable: string) => {
|
||||||
const value = vars[variable];
|
const value = vars[variable];
|
||||||
return value === undefined || value === null ? "" : String(value);
|
return value === undefined || value === null ? "" : String(value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import {
|
|||||||
resolvePrefixTrigger,
|
resolvePrefixTrigger,
|
||||||
resolveSlashName,
|
resolveSlashName,
|
||||||
} from "../../core/commands/usage.js";
|
} 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 categoryName = (command: BotCommand): string => command.meta.category;
|
||||||
|
|
||||||
const commandDescription = (ctx: CommandExecutionContext, command: BotCommand): string =>
|
const commandDescription = (
|
||||||
ctx.i18n.commandT(ctx.lang, command.meta.name, "description");
|
ctx: CommandExecutionContext,
|
||||||
|
command: BotCommand,
|
||||||
|
): string => ctx.i18n.commandT(ctx.lang, command.meta.name, "description");
|
||||||
|
|
||||||
export const resolveCommandFromQuery = (
|
export const resolveCommandFromQuery = (
|
||||||
ctx: CommandExecutionContext,
|
ctx: CommandExecutionContext,
|
||||||
@@ -41,12 +46,23 @@ export const createGlobalHelpEmbed = (
|
|||||||
grouped.get(key)?.push(command);
|
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()
|
const embed = new EmbedBuilder()
|
||||||
.setColor(0x2b6cb0)
|
.setColor(0x2b6cb0)
|
||||||
.setTitle(ctx.ct("embed.title"))
|
.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()) {
|
for (const [category, categoryCommands] of grouped.entries()) {
|
||||||
const lines = categoryCommands
|
const lines = categoryCommands
|
||||||
@@ -70,45 +86,85 @@ export const createCommandDetailsEmbed = (
|
|||||||
ctx: CommandExecutionContext,
|
ctx: CommandExecutionContext,
|
||||||
command: BotCommand,
|
command: BotCommand,
|
||||||
): EmbedBuilder => {
|
): 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 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 slashTrigger = resolveSlashName(command, ctx.lang, ctx.i18n);
|
||||||
const localizedCommandName = ctx.i18n.commandName(ctx.lang, command.meta.name);
|
const localizedCommandName = ctx.i18n.commandName(
|
||||||
|
ctx.lang,
|
||||||
|
command.meta.name,
|
||||||
|
);
|
||||||
|
|
||||||
const args = command.args.length === 0
|
const args =
|
||||||
? ctx.ct("labels.noArgs")
|
command.args.length === 0
|
||||||
: command.args
|
? ctx.ct("labels.noArgs")
|
||||||
.map((arg) => {
|
: command.args
|
||||||
const description = ctx.i18n.commandT(ctx.lang, command.meta.name, arg.descriptionKey);
|
.map((arg) => {
|
||||||
const requirement = arg.required ? ctx.ct("labels.required") : ctx.ct("labels.optional");
|
const description = ctx.i18n.commandT(
|
||||||
return `- ${arg.name} (${arg.type}, ${requirement}): ${description}`;
|
ctx.lang,
|
||||||
})
|
command.meta.name,
|
||||||
.join("\n");
|
arg.descriptionKey,
|
||||||
|
);
|
||||||
|
const requirement = arg.required
|
||||||
|
? ctx.ct("labels.required")
|
||||||
|
: ctx.ct("labels.optional");
|
||||||
|
return `- ${arg.name} (${arg.type}, ${requirement}): ${description}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
const examples = command.examples.length === 0
|
const examples =
|
||||||
? ctx.ct("labels.noExamples")
|
command.examples.length === 0
|
||||||
: command.examples
|
? ctx.ct("labels.noExamples")
|
||||||
.map((example) => {
|
: command.examples
|
||||||
const source = example.source ?? "prefix";
|
.map((example) => {
|
||||||
const baseUsage = source === "slash"
|
const source = example.source ?? "prefix";
|
||||||
? buildSlashUsage(command, ctx.lang, ctx.i18n)
|
const baseUsage =
|
||||||
: buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n);
|
source === "slash"
|
||||||
|
? buildSlashUsage(command, ctx.lang, ctx.i18n)
|
||||||
|
: buildPrefixUsage(
|
||||||
|
command,
|
||||||
|
ctx.prefix,
|
||||||
|
ctx.lang,
|
||||||
|
ctx.defaultLang,
|
||||||
|
ctx.i18n,
|
||||||
|
);
|
||||||
|
|
||||||
const baseCommand = source === "slash"
|
const baseCommand =
|
||||||
? `/${slashTrigger}`
|
source === "slash"
|
||||||
: `${ctx.prefix}${prefixTrigger}`;
|
? `/${slashTrigger}`
|
||||||
|
: `${ctx.prefix}${prefixTrigger}`;
|
||||||
|
|
||||||
const input = example.args ? `${baseCommand} ${example.args}` : baseUsage;
|
const input = example.args
|
||||||
const description = ctx.i18n.commandT(ctx.lang, command.meta.name, example.descriptionKey);
|
? `${baseCommand} ${example.args}`
|
||||||
return `- ${input}: ${description}`;
|
: baseUsage;
|
||||||
})
|
const description = ctx.i18n.commandT(
|
||||||
.join("\n");
|
ctx.lang,
|
||||||
|
command.meta.name,
|
||||||
|
example.descriptionKey,
|
||||||
|
);
|
||||||
|
return `- ${input}: ${description}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
return new EmbedBuilder()
|
return new EmbedBuilder()
|
||||||
.setColor(0x2f855a)
|
.setColor(0x2f855a)
|
||||||
.setTitle(ctx.ct("embed.detailsTitle", { name: localizedCommandName }))
|
.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(
|
.addFields(
|
||||||
{
|
{
|
||||||
name: ctx.ct("embed.fields.usage"),
|
name: ctx.ct("embed.fields.usage"),
|
||||||
@@ -123,5 +179,7 @@ export const createCommandDetailsEmbed = (
|
|||||||
value: examples,
|
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 SupportedLang = (typeof SUPPORTED_LANGS)[number];
|
||||||
export type CommandSource = "prefix" | "slash";
|
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 TranslationVars = Record<
|
||||||
export type ReplyPayload = string | MessageCreateOptions | MessageReplyOptions | InteractionReplyOptions;
|
string,
|
||||||
|
string | number | boolean | null | undefined
|
||||||
|
>;
|
||||||
|
export type ReplyPayload =
|
||||||
|
| string
|
||||||
|
| MessageCreateOptions
|
||||||
|
| MessageReplyOptions
|
||||||
|
| InteractionReplyOptions;
|
||||||
|
|
||||||
export type CommandArgValue =
|
export type CommandArgValue =
|
||||||
| string
|
| string
|
||||||
@@ -85,8 +99,16 @@ export interface CommandRegistryReader {
|
|||||||
export interface CommandI18nTools {
|
export interface CommandI18nTools {
|
||||||
commandName: (lang: SupportedLang, commandName: string) => string;
|
commandName: (lang: SupportedLang, commandName: string) => string;
|
||||||
commandTrigger: (lang: SupportedLang, commandName: string) => string;
|
commandTrigger: (lang: SupportedLang, commandName: string) => string;
|
||||||
commandT: (lang: SupportedLang, commandName: string, relativeKey: string, vars?: TranslationVars) => string;
|
commandT: (
|
||||||
commandObject: (lang: SupportedLang, commandName: string) => Record<string, unknown>;
|
lang: SupportedLang,
|
||||||
|
commandName: string,
|
||||||
|
relativeKey: string,
|
||||||
|
vars?: TranslationVars,
|
||||||
|
) => string;
|
||||||
|
commandObject: (
|
||||||
|
lang: SupportedLang,
|
||||||
|
commandName: string,
|
||||||
|
) => Record<string, unknown>;
|
||||||
format: (template: string, vars?: TranslationVars) => string;
|
format: (template: string, vars?: TranslationVars) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import type {
|
|||||||
import type { I18nService } from "../i18n/index.js";
|
import type { I18nService } from "../i18n/index.js";
|
||||||
|
|
||||||
export type MemberMessageKind = "welcome" | "goodbye";
|
export type MemberMessageKind = "welcome" | "goodbye";
|
||||||
export type MemberMessageRenderType = "simple" | "embed" | "container" | "image";
|
export type MemberMessageRenderType =
|
||||||
|
| "simple"
|
||||||
|
| "embed"
|
||||||
|
| "container"
|
||||||
|
| "image";
|
||||||
|
|
||||||
export interface MemberMessageConfig {
|
export interface MemberMessageConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import type { Message } from "discord.js";
|
import type { Message } from "discord.js";
|
||||||
|
|
||||||
export type PresenceStatusValue = "online" | "idle" | "dnd" | "invisible" | "streaming";
|
export type PresenceStatusValue =
|
||||||
export type PresenceActivityTypeValue = "PLAYING" | "STREAMING" | "WATCHING" | "LISTENING" | "COMPETING" | "CUSTOM";
|
| "online"
|
||||||
|
| "idle"
|
||||||
|
| "dnd"
|
||||||
|
| "invisible"
|
||||||
|
| "streaming";
|
||||||
|
export type PresenceActivityTypeValue =
|
||||||
|
| "PLAYING"
|
||||||
|
| "STREAMING"
|
||||||
|
| "WATCHING"
|
||||||
|
| "LISTENING"
|
||||||
|
| "COMPETING"
|
||||||
|
| "CUSTOM";
|
||||||
|
|
||||||
export interface PresenceActivityState {
|
export interface PresenceActivityState {
|
||||||
type: PresenceActivityTypeValue;
|
type: PresenceActivityTypeValue;
|
||||||
|
|||||||
@@ -2,20 +2,29 @@ import type { TemplateRenderOptions } from "../types/templateVariables.js";
|
|||||||
|
|
||||||
const createTemplateTokenRegex = (): RegExp => /\{\{([a-zA-Z0-9_]+)\}\}/g;
|
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]) => {
|
Object.entries(aliases).reduce<Record<string, string>>((acc, [from, to]) => {
|
||||||
acc[normalizeVariableName(from)] = normalizeVariableName(to);
|
acc[normalizeVariableName(from)] = normalizeVariableName(to);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const resolveVariableName = (rawName: string, aliases: Record<string, string>): string => {
|
const resolveVariableName = (
|
||||||
|
rawName: string,
|
||||||
|
aliases: Record<string, string>,
|
||||||
|
): string => {
|
||||||
const normalized = normalizeVariableName(rawName);
|
const normalized = normalizeVariableName(rawName);
|
||||||
return aliases[normalized] ?? normalized;
|
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 normalizedAliases = normalizeAliases(aliases);
|
||||||
const found = new Set<string>();
|
const found = new Set<string>();
|
||||||
|
|
||||||
@@ -63,7 +72,9 @@ export const renderTemplate = (
|
|||||||
): string => {
|
): string => {
|
||||||
const normalizedAliases = normalizeAliases(options.aliases);
|
const normalizedAliases = normalizeAliases(options.aliases);
|
||||||
const keepUnknown = options.keepUnknown ?? true;
|
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;
|
acc[normalizeVariableName(key)] = value;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export const createDefaultLogEventState = (): LogEventStateByKey => {
|
|||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cloneLogEventState = (state: LogEventStateByKey): LogEventStateByKey => {
|
export const cloneLogEventState = (
|
||||||
|
state: LogEventStateByKey,
|
||||||
|
): LogEventStateByKey => {
|
||||||
const next = {} as LogEventStateByKey;
|
const next = {} as LogEventStateByKey;
|
||||||
|
|
||||||
for (const eventKey of LOG_EVENT_KEYS) {
|
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);
|
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();
|
const state = createDefaultLogEventState();
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|||||||
@@ -6,13 +6,24 @@ import type {
|
|||||||
|
|
||||||
const DISCORD_SNOWFLAKE_REGEX = /^\d{17,20}$/;
|
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>();
|
const uniqueRoleIds = new Set<string>();
|
||||||
|
|
||||||
for (const rawRoleId of roleIds) {
|
for (const rawRoleId of roleIds) {
|
||||||
@@ -34,10 +45,14 @@ export const createDefaultMemberMessageConfig = (): MemberMessageConfig => ({
|
|||||||
autoRoleIds: [],
|
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);
|
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);
|
return MEMBER_MESSAGE_RENDER_TYPES.includes(value as MemberMessageRenderType);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import type {
|
|||||||
PresenceStatusValue,
|
PresenceStatusValue,
|
||||||
} from "../types/presence.js";
|
} 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[] = [
|
export const PRESENCE_ACTIVITY_TYPES: readonly PresenceActivityTypeValue[] = [
|
||||||
"PLAYING",
|
"PLAYING",
|
||||||
"STREAMING",
|
"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);
|
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);
|
PRESENCE_ACTIVITY_TYPES.includes(value as PresenceActivityTypeValue);
|
||||||
|
|
||||||
export const sanitizeActivityText = (value: string): string => {
|
export const sanitizeActivityText = (value: string): string => {
|
||||||
@@ -59,7 +69,9 @@ export const sanitizeActivityTexts = (values: readonly string[]): string[] => {
|
|||||||
return cleaned;
|
return cleaned;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sanitizePresenceRotationIntervalSeconds = (value: number): number => {
|
export const sanitizePresenceRotationIntervalSeconds = (
|
||||||
|
value: number,
|
||||||
|
): number => {
|
||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
return DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS;
|
return DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS;
|
||||||
}
|
}
|
||||||
@@ -76,10 +88,12 @@ export const sanitizePresenceRotationIntervalSeconds = (value: number): number =
|
|||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isPresenceRotationIntervalSecondsValue = (value: number): boolean =>
|
export const isPresenceRotationIntervalSecondsValue = (
|
||||||
Number.isInteger(value)
|
value: number,
|
||||||
&& value >= MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS
|
): boolean =>
|
||||||
&& value <= MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS;
|
Number.isInteger(value) &&
|
||||||
|
value >= MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS &&
|
||||||
|
value <= MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS;
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
@@ -92,12 +106,19 @@ export const parsePresenceState = (value: unknown): PresenceState | null => {
|
|||||||
const statusValue = value.status;
|
const statusValue = value.status;
|
||||||
const activityValue = value.activity;
|
const activityValue = value.activity;
|
||||||
|
|
||||||
if (typeof statusValue !== "string" || !isPresenceStatusValue(statusValue) || !isRecord(activityValue)) {
|
if (
|
||||||
|
typeof statusValue !== "string" ||
|
||||||
|
!isPresenceStatusValue(statusValue) ||
|
||||||
|
!isRecord(activityValue)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityType = activityValue.type;
|
const activityType = activityValue.type;
|
||||||
if (typeof activityType !== "string" || !isPresenceActivityTypeValue(activityType)) {
|
if (
|
||||||
|
typeof activityType !== "string" ||
|
||||||
|
!isPresenceActivityTypeValue(activityType)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +127,11 @@ export const parsePresenceState = (value: unknown): PresenceState | null => {
|
|||||||
const intervalValue = activityValue.rotationIntervalSeconds;
|
const intervalValue = activityValue.rotationIntervalSeconds;
|
||||||
|
|
||||||
const activityTexts = Array.isArray(activityTextsValue)
|
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"
|
: typeof legacyActivityTextValue === "string"
|
||||||
? sanitizeActivityTexts([legacyActivityTextValue])
|
? sanitizeActivityTexts([legacyActivityTextValue])
|
||||||
: null;
|
: null;
|
||||||
@@ -115,9 +140,10 @@ export const parsePresenceState = (value: unknown): PresenceState | null => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rotationIntervalSeconds = typeof intervalValue === "number"
|
const rotationIntervalSeconds =
|
||||||
? sanitizePresenceRotationIntervalSeconds(intervalValue)
|
typeof intervalValue === "number"
|
||||||
: DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS;
|
? sanitizePresenceRotationIntervalSeconds(intervalValue)
|
||||||
|
: DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: statusValue,
|
status: statusValue,
|
||||||
|
|||||||
@@ -54,20 +54,23 @@ export class BotManager {
|
|||||||
const persistedBots = await listBotsToRestore(this.pool);
|
const persistedBots = await listBotsToRestore(this.pool);
|
||||||
|
|
||||||
for (const bot of persistedBots) {
|
for (const bot of persistedBots) {
|
||||||
await this.startBot(bot.id, bot.tenantId).catch(async (error: unknown) => {
|
await this.startBot(bot.id, bot.tenantId).catch(
|
||||||
const message = error instanceof Error ? error.message : "Unknown startup error";
|
async (error: unknown) => {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Unknown startup error";
|
||||||
|
|
||||||
await setBotStatusById(this.pool, bot.id, "error", message);
|
await setBotStatusById(this.pool, bot.id, "error", message);
|
||||||
await insertRuntimeEvent(this.pool, {
|
await insertRuntimeEvent(this.pool, {
|
||||||
tenantId: bot.tenantId,
|
tenantId: bot.tenantId,
|
||||||
botId: bot.id,
|
botId: bot.id,
|
||||||
level: "error",
|
level: "error",
|
||||||
message: "Bot failed to restore at startup",
|
message: "Bot failed to restore at startup",
|
||||||
metadata: {
|
metadata: {
|
||||||
error: message,
|
error: message,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +159,8 @@ export class BotManager {
|
|||||||
client.destroy();
|
client.destroy();
|
||||||
this.runningBots.delete(botId);
|
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 setBotStatusById(this.pool, botId, "error", message);
|
||||||
await insertRuntimeEvent(this.pool, {
|
await insertRuntimeEvent(this.pool, {
|
||||||
tenantId: bot.tenantId,
|
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 previous = this.operationLocks.get(botId) ?? Promise.resolve();
|
||||||
|
|
||||||
const next = previous
|
const next = previous
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
|
|
||||||
import { BotManager } from "../manager/BotManager.js";
|
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>(
|
return new Worker<BotControlJob>(
|
||||||
BOT_CONTROL_QUEUE,
|
BOT_CONTROL_QUEUE,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
|
|||||||
@@ -6,12 +6,8 @@ import { createCommandList } from "../legacy/commands/index.js";
|
|||||||
import { env } from "../legacy/config/env.js";
|
import { env } from "../legacy/config/env.js";
|
||||||
import { CommandRegistry } from "../legacy/core/commands/registry.js";
|
import { CommandRegistry } from "../legacy/core/commands/registry.js";
|
||||||
import { LocalCommandDispatchPort } from "../legacy/core/execution/dispatch.js";
|
import { LocalCommandDispatchPort } from "../legacy/core/execution/dispatch.js";
|
||||||
import {
|
import { MemoryCooldownStore } from "../legacy/core/execution/cooldownStore.js";
|
||||||
MemoryCooldownStore,
|
import { MemoryGlobalRateLimitStore } from "../legacy/core/execution/globalRateLimitStore.js";
|
||||||
} from "../legacy/core/execution/cooldownStore.js";
|
|
||||||
import {
|
|
||||||
MemoryGlobalRateLimitStore,
|
|
||||||
} from "../legacy/core/execution/globalRateLimitStore.js";
|
|
||||||
import { CommandExecutor } from "../legacy/core/execution/CommandExecutor.js";
|
import { CommandExecutor } from "../legacy/core/execution/CommandExecutor.js";
|
||||||
import { createScopedLogger } from "../legacy/core/logging/logger.js";
|
import { createScopedLogger } from "../legacy/core/logging/logger.js";
|
||||||
import {
|
import {
|
||||||
@@ -23,15 +19,9 @@ import { registerEvents } from "../legacy/events/index.js";
|
|||||||
import { createPrefixHandler } from "../legacy/handlers/prefixHandler.js";
|
import { createPrefixHandler } from "../legacy/handlers/prefixHandler.js";
|
||||||
import { createSlashHandler } from "../legacy/handlers/slashHandler.js";
|
import { createSlashHandler } from "../legacy/handlers/slashHandler.js";
|
||||||
import { I18nService } from "../legacy/i18n/index.js";
|
import { I18nService } from "../legacy/i18n/index.js";
|
||||||
import {
|
import { LogEventService } from "../legacy/modules/logs/index.js";
|
||||||
LogEventService,
|
import { MemberMessageService } from "../legacy/modules/memberMessages/index.js";
|
||||||
} from "../legacy/modules/logs/index.js";
|
import { PresenceService } from "../legacy/modules/presence/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 { TenantLogEventStore } from "./stores/TenantLogEventStore.js";
|
||||||
import { TenantMemberMessageStore } from "./stores/TenantMemberMessageStore.js";
|
import { TenantMemberMessageStore } from "./stores/TenantMemberMessageStore.js";
|
||||||
import { TenantPresenceStore } from "./stores/TenantPresenceStore.js";
|
import { TenantPresenceStore } from "./stores/TenantPresenceStore.js";
|
||||||
@@ -54,9 +44,21 @@ export interface LegacyBotRuntime {
|
|||||||
export const initializeLegacyBotRuntime = async (
|
export const initializeLegacyBotRuntime = async (
|
||||||
input: InitializeLegacyBotRuntimeInput,
|
input: InitializeLegacyBotRuntimeInput,
|
||||||
): Promise<LegacyBotRuntime> => {
|
): Promise<LegacyBotRuntime> => {
|
||||||
const presenceStore = new TenantPresenceStore(input.pool, input.tenantId, input.ownerUserId);
|
const presenceStore = new TenantPresenceStore(
|
||||||
const memberMessageStore = new TenantMemberMessageStore(input.pool, input.tenantId, input.ownerUserId);
|
input.pool,
|
||||||
const logEventStore = new TenantLogEventStore(input.pool, input.tenantId, input.ownerUserId);
|
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(
|
const dbLifecycle = new DatabaseLifecycle(
|
||||||
[
|
[
|
||||||
@@ -70,7 +72,10 @@ export const initializeLegacyBotRuntime = async (
|
|||||||
await dbLifecycle.init();
|
await dbLifecycle.init();
|
||||||
|
|
||||||
const services: AppFeatureServices = {
|
const services: AppFeatureServices = {
|
||||||
presenceService: new PresenceService(presenceStore, env.PRESENCE_STREAM_URL),
|
presenceService: new PresenceService(
|
||||||
|
presenceStore,
|
||||||
|
env.PRESENCE_STREAM_URL,
|
||||||
|
),
|
||||||
memberMessageService: new MemberMessageService(memberMessageStore),
|
memberMessageService: new MemberMessageService(memberMessageStore),
|
||||||
logEventService: new LogEventService(logEventStore),
|
logEventService: new LogEventService(logEventStore),
|
||||||
};
|
};
|
||||||
@@ -137,15 +142,36 @@ export const initializeLegacyBotRuntime = async (
|
|||||||
return {
|
return {
|
||||||
shutdown: async () => {
|
shutdown: async () => {
|
||||||
await services.logEventService.shutdown().catch((error) => {
|
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) => {
|
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) => {
|
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);
|
await this.pool.query(logEventSchemaProbeSql);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new 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 },
|
{ 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>(
|
const result = await this.pool.query<LogEventRow>(
|
||||||
`
|
`
|
||||||
SELECT event_key, enabled, channel_id
|
SELECT event_key, enabled, channel_id
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ const parseRoleIds = (serialized: string | null): string[] => {
|
|||||||
return [];
|
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);
|
return sanitizeMemberMessageRoleIds(roleIds);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
@@ -52,7 +54,9 @@ const toConfig = (row: MemberMessageRow): MemberMessageConfig => {
|
|||||||
return {
|
return {
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
channelId: row.channel_id,
|
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),
|
autoRoleIds: parseRoleIds(row.auto_role_ids),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -69,13 +73,17 @@ export class TenantMemberMessageStore implements MemberMessageRepository {
|
|||||||
await this.pool.query(memberMessageSchemaProbeSql);
|
await this.pool.query(memberMessageSchemaProbeSql);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new 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 },
|
{ 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>(
|
const result = await this.pool.query<MemberMessageRow>(
|
||||||
`
|
`
|
||||||
SELECT enabled, channel_id, message_type, auto_role_ids
|
SELECT enabled, channel_id, message_type, auto_role_ids
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ FROM bot_presence_states
|
|||||||
LIMIT 0;
|
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) {
|
if (typeof rawTexts === "string" && rawTexts.trim().length > 0) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(rawTexts) as unknown;
|
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 => {
|
const toPresenceState = (row: PresenceRow): PresenceState | null => {
|
||||||
if (!isPresenceStatusValue(row.status) || !isPresenceActivityTypeValue(row.activity_type)) {
|
if (
|
||||||
|
!isPresenceStatusValue(row.status) ||
|
||||||
|
!isPresenceActivityTypeValue(row.activity_type)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +93,7 @@ export class TenantPresenceStore implements PresenceRepository {
|
|||||||
await this.pool.query(presenceSchemaProbeSql);
|
await this.pool.query(presenceSchemaProbeSql);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new 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 },
|
{ cause: error },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,10 +118,16 @@ export class TenantPresenceStore implements PresenceRepository {
|
|||||||
return toPresenceState(row) ?? createDefaultPresenceState();
|
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 activityTexts = sanitizeActivityTexts(state.activity.texts);
|
||||||
const primaryText = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
|
const primaryText =
|
||||||
const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
|
activityTexts[0] ?? sanitizeActivityText(state.activity.text);
|
||||||
|
const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(
|
||||||
|
state.activity.rotationIntervalSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
await this.pool.query(
|
await this.pool.query(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"composite": true,
|
"composite": true,
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"types": ["node"]
|
"types": ["node"],
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { DashboardClient } from "../../../components/dashboard-client";
|
import { DashboardClient } from "../../../components/dashboard-client";
|
||||||
import PageWrapper from "../../../components/ui/PageWrapper";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|||||||
@@ -137,15 +137,15 @@ export default async function LocaleLayout({
|
|||||||
|
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
const direction = RTL_LOCALES.includes(
|
const direction = RTL_LOCALES.includes(locale as (typeof RTL_LOCALES)[number])
|
||||||
locale as (typeof RTL_LOCALES)[number],
|
|
||||||
)
|
|
||||||
? "rtl"
|
? "rtl"
|
||||||
: "ltr";
|
: "ltr";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} dir={direction}>
|
<html lang={locale} dir={direction}>
|
||||||
<body className={`${headingFont.variable} ${monoFont.variable} antialiased`}>
|
<body
|
||||||
|
className={`${headingFont.variable} ${monoFont.variable} antialiased`}
|
||||||
|
>
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
{children}
|
{children}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { getT } from "../../../i18n/server";
|
import { getT } from "../../../i18n/server";
|
||||||
import { Badge } from "../../../components/ui/Badge";
|
import { Badge } from "../../../components/ui/Badge";
|
||||||
import { buttonClassName } from "../../../components/ui/Button";
|
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";
|
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() {
|
export default async function LoginPage() {
|
||||||
const t = await getT();
|
const t = await getT();
|
||||||
|
|||||||
@@ -72,9 +72,21 @@ a {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 18% 10%, color-mix(in srgb, var(--primary) 28%, transparent), transparent 44%),
|
radial-gradient(
|
||||||
radial-gradient(circle at 82% 0%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 36%),
|
circle at 18% 10%,
|
||||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-elevated) 86%, var(--bg)), var(--bg));
|
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 {
|
.ui-background::before {
|
||||||
@@ -82,8 +94,16 @@ a {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(to right, color-mix(in srgb, var(--foreground-muted) 10%, transparent) 1px, transparent 1px),
|
linear-gradient(
|
||||||
linear-gradient(to bottom, color-mix(in srgb, var(--foreground-muted) 10%, transparent) 1px, transparent 1px);
|
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;
|
background-size: 42px 42px;
|
||||||
mask-image: radial-gradient(circle at 55% 42%, black, transparent 78%);
|
mask-image: radial-gradient(circle at 55% 42%, black, transparent 78%);
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ export default function FeatureCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-lg font-semibold text-[var(--foreground)]">{title}</h3>
|
<h3 className="text-lg font-semibold text-[var(--foreground)]">
|
||||||
<p className="text-sm text-[var(--foreground-muted)]">{description}</p>
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--foreground-muted)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -9,9 +9,27 @@ export default async function Features() {
|
|||||||
title: t("features.items.autoCommands.title"),
|
title: t("features.items.autoCommands.title"),
|
||||||
description: t("features.items.autoCommands.description"),
|
description: t("features.items.autoCommands.description"),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<path d="M4 7h16v10H4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
width="20"
|
||||||
<path d="M8 11h0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
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>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -19,9 +37,27 @@ export default async function Features() {
|
|||||||
title: t("features.items.componentsV2.title"),
|
title: t("features.items.componentsV2.title"),
|
||||||
description: t("features.items.componentsV2.description"),
|
description: t("features.items.componentsV2.description"),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<path d="M12 3v18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
width="20"
|
||||||
<path d="M5 8h14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
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>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -29,9 +65,27 @@ export default async function Features() {
|
|||||||
title: t("features.items.autoHelp.title"),
|
title: t("features.items.autoHelp.title"),
|
||||||
description: t("features.items.autoHelp.description"),
|
description: t("features.items.autoHelp.description"),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<path d="M3 12h18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
width="20"
|
||||||
<path d="M6 8v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
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>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -39,9 +93,27 @@ export default async function Features() {
|
|||||||
title: t("features.items.multiLanguage.title"),
|
title: t("features.items.multiLanguage.title"),
|
||||||
description: t("features.items.multiLanguage.description"),
|
description: t("features.items.multiLanguage.description"),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<path d="M4 7h16v10H4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
width="20"
|
||||||
<path d="M8 11h0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
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>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -49,9 +121,27 @@ export default async function Features() {
|
|||||||
title: t("features.items.dynamicVariables.title"),
|
title: t("features.items.dynamicVariables.title"),
|
||||||
description: t("features.items.dynamicVariables.description"),
|
description: t("features.items.dynamicVariables.description"),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<path d="M12 3v18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
width="20"
|
||||||
<path d="M5 8h14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
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>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -59,9 +149,27 @@ export default async function Features() {
|
|||||||
title: t("features.items.hosting.title"),
|
title: t("features.items.hosting.title"),
|
||||||
description: t("features.items.hosting.description"),
|
description: t("features.items.hosting.description"),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<path d="M3 12h18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
width="20"
|
||||||
<path d="M6 8v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
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>
|
</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">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{items.map((it, idx) => (
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { getT } from "../i18n/server";
|
|||||||
|
|
||||||
import { Badge } from "./ui/Badge";
|
import { Badge } from "./ui/Badge";
|
||||||
import { buttonClassName } from "./ui/Button";
|
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() {
|
export default async function Hero() {
|
||||||
const t = await getT();
|
const t = await getT();
|
||||||
@@ -24,7 +30,10 @@ export default async function Hero() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<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")}
|
{t("hero.ctaStart")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -53,14 +62,18 @@ export default async function Hero() {
|
|||||||
key={bot.name}
|
key={bot.name}
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<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)]">
|
<p className="text-xs text-[var(--foreground-muted)]">
|
||||||
{t("hero.card.instanceMetrics")}
|
{t("hero.card.instanceMetrics")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={bot.status === "online" ? "success" : "neutral"}>
|
<Badge
|
||||||
|
variant={bot.status === "online" ? "success" : "neutral"}
|
||||||
|
>
|
||||||
{bot.status === "online"
|
{bot.status === "online"
|
||||||
? t("hero.status.online")
|
? t("hero.status.online")
|
||||||
: t("hero.status.stopped")}
|
: t("hero.status.stopped")}
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ export default async function HowItWorks() {
|
|||||||
{`${i + 1}`}
|
{`${i + 1}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-lg font-semibold text-[var(--foreground)]">{s.title}</h3>
|
<h3 className="text-lg font-semibold text-[var(--foreground)]">
|
||||||
<p className="text-sm text-[var(--foreground-muted)]">{s.desc}</p>
|
{s.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--foreground-muted)]">
|
||||||
|
{s.desc}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { type FormEvent, useCallback, useEffect, useState } from "react";
|
|||||||
import { useT } from "../i18n/client";
|
import { useT } from "../i18n/client";
|
||||||
import { Badge } from "./ui/Badge";
|
import { Badge } from "./ui/Badge";
|
||||||
import { Button, buttonClassName } from "./ui/Button";
|
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";
|
import { Input } from "./ui/Input";
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
@@ -105,7 +111,10 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
|
|||||||
const botsJson = await botsResponse.json();
|
const botsJson = await botsResponse.json();
|
||||||
setBots((botsJson.bots ?? []) as Bot[]);
|
setBots((botsJson.bots ?? []) as Bot[]);
|
||||||
} catch (cause) {
|
} 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);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -153,7 +162,10 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
|
|||||||
setDisplayName("");
|
setDisplayName("");
|
||||||
await refreshData();
|
await refreshData();
|
||||||
} catch (cause) {
|
} 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);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -164,19 +176,28 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiBaseUrl}/api/bots/${botId}/${action}`, {
|
const response = await fetch(
|
||||||
method: "POST",
|
`${apiBaseUrl}/api/bots/${botId}/${action}`,
|
||||||
credentials: "include",
|
{
|
||||||
});
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorJson = await response.json().catch(() => ({}));
|
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();
|
await refreshData();
|
||||||
} catch (cause) {
|
} 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);
|
setError(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -185,7 +206,9 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
|
|||||||
return (
|
return (
|
||||||
<Card className="max-w-xl">
|
<Card className="max-w-xl">
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -223,7 +246,9 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<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)]">
|
<h1 className="text-3xl font-semibold tracking-tight text-[var(--foreground)]">
|
||||||
{user.username}
|
{user.username}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -239,7 +264,9 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
|
|||||||
{error ? (
|
{error ? (
|
||||||
<Card className="border-[color:color-mix(in_srgb,var(--danger)_35%,transparent)] bg-[color:color-mix(in_srgb,var(--danger)_10%,var(--surface))]">
|
<Card className="border-[color:color-mix(in_srgb,var(--danger)_35%,transparent)] bg-[color:color-mix(in_srgb,var(--danger)_10%,var(--surface))]">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm font-semibold text-[var(--danger)]">{error}</p>
|
<p className="text-sm font-semibold text-[var(--danger)]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -249,7 +276,9 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
|
|||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
<CardHeader className="space-y-2">
|
<CardHeader className="space-y-2">
|
||||||
<CardTitle>{t("dashboard.addBot.title")}</CardTitle>
|
<CardTitle>{t("dashboard.addBot.title")}</CardTitle>
|
||||||
<CardDescription>{t("dashboard.addBot.description")}</CardDescription>
|
<CardDescription>
|
||||||
|
{t("dashboard.addBot.description")}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -280,7 +309,12 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<Button disabled={submitting} fullWidth type="submit" variant="primary">
|
<Button
|
||||||
|
disabled={submitting}
|
||||||
|
fullWidth
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
{submitting
|
{submitting
|
||||||
? t("dashboard.addBot.submitPending")
|
? t("dashboard.addBot.submitPending")
|
||||||
: t("dashboard.addBot.submit")}
|
: t("dashboard.addBot.submit")}
|
||||||
@@ -294,7 +328,11 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
|
|||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
<CardHeader className="mb-4 flex flex-row flex-wrap items-center justify-between gap-3 space-y-0">
|
<CardHeader className="mb-4 flex flex-row flex-wrap items-center justify-between gap-3 space-y-0">
|
||||||
<CardTitle>{t("dashboard.bots.title")}</CardTitle>
|
<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")}
|
{t("dashboard.bots.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -330,7 +368,9 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
|
|||||||
|
|
||||||
{bot.lastError ? (
|
{bot.lastError ? (
|
||||||
<p className="text-sm font-medium text-[var(--danger)]">
|
<p className="text-sm font-medium text-[var(--danger)]">
|
||||||
{t("dashboard.bots.lastError", { message: bot.lastError })}
|
{t("dashboard.bots.lastError", {
|
||||||
|
message: bot.lastError,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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} />;
|
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 (
|
return (
|
||||||
<h2
|
<h2
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -36,12 +42,18 @@ export function CardDescription({
|
|||||||
}: HTMLAttributes<HTMLParagraphElement>) {
|
}: HTMLAttributes<HTMLParagraphElement>) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
className={cn("text-sm leading-relaxed text-[var(--foreground-muted)]", className)}
|
className={cn(
|
||||||
|
"text-sm leading-relaxed text-[var(--foreground-muted)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
export function CardContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn("space-y-4", className)} {...props} />;
|
return <div className={cn("space-y-4", className)} {...props} />;
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,16 @@ import type { HTMLAttributes } from "react";
|
|||||||
|
|
||||||
import { cn } from "./cn";
|
import { cn } from "./cn";
|
||||||
|
|
||||||
export function Container({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
export function Container({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ type NavbarProps = {
|
|||||||
export default async function Navbar({ currentPath }: NavbarProps) {
|
export default async function Navbar({ currentPath }: NavbarProps) {
|
||||||
const t = await getT();
|
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: "/", key: "home", label: t("nav.home") },
|
||||||
{ href: "/dashboard", key: "dashboard", label: t("nav.dashboard") },
|
{ href: "/dashboard", key: "dashboard", label: t("nav.dashboard") },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export default async function PageWrapper({
|
|||||||
<Navbar currentPath={currentPath} />
|
<Navbar currentPath={currentPath} />
|
||||||
|
|
||||||
<main className={cn("relative py-10 md:py-14", className)}>
|
<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>
|
</main>
|
||||||
|
|
||||||
{footer ? (
|
{footer ? (
|
||||||
|
|||||||
@@ -4,7 +4,5 @@ import { routing } from "./i18n/routing";
|
|||||||
export default createMiddleware(routing);
|
export default createMiddleware(routing);
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: ["/((?!api|auth|_next|_vercel|.*\\..*).*)"],
|
||||||
"/((?!api|auth|_next|_vercel|.*\\..*).*)"
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
@@ -5,7 +5,22 @@ const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
|
// indispensable pour Docker + monorepo
|
||||||
output: "standalone",
|
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);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
+3
-14
@@ -1,11 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": [
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"esnext"
|
|
||||||
],
|
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
@@ -19,13 +15,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["**/*.ts", "**/*.tsx", "next-env.d.ts", ".next/types/**/*.ts"],
|
||||||
"**/*.ts",
|
"exclude": ["node_modules"]
|
||||||
"**/*.tsx",
|
|
||||||
"next-env.d.ts",
|
|
||||||
".next/types/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -57,7 +57,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -115,8 +116,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
api:
|
api:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
ports:
|
|
||||||
- "${BOT_PORT}:4100"
|
|
||||||
networks:
|
networks:
|
||||||
- edge_net
|
- edge_net
|
||||||
- backend_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