feat: introduce web dashboard with multi-bot management

- add Discord OAuth2 authentication
- allow users to register their bots via token
- implement dynamic bot start/stop system
- store bots in database (multi-tenant)
- replace single-bot env setup with scalable architecture
This commit is contained in:
Puechberty Arthur
2026-04-18 01:39:12 +02:00
parent 58376568c7
commit 3063796eb0
150 changed files with 6248 additions and 1387 deletions
+37
View File
@@ -0,0 +1,37 @@
FROM node:20-alpine AS deps
WORKDIR /workspace
COPY package.json package-lock.json tsconfig.base.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/api/package.json ./apps/api/package.json
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /workspace
COPY --from=deps /workspace/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.base.json ./
COPY packages/shared ./packages/shared
COPY apps/api ./apps/api
COPY database ./database
RUN npm run build -w @saas/shared && npm exec -- tsc -p apps/api/tsconfig.json
FROM node:20-alpine AS runner
WORKDIR /workspace
ENV NODE_ENV=production
COPY --from=deps /workspace/node_modules ./node_modules
COPY package.json package-lock.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/api/package.json ./apps/api/package.json
COPY apps/api/scripts ./apps/api/scripts
COPY --from=builder /workspace/packages/shared/dist ./packages/shared/dist
COPY --from=builder /workspace/apps/api/dist ./apps/api/dist
COPY database ./database
CMD ["sh", "-c", "npm run migrate -w @saas/api && npm run start -w @saas/api"]
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@saas/api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @saas/shared && tsc -p tsconfig.json",
"typecheck": "npm run build -w @saas/shared && tsc -p tsconfig.json --noEmit",
"start": "node dist/index.js",
"migrate": "node scripts/migrate.mjs"
},
"dependencies": {
"@saas/shared": "*",
"bullmq": "^5.21.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"ioredis": "^5.4.1",
"jose": "^5.9.6",
"pg": "^8.13.3",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/pg": "^8.11.11"
}
}
+138
View File
@@ -0,0 +1,138 @@
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { config as loadEnv } from "dotenv";
import { Pool } from "pg";
const LOCK_CLASS_ID = 7813;
const LOCK_OBJECT_ID = 4312;
const parseBoolean = (value, fallback = false) => {
if (value === undefined || value === null || String(value).trim().length === 0) {
return fallback;
}
const normalized = String(value).trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
};
const sha256 = (content) => createHash("sha256").update(content).digest("hex");
const listMigrationFiles = async (migrationsDir) => {
const entries = await readdir(migrationsDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && /^\d+.*\.sql$/i.test(entry.name))
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b));
};
const loadMigrations = async (migrationsDir) => {
const files = await listMigrationFiles(migrationsDir);
if (files.length === 0) {
throw new Error(`[migrate] no SQL migration files found in ${migrationsDir}`);
}
const migrations = [];
for (const fileName of files) {
const filePath = path.join(migrationsDir, fileName);
const sql = await readFile(filePath, "utf8");
migrations.push({
fileName,
sql,
checksum: sha256(sql),
});
}
return migrations;
};
const ensureMigrationTable = async (client) => {
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
};
const applyMigrations = async (client, migrations) => {
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)]));
for (const migration of migrations) {
const existingChecksum = appliedByVersion.get(migration.fileName);
if (existingChecksum) {
if (existingChecksum !== migration.checksum) {
throw new Error(
`[migrate] checksum mismatch for ${migration.fileName}. expected ${existingChecksum}, got ${migration.checksum}`,
);
}
continue;
}
await client.query("BEGIN");
try {
await client.query(migration.sql);
await client.query(
"INSERT INTO schema_migrations (version, checksum) VALUES ($1, $2)",
[migration.fileName, migration.checksum],
);
await client.query("COMMIT");
console.log(`[migrate] applied ${migration.fileName}`);
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw new Error(`[migrate] failed while applying ${migration.fileName}`, { cause: error });
}
}
};
const main = async () => {
loadEnv();
if (!process.env.DATABASE_URL) {
throw new Error("[migrate] DATABASE_URL is required");
}
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(scriptDir, "../../..");
const migrationsDir = path.join(rootDir, "database", "migrations");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: parseBoolean(process.env.DATABASE_SSL, false)
? {
rejectUnauthorized: true,
}
: undefined,
});
const client = await pool.connect();
try {
const migrations = await loadMigrations(migrationsDir);
await client.query("SELECT pg_advisory_lock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]);
await ensureMigrationTable(client);
await applyMigrations(client, migrations);
console.log("[migrate] completed");
} finally {
await client.query("SELECT pg_advisory_unlock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]).catch(() => undefined);
client.release();
await pool.end();
}
};
main().catch((error) => {
console.error("[migrate] fatal", error);
process.exit(1);
});
+122
View File
@@ -0,0 +1,122 @@
import { randomBytes } from "node:crypto";
import { z } from "zod";
import { env } from "../config/env.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
const oauthTokenSchema = z.object({
access_token: z.string(),
token_type: z.string(),
});
const discordUserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
global_name: z.string().nullable().optional(),
});
const discordBotSchema = z.object({
id: z.string(),
username: z.string(),
discriminator: z.string(),
global_name: z.string().nullable().optional(),
});
export interface DiscordIdentity {
id: string;
username: string;
avatarUrl: string | null;
}
export interface DiscordBotIdentity {
id: string;
displayName: string;
}
export const createOauthState = (): string => {
return randomBytes(24).toString("base64url");
};
export const buildDiscordLoginUrl = (state: string): string => {
const params = new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
redirect_uri: env.DISCORD_REDIRECT_URI,
response_type: "code",
scope: "identify",
state,
prompt: "consent",
});
return `https://discord.com/oauth2/authorize?${params.toString()}`;
};
const createAvatarUrl = (discordUserId: string, avatarHash: string | null): string | null => {
if (!avatarHash) {
return null;
}
return `https://cdn.discordapp.com/avatars/${discordUserId}/${avatarHash}.png`;
};
export const exchangeCodeForDiscordIdentity = async (code: string): Promise<DiscordIdentity> => {
const payload = new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
client_secret: env.DISCORD_CLIENT_SECRET,
grant_type: "authorization_code",
code,
redirect_uri: env.DISCORD_REDIRECT_URI,
});
const tokenResponse = await fetch(`${DISCORD_API_BASE}/oauth2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: payload,
});
if (!tokenResponse.ok) {
throw new Error("Discord OAuth token exchange failed");
}
const tokenJson = oauthTokenSchema.parse(await tokenResponse.json());
const userResponse = await fetch(`${DISCORD_API_BASE}/users/@me`, {
headers: {
Authorization: `${tokenJson.token_type} ${tokenJson.access_token}`,
},
});
if (!userResponse.ok) {
throw new Error("Discord user profile fetch failed");
}
const userJson = discordUserSchema.parse(await userResponse.json());
return {
id: userJson.id,
username: userJson.global_name ?? userJson.username,
avatarUrl: createAvatarUrl(userJson.id, userJson.avatar),
};
};
export const validateDiscordBotToken = async (botToken: string): Promise<DiscordBotIdentity> => {
const response = await fetch(`${DISCORD_API_BASE}/users/@me`, {
headers: {
Authorization: `Bot ${botToken}`,
},
});
if (!response.ok) {
throw new Error("Invalid Discord bot token");
}
const botJson = discordBotSchema.parse(await response.json());
return {
id: botJson.id,
displayName: botJson.global_name ?? botJson.username,
};
};
+43
View File
@@ -0,0 +1,43 @@
import { SignJWT, jwtVerify } from "jose";
import { env } from "../config/env.js";
import type { AuthSession } from "../types/auth.js";
interface SessionJwtPayload {
sub: string;
tenantId: string;
discordUserId: string;
username: string;
}
const secret = new TextEncoder().encode(env.JWT_SECRET);
export const issueSessionToken = async (session: AuthSession): Promise<string> => {
return new SignJWT({
tenantId: session.tenantId,
discordUserId: session.discordUserId,
username: session.username,
})
.setProtectedHeader({ alg: "HS256" })
.setSubject(session.userId)
.setIssuedAt()
.setExpirationTime(env.JWT_EXPIRES_IN)
.sign(secret);
};
export const verifySessionToken = async (token: string): Promise<AuthSession> => {
const verified = await jwtVerify<SessionJwtPayload>(token, secret, {
algorithms: ["HS256"],
});
if (!verified.payload.sub) {
throw new Error("session token missing subject");
}
return {
userId: verified.payload.sub,
tenantId: verified.payload.tenantId,
discordUserId: verified.payload.discordUserId,
username: verified.payload.username,
};
};
+31
View File
@@ -0,0 +1,31 @@
import { config as loadEnv } from "dotenv";
import { z } from "zod";
loadEnv();
const parseBoolean = (value: string): boolean => {
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
};
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(4000),
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
REDIS_URL: z.string().url("REDIS_URL must be a valid URL"),
DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"),
DISCORD_CLIENT_SECRET: z.string().min(1, "DISCORD_CLIENT_SECRET is required"),
DISCORD_REDIRECT_URI: z.string().url("DISCORD_REDIRECT_URI must be a valid URL"),
WEB_URL: z.string().url("WEB_URL must be a valid URL"),
API_BASE_URL: z.string().url("API_BASE_URL must be a valid URL"),
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
JWT_EXPIRES_IN: z.string().min(2).default("7d"),
SESSION_COOKIE_NAME: z.string().min(1).default("saas_session"),
COOKIE_SECURE: z.string().default("false").transform(parseBoolean),
TOKEN_ENCRYPTION_KEY: z.string().min(10, "TOKEN_ENCRYPTION_KEY is required"),
TENANT_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(120),
TENANT_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(60),
});
export const env = envSchema.parse(process.env);
+14
View File
@@ -0,0 +1,14 @@
import { Pool } from "pg";
import { env } from "../config/env.js";
export const createPgPool = (): Pool => {
return new Pool({
connectionString: env.DATABASE_URL,
ssl: env.DATABASE_SSL
? {
rejectUnauthorized: true,
}
: undefined,
});
};
+328
View File
@@ -0,0 +1,328 @@
import type { EncryptedToken, PublicBot } from "@saas/shared";
import type { Pool } from "pg";
export interface DbUser {
id: string;
tenantId: string;
discordUserId: string;
username: string;
avatarUrl: string | null;
role: "owner" | "member";
}
interface CreateOrUpdateBotInput {
tenantId: string;
ownerUserId: string;
discordBotId: string;
displayName: string;
encryptedToken: EncryptedToken;
}
const mapUser = (row: Record<string, unknown>): DbUser => {
return {
id: String(row.id),
tenantId: String(row.tenant_id),
discordUserId: String(row.discord_user_id),
username: String(row.username),
avatarUrl: row.avatar_url ? String(row.avatar_url) : null,
role: (row.role as "owner" | "member") ?? "member",
};
};
const mapPublicBot = (row: Record<string, unknown>): PublicBot => {
return {
id: String(row.id),
tenantId: String(row.tenant_id),
discordBotId: String(row.discord_bot_id),
displayName: String(row.display_name),
status: row.status as PublicBot["status"],
lastError: row.last_error ? String(row.last_error) : null,
createdAt: new Date(String(row.created_at)).toISOString(),
updatedAt: new Date(String(row.updated_at)).toISOString(),
};
};
export const upsertUserFromDiscord = async (
pool: Pool,
identity: {
discordUserId: string;
username: string;
avatarUrl: string | null;
},
): Promise<DbUser> => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const existing = await client.query(
`
SELECT id, tenant_id, discord_user_id, username, avatar_url, role
FROM users
WHERE discord_user_id = $1
FOR UPDATE
`,
[identity.discordUserId],
);
if (existing.rowCount && existing.rows[0]) {
const updated = await client.query(
`
UPDATE users
SET username = $2,
avatar_url = $3,
updated_at = NOW()
WHERE id = $1
RETURNING id, tenant_id, discord_user_id, username, avatar_url, role
`,
[existing.rows[0].id, identity.username, identity.avatarUrl],
);
await client.query("COMMIT");
return mapUser(updated.rows[0] as Record<string, unknown>);
}
const tenantInsert = await client.query<{ id: string }>(
`
INSERT INTO tenants DEFAULT VALUES
RETURNING id
`,
);
const tenantId = tenantInsert.rows[0]?.id;
if (!tenantId) {
throw new Error("Failed to create tenant");
}
const userInsert = await client.query(
`
INSERT INTO users (
tenant_id,
discord_user_id,
username,
avatar_url,
role
) VALUES ($1, $2, $3, $4, 'owner')
RETURNING id, tenant_id, discord_user_id, username, avatar_url, role
`,
[tenantId, identity.discordUserId, identity.username, identity.avatarUrl],
);
const user = mapUser(userInsert.rows[0] as Record<string, unknown>);
await client.query(
`
UPDATE tenants
SET owner_user_id = $1
WHERE id = $2
`,
[user.id, tenantId],
);
await client.query("COMMIT");
return user;
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
};
export const getUserByIdAndTenant = async (
pool: Pool,
userId: string,
tenantId: string,
): Promise<DbUser | null> => {
const result = await pool.query(
`
SELECT id, tenant_id, discord_user_id, username, avatar_url, role
FROM users
WHERE id = $1 AND tenant_id = $2
LIMIT 1
`,
[userId, tenantId],
);
if (!result.rows[0]) {
return null;
}
return mapUser(result.rows[0] as Record<string, unknown>);
};
export const listBotsForTenant = async (pool: Pool, tenantId: string): Promise<PublicBot[]> => {
const result = await pool.query(
`
SELECT id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
FROM bots
WHERE tenant_id = $1
ORDER BY created_at DESC
`,
[tenantId],
);
return result.rows.map((row) => mapPublicBot(row as Record<string, unknown>));
};
export const getBotForTenant = async (
pool: Pool,
tenantId: string,
botId: string,
): Promise<PublicBot | null> => {
const result = await pool.query(
`
SELECT id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
FROM bots
WHERE tenant_id = $1 AND id = $2
LIMIT 1
`,
[tenantId, botId],
);
if (!result.rows[0]) {
return null;
}
return mapPublicBot(result.rows[0] as Record<string, unknown>);
};
export const createOrUpdateBotForTenant = async (
pool: Pool,
input: CreateOrUpdateBotInput,
): Promise<PublicBot> => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const existingByDiscordBot = await client.query(
`
SELECT id, tenant_id
FROM bots
WHERE discord_bot_id = $1
FOR UPDATE
`,
[input.discordBotId],
);
if (existingByDiscordBot.rows[0] && existingByDiscordBot.rows[0].tenant_id !== input.tenantId) {
throw new Error("BOT_ALREADY_CLAIMED");
}
const existingInTenant = await client.query(
`
SELECT id
FROM bots
WHERE tenant_id = $1 AND discord_bot_id = $2
LIMIT 1
`,
[input.tenantId, input.discordBotId],
);
let upserted;
if (existingInTenant.rows[0]) {
upserted = await client.query(
`
UPDATE bots
SET owner_user_id = $2,
display_name = $3,
token_ciphertext = $4,
token_iv = $5,
token_tag = $6,
status = 'stopped',
last_error = NULL,
updated_at = NOW()
WHERE id = $1
RETURNING id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
`,
[
existingInTenant.rows[0].id,
input.ownerUserId,
input.displayName,
input.encryptedToken.ciphertext,
input.encryptedToken.iv,
input.encryptedToken.tag,
],
);
} else {
upserted = await client.query(
`
INSERT INTO bots (
tenant_id,
owner_user_id,
discord_bot_id,
display_name,
token_ciphertext,
token_iv,
token_tag,
status
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'stopped')
RETURNING id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
`,
[
input.tenantId,
input.ownerUserId,
input.discordBotId,
input.displayName,
input.encryptedToken.ciphertext,
input.encryptedToken.iv,
input.encryptedToken.tag,
],
);
}
await client.query("COMMIT");
return mapPublicBot(upserted.rows[0] as Record<string, unknown>);
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
};
export const setBotStatusForTenant = async (
pool: Pool,
tenantId: string,
botId: string,
status: PublicBot["status"],
lastError: string | null = null,
): Promise<void> => {
await pool.query(
`
UPDATE bots
SET status = $3,
last_error = $4,
updated_at = NOW()
WHERE tenant_id = $1 AND id = $2
`,
[tenantId, botId, status, lastError],
);
};
export const insertBotRuntimeEvent = async (
pool: Pool,
input: {
tenantId: string;
botId: string;
level: "info" | "warn" | "error";
message: string;
metadata?: unknown;
},
): Promise<void> => {
await pool.query(
`
INSERT INTO bot_runtime_events (tenant_id, bot_id, level, message, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb)
`,
[
input.tenantId,
input.botId,
input.level,
input.message,
JSON.stringify(input.metadata ?? {}),
],
);
};
+125
View File
@@ -0,0 +1,125 @@
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";
import { Redis } from "ioredis";
import { parseTokenEncryptionKey } from "@saas/shared";
import { env } from "./config/env.js";
import { createPgPool } from "./db/pool.js";
import { getUserByIdAndTenant } from "./db/repositories.js";
import { requireAuth } from "./middleware/auth.js";
import { createTenantRateLimit } from "./middleware/tenantRateLimit.js";
import { createAuthRouter } from "./routes/authRoutes.js";
import { createBotRouter } from "./routes/botRoutes.js";
import { createBotControlQueue } from "./services/botControlQueue.js";
const bootstrap = async (): Promise<void> => {
const pgPool = createPgPool();
const redis = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: null,
enableReadyCheck: true,
});
const tokenEncryptionKey = parseTokenEncryptionKey(env.TOKEN_ENCRYPTION_KEY);
const botControlQueue = createBotControlQueue(redis.duplicate());
await pgPool.query("SELECT 1");
await redis.ping();
const app = express();
app.disable("x-powered-by");
app.use(
cors({
origin: env.WEB_URL,
credentials: true,
}),
);
app.use(express.json({ limit: "200kb" }));
app.use(cookieParser());
app.get("/health", (_req, res) => {
res.status(200).json({
ok: true,
service: "api",
timestamp: new Date().toISOString(),
});
});
app.use("/auth", createAuthRouter({ pool: pgPool }));
app.get("/api/me", requireAuth, async (req, res) => {
if (!req.auth) {
res.status(401).json({ error: "Authentication required" });
return;
}
const user = await getUserByIdAndTenant(pgPool, req.auth.userId, req.auth.tenantId);
if (!user) {
res.status(401).json({ error: "Session does not match an existing user" });
return;
}
res.json({
user: {
id: user.id,
tenantId: user.tenantId,
discordUserId: user.discordUserId,
username: user.username,
avatarUrl: user.avatarUrl,
role: user.role,
},
});
});
const tenantControlRateLimit = createTenantRateLimit({
redis,
scope: "bot-control",
maxRequests: env.TENANT_RATE_LIMIT_MAX,
windowSeconds: env.TENANT_RATE_LIMIT_WINDOW_SECONDS,
});
app.use(
"/api/bots",
requireAuth,
createBotRouter({
pool: pgPool,
queue: botControlQueue,
tokenEncryptionKey,
tenantControlRateLimit,
}),
);
const server = app.listen(env.PORT, () => {
// eslint-disable-next-line no-console
console.log(`[api] listening on :${env.PORT}`);
});
const shutdown = async (signal: string): Promise<void> => {
// eslint-disable-next-line no-console
console.log(`[api] shutdown requested (${signal})`);
server.close(async () => {
await botControlQueue.close();
await redis.quit().catch(() => undefined);
await pgPool.end().catch(() => undefined);
process.exit(0);
});
};
process.once("SIGINT", () => {
void shutdown("SIGINT");
});
process.once("SIGTERM", () => {
void shutdown("SIGTERM");
});
};
bootstrap().catch((error) => {
// eslint-disable-next-line no-console
console.error("[api] fatal startup error", error);
process.exit(1);
});
+20
View File
@@ -0,0 +1,20 @@
import type { RequestHandler } from "express";
import { env } from "../config/env.js";
import { verifySessionToken } from "../auth/jwt.js";
export const requireAuth: RequestHandler = async (req, res, next) => {
const sessionToken = req.cookies?.[env.SESSION_COOKIE_NAME];
if (!sessionToken) {
res.status(401).json({ error: "Authentication required" });
return;
}
try {
req.auth = await verifySessionToken(sessionToken);
next();
} catch {
res.status(401).json({ error: "Invalid or expired session" });
}
};
@@ -0,0 +1,47 @@
import type { RequestHandler } from "express";
import type { Redis } from "ioredis";
import { tenantRateLimitKey } from "@saas/shared";
interface TenantRateLimitOptions {
redis: Redis;
scope: string;
maxRequests: number;
windowSeconds: number;
}
export const createTenantRateLimit = ({
redis,
scope,
maxRequests,
windowSeconds,
}: TenantRateLimitOptions): RequestHandler => {
return async (req, res, next) => {
if (!req.auth) {
res.status(401).json({ error: "Authentication required" });
return;
}
const key = tenantRateLimitKey(req.auth.tenantId, scope);
try {
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
if (count > maxRequests) {
res.status(429).json({
error: "Rate limit reached",
limit: maxRequests,
windowSeconds,
});
return;
}
next();
} catch {
res.status(503).json({ error: "Rate limit backend unavailable" });
}
};
};
+84
View File
@@ -0,0 +1,84 @@
import type { Router } from "express";
import { Router as expressRouter } from "express";
import type { Pool } from "pg";
import { buildDiscordLoginUrl, createOauthState, exchangeCodeForDiscordIdentity } from "../auth/discordOAuth.js";
import { issueSessionToken } from "../auth/jwt.js";
import { env } from "../config/env.js";
import { upsertUserFromDiscord } from "../db/repositories.js";
const OAUTH_STATE_COOKIE_NAME = "discord_oauth_state";
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
interface AuthRouterDependencies {
pool: Pool;
}
const sessionCookieConfig = {
httpOnly: true,
sameSite: "lax" as const,
secure: env.COOKIE_SECURE,
maxAge: SESSION_TTL_MS,
path: "/",
};
export const createAuthRouter = ({ pool }: AuthRouterDependencies): Router => {
const router = expressRouter();
router.get("/discord/login", (_req, res) => {
const state = createOauthState();
res.cookie(OAUTH_STATE_COOKIE_NAME, state, {
httpOnly: true,
sameSite: "lax",
secure: env.COOKIE_SECURE,
maxAge: OAUTH_STATE_TTL_MS,
path: "/auth/discord/callback",
});
res.redirect(buildDiscordLoginUrl(state));
});
router.get("/discord/callback", async (req, res) => {
const code = typeof req.query.code === "string" ? req.query.code : "";
const state = typeof req.query.state === "string" ? req.query.state : "";
const cookieState = req.cookies?.[OAUTH_STATE_COOKIE_NAME];
if (!code || !state || !cookieState || state !== cookieState) {
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
res.redirect(`${env.WEB_URL}/login?error=oauth_state_invalid`);
return;
}
try {
const discordIdentity = await exchangeCodeForDiscordIdentity(code);
const user = await upsertUserFromDiscord(pool, {
discordUserId: discordIdentity.id,
username: discordIdentity.username,
avatarUrl: discordIdentity.avatarUrl,
});
const sessionToken = await issueSessionToken({
userId: user.id,
tenantId: user.tenantId,
discordUserId: user.discordUserId,
username: user.username,
});
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
res.cookie(env.SESSION_COOKIE_NAME, sessionToken, sessionCookieConfig);
res.redirect(`${env.WEB_URL}/dashboard`);
} catch {
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
res.redirect(`${env.WEB_URL}/login?error=oauth_failed`);
}
});
router.post("/logout", (_req, res) => {
res.clearCookie(env.SESSION_COOKIE_NAME, { path: "/" });
res.status(204).send();
});
return router;
};
+156
View File
@@ -0,0 +1,156 @@
import type { RequestHandler, Router } from "express";
import { Router as expressRouter } from "express";
import type { Queue } from "bullmq";
import { z } from "zod";
import type { Pool } from "pg";
import type { BotControlJob } from "@saas/shared";
import { encryptToken } from "@saas/shared";
import { validateDiscordBotToken } from "../auth/discordOAuth.js";
import {
createOrUpdateBotForTenant,
getBotForTenant,
insertBotRuntimeEvent,
listBotsForTenant,
setBotStatusForTenant,
} from "../db/repositories.js";
import { enqueueBotControlAction } from "../services/botControlQueue.js";
const addBotSchema = z.object({
token: z.string().min(20, "Bot token is required"),
displayName: z.string().min(2).max(80).optional(),
});
const botIdParamsSchema = z.object({
botId: z.string().uuid("botId must be a valid UUID"),
});
interface BotRouterDependencies {
pool: Pool;
queue: Queue<BotControlJob>;
tokenEncryptionKey: Buffer;
tenantControlRateLimit: RequestHandler;
}
const queueBotAction = (
action: "start" | "stop" | "restart",
statusDuringAction: "starting" | "stopping",
deps: BotRouterDependencies,
): RequestHandler => {
return async (req, res) => {
if (!req.auth) {
res.status(401).json({ error: "Authentication required" });
return;
}
const parsedParams = botIdParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
res.status(400).json({ error: "Invalid botId" });
return;
}
const bot = await getBotForTenant(deps.pool, req.auth.tenantId, parsedParams.data.botId);
if (!bot) {
res.status(404).json({ error: "Bot not found" });
return;
}
await setBotStatusForTenant(deps.pool, req.auth.tenantId, parsedParams.data.botId, statusDuringAction, null);
await insertBotRuntimeEvent(deps.pool, {
tenantId: req.auth.tenantId,
botId: parsedParams.data.botId,
level: "info",
message: `Bot ${action} requested by dashboard user`,
metadata: {
userId: req.auth.userId,
action,
},
});
await enqueueBotControlAction(deps.queue, {
tenantId: req.auth.tenantId,
userId: req.auth.userId,
botId: parsedParams.data.botId,
action,
});
res.status(202).json({
accepted: true,
action,
botId: parsedParams.data.botId,
});
};
};
export const createBotRouter = (deps: BotRouterDependencies): Router => {
const router = expressRouter();
router.get("/", async (req, res) => {
if (!req.auth) {
res.status(401).json({ error: "Authentication required" });
return;
}
const bots = await listBotsForTenant(deps.pool, req.auth.tenantId);
res.json({ bots });
});
router.post("/", async (req, res) => {
if (!req.auth) {
res.status(401).json({ error: "Authentication required" });
return;
}
const parsedBody = addBotSchema.safeParse(req.body);
if (!parsedBody.success) {
res.status(400).json({ error: parsedBody.error.issues[0]?.message ?? "Invalid body" });
return;
}
try {
const botIdentity = await validateDiscordBotToken(parsedBody.data.token);
const encryptedToken = encryptToken(parsedBody.data.token, deps.tokenEncryptionKey);
const bot = await createOrUpdateBotForTenant(deps.pool, {
tenantId: req.auth.tenantId,
ownerUserId: req.auth.userId,
discordBotId: botIdentity.id,
displayName: parsedBody.data.displayName ?? botIdentity.displayName,
encryptedToken,
});
await insertBotRuntimeEvent(deps.pool, {
tenantId: req.auth.tenantId,
botId: bot.id,
level: "info",
message: "Bot credentials stored and encrypted",
metadata: {
byUser: req.auth.userId,
discordBotId: botIdentity.id,
},
});
res.status(201).json({ bot });
} catch (error) {
if (error instanceof Error && error.message === "BOT_ALREADY_CLAIMED") {
res.status(409).json({ error: "This Discord bot is already claimed by another tenant" });
return;
}
if (error instanceof Error && error.message === "Invalid Discord bot token") {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: "Unable to store bot" });
}
});
router.post("/:botId/start", deps.tenantControlRateLimit, queueBotAction("start", "starting", deps));
router.post("/:botId/stop", deps.tenantControlRateLimit, queueBotAction("stop", "stopping", deps));
router.post("/:botId/restart", deps.tenantControlRateLimit, queueBotAction("restart", "starting", deps));
return router;
};
+45
View File
@@ -0,0 +1,45 @@
import { Queue } from "bullmq";
import type { Redis } from "ioredis";
import {
BOT_CONTROL_QUEUE,
BOT_CONTROL_QUEUE_PREFIX,
type BotControlAction,
type BotControlJob,
} from "@saas/shared";
export const createBotControlQueue = (redis: Redis): Queue<BotControlJob> => {
return new Queue<BotControlJob>(BOT_CONTROL_QUEUE, {
connection: redis,
prefix: BOT_CONTROL_QUEUE_PREFIX,
defaultJobOptions: {
attempts: 3,
backoff: {
type: "exponential",
delay: 750,
},
removeOnComplete: 1000,
removeOnFail: 3000,
},
});
};
export const enqueueBotControlAction = async (
queue: Queue<BotControlJob>,
input: {
tenantId: string;
userId: string;
botId: string;
action: BotControlAction;
},
): Promise<void> => {
const payload: BotControlJob = {
tenantId: input.tenantId,
userId: input.userId,
botId: input.botId,
action: input.action,
requestedAt: new Date().toISOString(),
};
await queue.add(`${input.action}:${input.botId}:${Date.now()}`, payload);
};
+6
View File
@@ -0,0 +1,6 @@
export interface AuthSession {
userId: string;
tenantId: string;
discordUserId: string;
username: string;
}
+11
View File
@@ -0,0 +1,11 @@
import type { AuthSession } from "./auth.js";
declare global {
namespace Express {
interface Request {
auth?: AuthSession;
}
}
}
export {};
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+34
View File
@@ -0,0 +1,34 @@
FROM node:20-alpine AS deps
WORKDIR /workspace
COPY package.json package-lock.json tsconfig.base.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/bot/package.json ./apps/bot/package.json
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /workspace
COPY --from=deps /workspace/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.base.json ./
COPY packages/shared ./packages/shared
COPY apps/bot ./apps/bot
RUN npm run build -w @saas/bot
FROM node:20-alpine AS runner
WORKDIR /workspace
ENV NODE_ENV=production
COPY --from=deps /workspace/node_modules ./node_modules
COPY package.json package-lock.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/bot/package.json ./apps/bot/package.json
COPY --from=builder /workspace/packages/shared/dist ./packages/shared/dist
COPY --from=builder /workspace/apps/bot/dist ./apps/bot/dist
CMD ["npm", "run", "start", "-w", "@saas/bot"]
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@saas/bot",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @saas/shared && tsc -p tsconfig.json && node ./scripts/copyLegacyLocales.mjs",
"typecheck": "npm run build -w @saas/shared && tsc -p tsconfig.json --noEmit",
"start": "node dist/index.js"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.98",
"@saas/shared": "*",
"bullmq": "^5.21.2",
"discord.js": "^14.17.3",
"dotenv": "^16.4.7",
"ioredis": "^5.4.1",
"pg": "^8.13.3",
"pino": "^10.3.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/pg": "^8.11.11"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const BOT_DIR = path.resolve(SCRIPT_DIR, "..");
const SOURCE_DIR = path.join(BOT_DIR, "src", "legacy", "i18n");
const TARGET_DIR = path.join(BOT_DIR, "dist", "legacy", "i18n");
if (!existsSync(SOURCE_DIR)) {
throw new Error(`[i18n] source locale directory not found: ${SOURCE_DIR}`);
}
mkdirSync(TARGET_DIR, { recursive: true });
for (const fileName of readdirSync(SOURCE_DIR)) {
if (!fileName.endsWith(".json")) {
continue;
}
copyFileSync(path.join(SOURCE_DIR, fileName), path.join(TARGET_DIR, fileName));
}
+106
View File
@@ -0,0 +1,106 @@
import { config as loadEnv } from "dotenv";
import { z } from "zod";
import { SUPPORTED_LANGS } from "../legacy/types/command.js";
loadEnv();
const parseBoolean = (value: string): boolean => {
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
};
const optionalString = (value?: string): string | undefined => {
if (!value) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const parseOptionalUrl = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
}
const normalized = optionalString(value);
if (!normalized) {
return undefined;
}
return normalized;
};
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(4100),
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
DATABASE_SSL_REJECT_UNAUTHORIZED: z
.string()
.optional()
.default("true")
.transform(parseBoolean),
DATABASE_SSL_CA: z
.string()
.optional()
.transform((value) => {
const normalized = optionalString(value);
if (!normalized) {
return undefined;
}
return normalized.replace(/\\n/g, "\n");
}),
ALLOW_INSECURE_DB_SSL: z
.string()
.optional()
.default("false")
.transform(parseBoolean),
REDIS_URL: z.preprocess(parseOptionalUrl, z.string().url("REDIS_URL must be a valid URL").optional()),
TOKEN_ENCRYPTION_KEY: z.string().min(10, "TOKEN_ENCRYPTION_KEY is required"),
PRESENCE_STREAM_URL: z
.string()
.url("PRESENCE_STREAM_URL must be a valid URL")
.optional()
.default("https://twitch.tv/discord"),
PREFIX: z.string().min(1).max(5).default("+"),
DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"),
DEV_GUILD_ID: z.string().optional().transform((value) => value && value.length > 0 ? value : undefined),
AUTO_DEPLOY_SLASH: z
.string()
.optional()
.default("false")
.transform(parseBoolean),
LOG_LEVEL: z.string().trim().min(1).default("info"),
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
COMMAND_DISPATCH_MODE: z.enum(["local", "worker"]).default("local"),
COMMAND_QUEUE_NAME: z.string().trim().min(1).default("bot:${botId}:command-jobs"),
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(20),
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(10),
RATE_LIMIT_FAIL_OPEN: z
.string()
.optional()
.default("false")
.transform(parseBoolean),
ENABLE_LEADER_ELECTION: z
.string()
.optional()
.default("true")
.transform(parseBoolean),
});
const parsed = envSchema.parse(process.env);
if (parsed.DATABASE_SSL && !parsed.DATABASE_SSL_REJECT_UNAUTHORIZED && !parsed.ALLOW_INSECURE_DB_SSL) {
throw new Error(
"Insecure DATABASE_SSL configuration detected: rejectUnauthorized=false is blocked by default. Set DATABASE_SSL_REJECT_UNAUTHORIZED=true or explicitly set ALLOW_INSECURE_DB_SSL=true for non-production environments.",
);
}
if (parsed.STATE_BACKEND === "redis" && !parsed.REDIS_URL) {
throw new Error("REDIS_URL is required when STATE_BACKEND=redis.");
}
export const env = parsed;
+17
View File
@@ -0,0 +1,17 @@
import { Pool } from "pg";
import { env } from "../config/env.js";
export const createPgPool = (): Pool => {
const ssl = env.DATABASE_SSL
? {
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
ca: env.DATABASE_SSL_CA,
}
: undefined;
return new Pool({
connectionString: env.DATABASE_URL,
ssl,
});
};
+105
View File
@@ -0,0 +1,105 @@
import type { BotStatus } from "@saas/shared";
import type { Pool } from "pg";
export interface StoredBotCredentials {
id: string;
tenantId: string;
ownerUserId: string;
discordBotId: string;
displayName: string;
tokenCiphertext: string;
tokenIv: string;
tokenTag: string;
status: BotStatus;
}
const mapBotCredentials = (row: Record<string, unknown>): StoredBotCredentials => {
return {
id: String(row.id),
tenantId: String(row.tenant_id),
ownerUserId: String(row.owner_user_id),
discordBotId: String(row.discord_bot_id),
displayName: String(row.display_name),
tokenCiphertext: String(row.token_ciphertext),
tokenIv: String(row.token_iv),
tokenTag: String(row.token_tag),
status: row.status as BotStatus,
};
};
export const listBotsToRestore = async (pool: Pool): Promise<StoredBotCredentials[]> => {
const result = await pool.query(
`
SELECT id, tenant_id, owner_user_id, discord_bot_id, display_name, token_ciphertext, token_iv, token_tag, status
FROM bots
WHERE status IN ('running', 'starting')
ORDER BY created_at ASC
`,
);
return result.rows.map((row) => mapBotCredentials(row as Record<string, unknown>));
};
export const getBotCredentialsById = async (
pool: Pool,
botId: string,
): Promise<StoredBotCredentials | null> => {
const result = await pool.query(
`
SELECT id, tenant_id, owner_user_id, discord_bot_id, display_name, token_ciphertext, token_iv, token_tag, status
FROM bots
WHERE id = $1
LIMIT 1
`,
[botId],
);
if (!result.rows[0]) {
return null;
}
return mapBotCredentials(result.rows[0] as Record<string, unknown>);
};
export const setBotStatusById = async (
pool: Pool,
botId: string,
status: BotStatus,
lastError: string | null = null,
): Promise<void> => {
await pool.query(
`
UPDATE bots
SET status = $2,
last_error = $3,
updated_at = NOW()
WHERE id = $1
`,
[botId, status, lastError],
);
};
export const insertRuntimeEvent = async (
pool: Pool,
input: {
tenantId: string;
botId: string;
level: "info" | "warn" | "error";
message: string;
metadata?: unknown;
},
): Promise<void> => {
await pool.query(
`
INSERT INTO bot_runtime_events (tenant_id, bot_id, level, message, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb)
`,
[
input.tenantId,
input.botId,
input.level,
input.message,
JSON.stringify(input.metadata ?? {}),
],
);
};
+98
View File
@@ -0,0 +1,98 @@
import { createServer } from "node:http";
import { Redis } from "ioredis";
import { parseTokenEncryptionKey } from "@saas/shared";
import { env } from "./config/env.js";
import { createPgPool } from "./db/pool.js";
import { BotManager } from "./manager/BotManager.js";
import { createBotControlWorker } from "./queue/worker.js";
const bootstrap = async (): Promise<void> => {
const pgPool = createPgPool();
const redis = env.REDIS_URL
? new Redis(env.REDIS_URL, {
maxRetriesPerRequest: null,
enableReadyCheck: true,
})
: null;
await pgPool.query("SELECT 1");
if (redis) {
await redis.ping();
}
const manager = new BotManager(pgPool, parseTokenEncryptionKey(env.TOKEN_ENCRYPTION_KEY));
await manager.loadAndStartPersistedBots();
const worker = redis ? createBotControlWorker(redis.duplicate(), manager) : null;
if (worker) {
worker.on("completed", (job) => {
// eslint-disable-next-line no-console
console.log(`[bot] completed ${job.data.action} for bot ${job.data.botId}`);
});
worker.on("failed", (job, error) => {
// eslint-disable-next-line no-console
console.error(`[bot] failed ${job?.data.action ?? "unknown"} for bot ${job?.data.botId ?? "unknown"}`, error);
});
} else {
// eslint-disable-next-line no-console
console.warn("[bot] REDIS_URL is not configured, bot control queue worker is disabled");
}
const healthServer = createServer((req, res) => {
if (req.method === "GET" && req.url === "/health") {
const payload = JSON.stringify({
ok: true,
service: "bot",
runningBots: manager.getRunningCount(),
bots: manager.listRunningBots(),
timestamp: new Date().toISOString(),
});
res.writeHead(200, {
"Content-Type": "application/json",
});
res.end(payload);
return;
}
res.writeHead(404);
res.end();
});
healthServer.listen(env.PORT, () => {
// eslint-disable-next-line no-console
console.log(`[bot] health endpoint available on :${env.PORT}`);
});
const shutdown = async (signal: string): Promise<void> => {
// eslint-disable-next-line no-console
console.log(`[bot] shutdown requested (${signal})`);
healthServer.close(async () => {
await worker?.close().catch(() => undefined);
await manager.shutdown().catch(() => undefined);
await redis?.quit().catch(() => undefined);
await pgPool.end().catch(() => undefined);
process.exit(0);
});
};
process.once("SIGINT", () => {
void shutdown("SIGINT");
});
process.once("SIGTERM", () => {
void shutdown("SIGTERM");
});
};
bootstrap().catch((error) => {
// eslint-disable-next-line no-console
console.error("[bot] fatal startup error", error);
process.exit(1);
});
+273
View File
@@ -0,0 +1,273 @@
import { Client, GatewayIntentBits } from "discord.js";
import { Redis } from "ioredis";
import { Pool } from "pg";
import type { AppFeatureServices } from "./container.js";
import { createCommandList } from "../commands/index.js";
import { env } from "../config/env.js";
import { CommandRegistry } from "../core/commands/registry.js";
import { CommandExecutor } from "../core/execution/CommandExecutor.js";
import {
type CommandDispatchMode,
LocalCommandDispatchPort,
} from "../core/execution/dispatch.js";
import {
MemoryCooldownStore,
RedisCooldownStore,
} from "../core/execution/cooldownStore.js";
import {
MemoryGlobalRateLimitStore,
RedisGlobalRateLimitStore,
} from "../core/execution/globalRateLimitStore.js";
import { createScopedLogger } from "../core/logging/logger.js";
import {
LocalLeaderCoordinator,
PostgresLeaderCoordinator,
} from "../core/runtime/leaderCoordinator.js";
import { PostgresMemberMessageStore } from "../database/stores/memberMessageStore.js";
import { PostgresLogEventStore } from "../database/stores/logEventStore.js";
import { PostgresPresenceStore } from "../database/stores/presenceStore.js";
import { DatabaseLifecycle } from "../database/dbLifecycle.js";
import { registerEvents } from "../events/index.js";
import { createPrefixHandler } from "../handlers/prefixHandler.js";
import { createSlashHandler } from "../handlers/slashHandler.js";
import { I18nService } from "../i18n/index.js";
import {
LogEventService,
} from "../modules/logs/index.js";
import {
MemberMessageService,
} from "../modules/memberMessages/index.js";
import {
PresenceService,
} from "../modules/presence/index.js";
const SHUTDOWN_TIMEOUT_MS = 10_000;
const log = createScopedLogger("bootstrap");
const bindGracefulShutdown = (shutdown: (signal: string) => Promise<void>): void => {
process.once("SIGINT", () => {
void shutdown("SIGINT");
});
process.once("SIGTERM", () => {
void shutdown("SIGTERM");
});
};
const bindFatalProcessHandlers = (
shutdown: (signal: string, exitCode?: number, error?: unknown) => Promise<void>,
): void => {
process.once("uncaughtException", (error) => {
log.error({ err: error }, "process uncaught exception");
void shutdown("UNCAUGHT_EXCEPTION", 1, error);
});
process.once("unhandledRejection", (reason) => {
log.error({ reason }, "process unhandled rejection");
void shutdown("UNHANDLED_REJECTION", 1, reason);
});
};
export const bootstrap = async (): Promise<void> => {
const dispatchMode: CommandDispatchMode = env.COMMAND_DISPATCH_MODE;
if (dispatchMode === "worker") {
throw new Error("Worker mode is not implemented and must not be used in production");
}
const pool = new Pool({
connectionString: env.DATABASE_URL,
ssl: env.DATABASE_SSL
? {
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
...(env.DATABASE_SSL_CA ? { ca: env.DATABASE_SSL_CA } : {}),
}
: undefined,
});
const requiresRedis = env.STATE_BACKEND === "redis";
let redis: Redis | null = null;
if (requiresRedis) {
const redisUrl = env.REDIS_URL;
if (!redisUrl) {
throw new Error("REDIS_URL is required when STATE_BACKEND=redis");
}
try {
redis = new Redis(redisUrl, {
maxRetriesPerRequest: null,
enableReadyCheck: true,
});
await redis.ping();
} catch (error) {
log.error(
{
err: error,
stateBackend: env.STATE_BACKEND,
},
"redis unavailable at startup",
);
throw error;
}
}
const presenceStore = new PostgresPresenceStore(pool);
const memberMessageStore = new PostgresMemberMessageStore(pool);
const logEventStore = new PostgresLogEventStore(pool);
const dbLifecycle = new DatabaseLifecycle(
[
{ name: "presenceStore", init: () => presenceStore.init() },
{ name: "memberMessageStore", init: () => memberMessageStore.init() },
{ name: "logEventStore", init: () => logEventStore.init() },
],
async () => {
await pool.end();
},
);
const services: AppFeatureServices = {
presenceService: new PresenceService(presenceStore, env.PRESENCE_STREAM_URL),
memberMessageService: new MemberMessageService(memberMessageStore),
logEventService: new LogEventService(logEventStore),
};
const cooldownStore = env.STATE_BACKEND === "redis" && redis
? new RedisCooldownStore(redis)
: new MemoryCooldownStore();
const globalRateLimitStore = env.STATE_BACKEND === "redis" && redis
? new RedisGlobalRateLimitStore(redis)
: new MemoryGlobalRateLimitStore();
const executor = new CommandExecutor({
cooldownStore,
globalRateLimitStore,
globalRateLimitPolicy: {
limit: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS,
windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS,
},
rateLimitFailOpen: env.RATE_LIMIT_FAIL_OPEN,
logger: createScopedLogger("command-executor"),
});
const dispatcher = new LocalCommandDispatchPort(executor);
const leaderCoordinator = env.ENABLE_LEADER_ELECTION
? new PostgresLeaderCoordinator(pool, "discordjs-framework-template")
: new LocalLeaderCoordinator();
let shuttingDown = false;
let client: Client | null = null;
const shutdown = async (signal: string, exitCode = 0, error?: unknown): Promise<void> => {
if (shuttingDown) {
return;
}
shuttingDown = true;
log.info({ signal }, "shutdown signal received");
if (error !== undefined) {
log.error({ signal, err: error }, "shutdown reason");
}
const forcedExitCode = exitCode === 0 ? 1 : exitCode;
const forceExitTimer = setTimeout(() => {
log.error({ signal, forcedExitCode, timeoutMs: SHUTDOWN_TIMEOUT_MS }, "forcing process exit");
process.exit(forcedExitCode);
}, SHUTDOWN_TIMEOUT_MS);
forceExitTimer.unref?.();
if (client) {
client.destroy();
}
await services.logEventService.shutdown().catch((error) => {
log.error({ err: error }, "logs service shutdown failed");
});
await services.presenceService.shutdown().catch((error) => {
log.error({ err: error }, "presence service shutdown failed");
});
await dbLifecycle.shutdown().catch((error) => {
log.error({ err: error }, "database shutdown failed");
});
if (redis) {
await redis.quit().catch((error: unknown) => {
log.error({ err: error }, "redis shutdown failed");
});
}
clearTimeout(forceExitTimer);
process.exit(exitCode);
};
bindFatalProcessHandlers(shutdown);
try {
await dbLifecycle.init();
bindGracefulShutdown((signal) => shutdown(signal));
client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
const i18n = new I18nService(env.DEFAULT_LANG);
const registry = new CommandRegistry(createCommandList(services, i18n), i18n);
const onPrefixMessage = createPrefixHandler({
registry,
i18n,
dispatcher,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
const onSlashInteraction = createSlashHandler({
registry,
i18n,
dispatcher,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
registerEvents(
client,
i18n,
{ onPrefixMessage, onSlashInteraction },
registry,
services,
leaderCoordinator,
);
log.info(
{
stateBackend: env.STATE_BACKEND,
commandDispatchMode: dispatchMode,
configuredCommandDispatchMode: env.COMMAND_DISPATCH_MODE,
rateLimitFailOpen: env.RATE_LIMIT_FAIL_OPEN,
rateLimit: {
maxRequests: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS,
windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS,
},
},
"runtime initialized",
);
await client.login(env.DISCORD_TOKEN);
} catch (error) {
await shutdown("BOOTSTRAP_FAILURE", 1, error);
throw error;
}
};
+15
View File
@@ -0,0 +1,15 @@
import type {
MemberMessageService,
} from "../modules/memberMessages/index.js";
import type {
LogEventService,
} from "../modules/logs/index.js";
import type {
PresenceService,
} from "../modules/presence/index.js";
export interface AppFeatureServices {
presenceService: PresenceService;
memberMessageService: MemberMessageService;
logEventService: LogEventService;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Commande `goodbye` (utility)
*
* Wrapper léger qui utilise la factory `createMemberMessageExecute` pour
* afficher un panneau de configuration des messages d'au revoir.
*/
import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js";
import type { I18nService } from "../i18n/index.js";
import {
createMemberMessagePanelExecute,
type MemberMessageService,
} from "../modules/memberMessages/index.js";
/** Commande `goodbye` — ouvre le panneau de configuration des messages 'goodbye'. */
export const createGoodbyeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
meta: {
name: "goodbye",
category: "utility",
},
permissions: [PermissionFlagsBits.ManageGuild],
sensitive: true,
examples: [
{
source: "slash",
descriptionKey: "examples.slash",
},
],
execute: createMemberMessagePanelExecute("goodbye", memberMessageService, i18n),
});
+1
View File
@@ -0,0 +1 @@
export { helpCommand } from "../modules/help/command.js";
+28
View File
@@ -0,0 +1,28 @@
/**
* Liste des commandes exportées par le module `commands`.
*
* Ce fichier centralise l'ordre par défaut des commandes et permet de
* récupérer facilement la liste pour l'enregistrement (registry/dispatch).
*/
import { helpCommand } from "./help.js";
import { kissCommand } from "./kiss.js";
import { createGoodbyeCommand } from "./goodbye.js";
import { createLogsCommand } from "./logs.js";
import { createPresenceCommand } from "./presence.js";
import { pingCommand } from "./ping.js";
import { createWelcomeCommand } from "./welcome.js";
import type { AppFeatureServices } from "../app/container.js";
import type { I18nService } from "../i18n/index.js";
import type { BotCommand } from "../types/command.js";
/** CommandList: tableau ordonné des commandes disponibles. */
export const createCommandList = (services: AppFeatureServices, i18n: I18nService): BotCommand[] => [
kissCommand,
pingCommand,
createWelcomeCommand(services.memberMessageService, i18n),
createGoodbyeCommand(services.memberMessageService, i18n),
createPresenceCommand(services.presenceService),
createLogsCommand(services.logEventService),
helpCommand,
];
+43
View File
@@ -0,0 +1,43 @@
/**
* Commande `kiss` (fun)
*
* Envoie une réponse de type `kissing` ciblant un utilisateur mentionné.
* Utilise un seul argument `user` de type `user`.
*/
import { defineCommand } from "../core/commands/defineCommand.js";
/** Commande `kiss` — envoie un message de type `kiss` vers la cible. */
export const kissCommand = defineCommand({
meta: {
name: "kiss",
category: "fun",
},
args: [
{
name: "user",
type: "user",
required: true,
descriptionKey: "args.user",
},
],
examples: [
{
args: "@Arthur",
descriptionKey: "examples.basic",
},
],
execute: async (ctx) => {
const target = ctx.args.user;
if (!target || typeof target !== "object" || !("id" in target)) {
await ctx.reply(ctx.t("errors.args.invalidUser", { value: String(target) }));
return;
}
await ctx.reply(
ctx.ct("responses.success", {
from: `<@${ctx.user.id}>`,
to: `<@${target.id}>`,
}),
);
},
});
+23
View File
@@ -0,0 +1,23 @@
import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js";
import {
createLogsCommandExecute,
type LogEventService,
} from "../modules/logs/index.js";
export const createLogsCommand = (logEventService: LogEventService) => defineCommand({
meta: {
name: "logs",
category: "utility",
},
permissions: [PermissionFlagsBits.ManageGuild],
sensitive: true,
examples: [
{
source: "slash",
descriptionKey: "examples.slash",
},
],
execute: createLogsCommandExecute(logEventService),
});
+32
View File
@@ -0,0 +1,32 @@
/**
* Commande `ping` (utility)
*
* Répond avec un message court contenant la latence websocket du bot.
*/
import { MessageFlags } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js";
/** Commande `ping` — affiche la latence du bot. */
export const pingCommand = defineCommand({
meta: {
name: "ping",
category: "utility",
},
cooldown: 5,
examples: [
{
descriptionKey: "examples.basic",
},
],
execute: async (ctx) => {
const responses = (ctx.commandText.responses as Record<string, unknown> | undefined) ?? {};
const template = typeof responses.pong === "string" ? responses.pong : "Pong {{latency}}ms";
await ctx.reply({
content: ctx.format(template, {
latency: ctx.client.ws.ping,
}),
flags: [MessageFlags.Ephemeral],
});
},
});
+23
View File
@@ -0,0 +1,23 @@
import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js";
import {
createPresenceCommandExecute,
type PresenceService,
} from "../modules/presence/index.js";
export const createPresenceCommand = (presenceService: PresenceService) => defineCommand({
meta: {
name: "presence",
category: "utility",
},
permissions: [PermissionFlagsBits.ManageGuild],
sensitive: true,
examples: [
{
source: "slash",
descriptionKey: "examples.slash",
},
],
execute: createPresenceCommandExecute(presenceService),
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Commande `welcome` (utility)
*
* Wrapper léger qui utilise la factory `createMemberMessageExecute` pour
* afficher un panneau de configuration des messages d'accueil.
*/
import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js";
import type { I18nService } from "../i18n/index.js";
import {
createMemberMessagePanelExecute,
type MemberMessageService,
} from "../modules/memberMessages/index.js";
/** Commande `welcome` — ouvre le panneau de configuration des messages 'welcome'. */
export const createWelcomeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
meta: {
name: "welcome",
category: "utility",
},
permissions: [PermissionFlagsBits.ManageGuild],
sensitive: true,
examples: [
{
source: "slash",
descriptionKey: "examples.slash",
},
],
execute: createMemberMessagePanelExecute("welcome", memberMessageService, i18n),
});
+115
View File
@@ -0,0 +1,115 @@
import { config as loadEnv } from "dotenv";
import { z } from "zod";
import { SUPPORTED_LANGS } from "../types/command.js";
loadEnv();
const toBoolean = (value: string): boolean => {
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
};
const optionalString = (value?: string): string | undefined => {
if (!value) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const parseOptionalUrl = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
}
const normalized = optionalString(value);
if (!normalized) {
return undefined;
}
return normalized;
};
const envSchema = z.object({
DISCORD_TOKEN: z.string().optional().default(""),
DISCORD_CLIENT_ID: z.string().optional().default(""),
DATABASE_URL: z
.string()
.trim()
.min(1, "DATABASE_URL is required")
.url("DATABASE_URL must be a valid URL"),
DATABASE_SSL: z
.string()
.optional()
.default("false")
.transform(toBoolean),
DATABASE_SSL_REJECT_UNAUTHORIZED: z
.string()
.optional()
.default("true")
.transform(toBoolean),
DATABASE_SSL_CA: z
.string()
.optional()
.transform((value) => {
const normalized = optionalString(value);
if (!normalized) {
return undefined;
}
return normalized.replace(/\\n/g, "\n");
}),
ALLOW_INSECURE_DB_SSL: z
.string()
.optional()
.default("false")
.transform(toBoolean),
PRESENCE_STREAM_URL: z
.string()
.url("PRESENCE_STREAM_URL must be a valid URL")
.optional()
.default("https://twitch.tv/discord"),
PREFIX: z.string().min(1).max(5).default("+"),
DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"),
DEV_GUILD_ID: z.string().optional().transform((value) => value && value.length > 0 ? value : undefined),
AUTO_DEPLOY_SLASH: z
.string()
.optional()
.default("false")
.transform(toBoolean),
LOG_LEVEL: z.string().trim().min(1).default("info"),
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
REDIS_URL: z.preprocess(parseOptionalUrl, z.string().url("REDIS_URL must be a valid URL").optional()),
COMMAND_DISPATCH_MODE: z.enum(["local", "worker"]).default("local"),
COMMAND_QUEUE_NAME: z.string().trim().min(1).default("bot:${botId}:command-jobs"),
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(20),
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(10),
RATE_LIMIT_FAIL_OPEN: z
.string()
.optional()
.default("false")
.transform(toBoolean),
ENABLE_LEADER_ELECTION: z
.string()
.optional()
.default("true")
.transform(toBoolean),
});
const parsed = envSchema.parse(process.env);
if (parsed.DATABASE_SSL && !parsed.DATABASE_SSL_REJECT_UNAUTHORIZED && !parsed.ALLOW_INSECURE_DB_SSL) {
throw new Error(
"Insecure DATABASE_SSL configuration detected: rejectUnauthorized=false is blocked by default. Set DATABASE_SSL_REJECT_UNAUTHORIZED=true or explicitly set ALLOW_INSECURE_DB_SSL=true for non-production environments.",
);
}
if (parsed.STATE_BACKEND === "redis" && !parsed.REDIS_URL) {
throw new Error(
"REDIS_URL is required when STATE_BACKEND=redis.",
);
}
export const env = parsed;
@@ -0,0 +1,257 @@
import type { ChatInputCommandInteraction, GuildBasedChannel, Message } from "discord.js";
import type { ArgumentParseError, ParsedArgumentsResult } from "../../types/argParser.js";
import type { CommandArgValue, CommandArgument } from "../../types/command.js";
const USER_MENTION_PATTERN = /^<@!?(\d{16,22})>$/;
const CHANNEL_MENTION_PATTERN = /^<#(\d{16,22})>$/;
const ROLE_MENTION_PATTERN = /^<@&(\d{16,22})>$/;
const SNOWFLAKE_ID_PATTERN = /^(\d{16,22})$/;
const BOOLEAN_TRUE = new Set(["true", "1", "yes", "y", "on"]);
const BOOLEAN_FALSE = new Set(["false", "0", "no", "n", "off"]);
const isGuildBasedChannel = (value: unknown): value is GuildBasedChannel => {
return Boolean(value) && typeof value === "object" && "guild" in (value as Record<string, unknown>);
};
export const tokenizePrefixInput = (raw: string): string[] => {
const tokens: string[] = [];
const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(raw)) !== null) {
tokens.push((match[1] ?? match[2] ?? match[3] ?? "").replace(/\\(["'])/g, "$1"));
}
return tokens;
};
export const parsePrefixArgs = async (
message: Message,
definitions: CommandArgument[],
rawInput: string,
): Promise<ParsedArgumentsResult> => {
const tokens = tokenizePrefixInput(rawInput);
const values: Record<string, CommandArgValue> = {};
const errors: ArgumentParseError[] = [];
for (const definition of definitions) {
const token = tokens.shift();
if (!token) {
if (definition.required) {
errors.push({ key: "errors.args.missing", vars: { arg: definition.name } });
}
values[definition.name] = undefined;
continue;
}
const parsed = await parseByTypeFromPrefix(message, definition.type, token);
if (parsed.ok) {
values[definition.name] = parsed.value;
continue;
}
errors.push(parsed.error);
}
if (tokens.length > 0) {
errors.push({
key: "errors.args.tooMany",
vars: {
count: tokens.length,
extras: tokens.join(" "),
},
});
}
return { values, errors };
};
export const parseSlashArgs = (
interaction: ChatInputCommandInteraction,
definitions: CommandArgument[],
): ParsedArgumentsResult => {
const values: Record<string, CommandArgValue> = {};
const errors: ArgumentParseError[] = [];
for (const definition of definitions) {
try {
values[definition.name] = parseByTypeFromSlash(interaction, definition);
} catch {
if (definition.required) {
errors.push({ key: "errors.args.missing", vars: { arg: definition.name } });
}
values[definition.name] = undefined;
}
}
return { values, errors };
};
const parseByTypeFromPrefix = async (
message: Message,
type: CommandArgument["type"],
token: string,
): Promise<{ ok: true; value: CommandArgValue } | { ok: false; error: ArgumentParseError }> => {
switch (type) {
case "string":
return { ok: true, value: token };
case "int": {
if (!/^-?\d+$/.test(token)) {
return { ok: false, error: { key: "errors.args.invalidInt", vars: { value: token } } };
}
const value = Number.parseInt(token, 10);
return { ok: true, value };
}
case "number": {
const value = Number(token);
if (!Number.isFinite(value)) {
return { ok: false, error: { key: "errors.args.invalidNumber", vars: { value: token } } };
}
return { ok: true, value };
}
case "boolean": {
const normalized = token.toLowerCase();
if (BOOLEAN_TRUE.has(normalized)) {
return { ok: true, value: true };
}
if (BOOLEAN_FALSE.has(normalized)) {
return { ok: true, value: false };
}
return { ok: false, error: { key: "errors.args.invalidBoolean", vars: { value: token } } };
}
case "user": {
const mentionMatch = token.match(USER_MENTION_PATTERN);
const idMatch = token.match(SNOWFLAKE_ID_PATTERN);
const userId = mentionMatch?.[1] ?? idMatch?.[1];
if (!userId) {
return { ok: false, error: { key: "errors.args.invalidUser", vars: { value: token } } };
}
const fromMention = message.mentions.users.get(userId);
if (fromMention) {
return { ok: true, value: fromMention };
}
try {
const fetched = await message.client.users.fetch(userId);
return { ok: true, value: fetched };
} catch {
return { ok: false, error: { key: "errors.args.invalidUser", vars: { value: token } } };
}
}
case "channel": {
const mentionMatch = token.match(CHANNEL_MENTION_PATTERN);
const idMatch = token.match(SNOWFLAKE_ID_PATTERN);
const channelId = mentionMatch?.[1] ?? idMatch?.[1];
if (!channelId) {
return { ok: false, error: { key: "errors.args.invalidChannel", vars: { value: token } } };
}
const fromMention = message.mentions.channels.get(channelId);
if (fromMention && isGuildBasedChannel(fromMention)) {
return { ok: true, value: fromMention };
}
const fromGuildCache = message.guild?.channels.cache.get(channelId);
if (fromGuildCache) {
return { ok: true, value: fromGuildCache };
}
try {
const fetchedFromGuild = await message.guild?.channels.fetch(channelId);
if (fetchedFromGuild) {
return { ok: true, value: fetchedFromGuild };
}
} catch {
// Try the global cache/fetch fallback below.
}
try {
const fetched = await message.client.channels.fetch(channelId);
if (fetched && isGuildBasedChannel(fetched)) {
return { ok: true, value: fetched };
}
} catch {
// Falls through to invalid channel error.
}
return { ok: false, error: { key: "errors.args.invalidChannel", vars: { value: token } } };
}
case "role": {
const mentionMatch = token.match(ROLE_MENTION_PATTERN);
const idMatch = token.match(SNOWFLAKE_ID_PATTERN);
const roleId = mentionMatch?.[1] ?? idMatch?.[1];
if (!roleId || !message.guild) {
return { ok: false, error: { key: "errors.args.invalidRole", vars: { value: token } } };
}
const fromMention = message.mentions.roles.get(roleId);
if (fromMention) {
return { ok: true, value: fromMention };
}
const fromCache = message.guild.roles.cache.get(roleId);
if (fromCache) {
return { ok: true, value: fromCache };
}
try {
const fetched = await message.guild.roles.fetch(roleId);
if (fetched) {
return { ok: true, value: fetched };
}
} catch {
// Falls through to invalid role error.
}
return { ok: false, error: { key: "errors.args.invalidRole", vars: { value: token } } };
}
default:
return { ok: true, value: token };
}
};
const parseByTypeFromSlash = (
interaction: ChatInputCommandInteraction,
definition: CommandArgument,
): CommandArgValue => {
switch (definition.type) {
case "string":
return interaction.options.getString(definition.name, definition.required) ?? undefined;
case "int":
return interaction.options.getInteger(definition.name, definition.required) ?? undefined;
case "number":
return interaction.options.getNumber(definition.name, definition.required) ?? undefined;
case "boolean":
return interaction.options.getBoolean(definition.name, definition.required) ?? undefined;
case "user":
return interaction.options.getUser(definition.name, definition.required) ?? undefined;
case "channel":
return (interaction.options.getChannel(definition.name, definition.required) ?? undefined) as CommandArgValue;
case "role":
return (interaction.options.getRole(definition.name, definition.required) ?? undefined) as CommandArgValue;
default:
return interaction.options.getString(definition.name, definition.required) ?? undefined;
}
};
@@ -0,0 +1,64 @@
import type { BotCommand, BotCommandInput } from "../../types/command.js";
const assertRequiredArgsBeforeOptional = (input: BotCommandInput): void => {
const args = input.args ?? [];
let firstOptionalArgName: string | null = null;
for (const arg of args) {
if (!arg.required) {
firstOptionalArgName ??= arg.name;
continue;
}
if (firstOptionalArgName) {
throw new Error(
`Invalid argument order for command "${input.meta.name}": required argument "${arg.name}" cannot appear after optional argument "${firstOptionalArgName}". Declare all required arguments before optional ones.`,
);
}
}
};
const normalizeCooldown = (input: BotCommandInput): number | undefined => {
if (input.cooldown === undefined) {
return undefined;
}
if (!Number.isFinite(input.cooldown) || input.cooldown <= 0) {
throw new Error(
`Invalid cooldown for command "${input.meta.name}": expected a positive number of seconds, received "${input.cooldown}".`,
);
}
return input.cooldown;
};
const resolveSensitive = (input: BotCommandInput, permissions: readonly unknown[]): boolean => {
if (input.sensitive === true && permissions.length === 0) {
throw new Error(
`Invalid security config for command "${input.meta.name}": sensitive commands must declare at least one required permission.`,
);
}
if (input.sensitive !== undefined) {
return input.sensitive;
}
return permissions.length > 0;
};
export const defineCommand = (input: BotCommandInput): BotCommand => {
assertRequiredArgsBeforeOptional(input);
const cooldown = normalizeCooldown(input);
const permissions = [...(input.permissions ?? [])];
const sensitive = resolveSensitive(input, permissions);
return {
meta: input.meta,
args: [...(input.args ?? [])],
permissions,
sensitive,
examples: input.examples ?? [],
...(cooldown !== undefined ? { cooldown } : {}),
execute: input.execute,
};
};
@@ -0,0 +1,17 @@
import { REST, Routes } from "discord.js";
import { buildSlashPayload } from "./slashBuilder.js";
import type { DeployCommandsOptions, DeployCommandsResult } from "../../types/deploy.js";
export const deployApplicationCommands = async (options: DeployCommandsOptions): Promise<DeployCommandsResult> => {
const body = buildSlashPayload(options.registry.getAll(), options.i18n);
const rest = new REST({ version: "10" }).setToken(options.token);
if (options.guildId) {
await rest.put(Routes.applicationGuildCommands(options.clientId, options.guildId), { body });
return { scope: "guild", count: body.length };
}
await rest.put(Routes.applicationCommands(options.clientId), { body });
return { scope: "global", count: body.length };
};
@@ -0,0 +1,76 @@
import type { BotCommand, PrefixTriggerMatch } from "../../types/command.js";
import { SUPPORTED_LANGS } from "../../types/command.js";
import type { I18nService } from "../../i18n/index.js";
const normalize = (value: string): string => value.trim().toLowerCase();
export class CommandRegistry {
private readonly commandsByName = new Map<string, BotCommand>();
private readonly prefixTriggers = new Map<string, PrefixTriggerMatch>();
private readonly slashTriggers = new Map<string, BotCommand>();
public constructor(
commands: BotCommand[],
private readonly i18n: I18nService,
) {
for (const command of commands) {
this.register(command);
}
}
public getAll(): readonly BotCommand[] {
return [...this.commandsByName.values()];
}
public findByName(name: string): BotCommand | undefined {
return this.commandsByName.get(normalize(name));
}
public findByAnyPrefixTrigger(trigger: string): PrefixTriggerMatch | undefined {
return this.prefixTriggers.get(normalize(trigger));
}
public findBySlashTrigger(trigger: string): BotCommand | undefined {
return this.slashTriggers.get(normalize(trigger));
}
private register(command: BotCommand): void {
const name = normalize(command.meta.name);
if (this.commandsByName.has(name)) {
throw new Error(`Duplicate command meta.name: ${command.meta.name}`);
}
this.commandsByName.set(name, command);
for (const lang of SUPPORTED_LANGS) {
const trigger = normalize(this.i18n.commandTrigger(lang, command.meta.name));
const existing = this.prefixTriggers.get(trigger);
if (existing) {
if (existing.command.meta.name !== command.meta.name) {
throw new Error(`Duplicate prefix trigger: ${trigger}`);
}
continue;
}
this.prefixTriggers.set(trigger, {
command,
lang,
trigger,
});
}
const slashKeys = [command.meta.name, ...SUPPORTED_LANGS.map((lang) => this.i18n.commandName(lang, command.meta.name))];
for (const keyRaw of slashKeys) {
const key = normalize(keyRaw);
const existing = this.slashTriggers.get(key);
if (existing && existing.meta.name !== command.meta.name) {
throw new Error(`Duplicate slash trigger: ${key}`);
}
this.slashTriggers.set(key, command);
}
}
}
@@ -0,0 +1,193 @@
import {
ApplicationCommandOptionType,
SlashCommandBuilder,
type RESTPostAPIChatInputApplicationCommandsJSONBody,
} from "discord.js";
import type { BotCommand, CommandArgument, SupportedLang } from "../../types/command.js";
import type { I18nService } from "../../i18n/index.js";
const LANG_TO_DISCORD_LOCALE: Record<SupportedLang, string> = {
en: "en-US",
fr: "fr",
es: "es-ES",
};
const toLocalizationMap = (source: Partial<Record<SupportedLang, string>>): Record<string, string> => {
const entries = Object.entries(source)
.filter(([, value]) => Boolean(value))
.map(([lang, value]) => [LANG_TO_DISCORD_LOCALE[lang as SupportedLang], value as string]);
return Object.fromEntries(entries);
};
const argDescriptionKey = (command: BotCommand, arg: CommandArgument): string => {
return `commands.${command.meta.name}.${arg.descriptionKey}`;
};
const commandDescriptionKey = (command: BotCommand): string => {
return `commands.${command.meta.name}.description`;
};
const buildHelpCommandChoices = (commands: readonly BotCommand[], i18n: I18nService): Array<{ name: string; value: string }> => {
return commands
.slice()
.sort((a, b) => a.meta.name.localeCompare(b.meta.name))
.slice(0, 25)
.map((cmd) => ({
name: i18n.commandName("en", cmd.meta.name),
value: cmd.meta.name,
}));
};
const applyOption = (
builder: SlashCommandBuilder,
command: BotCommand,
arg: CommandArgument,
i18n: I18nService,
allCommands: readonly BotCommand[],
): void => {
const descriptionKey = argDescriptionKey(command, arg);
const descriptionEn = i18n.t("en", descriptionKey);
const descriptionLocalizations = toLocalizationMap({
en: i18n.t("en", descriptionKey),
fr: i18n.t("fr", descriptionKey),
es: i18n.t("es", descriptionKey),
});
if (arg.type === "user") {
builder.addUserOption((opt) =>
opt
.setName(arg.name)
.setDescription(descriptionEn)
.setDescriptionLocalizations(descriptionLocalizations)
.setRequired(arg.required),
);
return;
}
if (arg.type === "int") {
builder.addIntegerOption((opt) =>
opt
.setName(arg.name)
.setDescription(descriptionEn)
.setDescriptionLocalizations(descriptionLocalizations)
.setRequired(arg.required),
);
return;
}
if (arg.type === "number") {
builder.addNumberOption((opt) =>
opt
.setName(arg.name)
.setDescription(descriptionEn)
.setDescriptionLocalizations(descriptionLocalizations)
.setRequired(arg.required),
);
return;
}
if (arg.type === "boolean") {
builder.addBooleanOption((opt) =>
opt
.setName(arg.name)
.setDescription(descriptionEn)
.setDescriptionLocalizations(descriptionLocalizations)
.setRequired(arg.required),
);
return;
}
if (arg.type === "channel") {
builder.addChannelOption((opt) =>
opt
.setName(arg.name)
.setDescription(descriptionEn)
.setDescriptionLocalizations(descriptionLocalizations)
.setRequired(arg.required),
);
return;
}
if (arg.type === "role") {
builder.addRoleOption((opt) =>
opt
.setName(arg.name)
.setDescription(descriptionEn)
.setDescriptionLocalizations(descriptionLocalizations)
.setRequired(arg.required),
);
return;
}
builder.addStringOption((opt) =>
{
const configured = opt
.setName(arg.name)
.setDescription(descriptionEn)
.setDescriptionLocalizations(descriptionLocalizations)
.setRequired(arg.required);
if (command.meta.name === "help" && arg.name === "command") {
const choices = buildHelpCommandChoices(allCommands, i18n);
if (choices.length > 0) {
configured.addChoices(...choices);
}
}
return configured;
},
);
};
export const buildSlashPayload = (
commands: readonly BotCommand[],
i18n: I18nService,
): RESTPostAPIChatInputApplicationCommandsJSONBody[] => {
return commands.map((command) => {
const descriptionKey = commandDescriptionKey(command);
const slashLocalizations = toLocalizationMap({
fr: i18n.commandName("fr", command.meta.name),
es: i18n.commandName("es", command.meta.name),
});
const slashBuilder = new SlashCommandBuilder()
.setName(command.meta.name)
.setDescription(i18n.t("en", descriptionKey))
.setDescriptionLocalizations(
toLocalizationMap({
en: i18n.t("en", descriptionKey),
fr: i18n.t("fr", descriptionKey),
es: i18n.t("es", descriptionKey),
}),
)
.setNameLocalizations(slashLocalizations);
for (const arg of command.args) {
applyOption(slashBuilder, command, arg, i18n, commands);
}
return slashBuilder.toJSON() as RESTPostAPIChatInputApplicationCommandsJSONBody;
});
};
export const commandOptionType = (type: CommandArgument["type"]): ApplicationCommandOptionType => {
switch (type) {
case "user":
return ApplicationCommandOptionType.User;
case "int":
return ApplicationCommandOptionType.Integer;
case "number":
return ApplicationCommandOptionType.Number;
case "boolean":
return ApplicationCommandOptionType.Boolean;
case "channel":
return ApplicationCommandOptionType.Channel;
case "role":
return ApplicationCommandOptionType.Role;
default:
return ApplicationCommandOptionType.String;
}
};
@@ -0,0 +1,44 @@
import type { BotCommand, CommandI18nTools, SupportedLang } from "../../types/command.js";
const formatArgToken = (name: string, required: boolean): string => required ? `<${name}>` : `[${name}]`;
export const resolvePrefixTrigger = (
command: BotCommand,
lang: SupportedLang,
defaultLang: SupportedLang,
i18n: CommandI18nTools,
): string => {
const fromLang = i18n.commandTrigger(lang, command.meta.name);
if (fromLang) {
return fromLang;
}
const fromDefault = i18n.commandTrigger(defaultLang, command.meta.name);
if (fromDefault) {
return fromDefault;
}
return command.meta.name;
};
export const resolveSlashName = (command: BotCommand, lang: SupportedLang, i18n: CommandI18nTools): string => {
return i18n.commandName(lang, command.meta.name);
};
export const buildPrefixUsage = (
command: BotCommand,
prefix: string,
lang: SupportedLang,
defaultLang: SupportedLang,
i18n: CommandI18nTools,
): string => {
const trigger = resolvePrefixTrigger(command, lang, defaultLang, i18n);
const args = command.args.map((arg) => formatArgToken(arg.name, arg.required)).join(" ");
return `${prefix}${trigger}${args.length > 0 ? ` ${args}` : ""}`;
};
export const buildSlashUsage = (command: BotCommand, lang: SupportedLang, i18n: CommandI18nTools): string => {
const slashName = resolveSlashName(command, lang, i18n);
const args = command.args.map((arg) => formatArgToken(arg.name, arg.required)).join(" ");
return `/${slashName}${args.length > 0 ? ` ${args}` : ""}`;
};
@@ -0,0 +1,45 @@
export interface ComponentPanelSession {
collector: {
stop: (reason?: string) => void;
};
disable: () => Promise<void>;
}
export class ComponentSessionRegistry<T extends ComponentPanelSession> {
private readonly sessions = new Map<string, T>();
public get(key: string): T | undefined {
return this.sessions.get(key);
}
public async replace(key: string, next: T): Promise<void> {
const existing = this.sessions.get(key);
if (existing) {
existing.collector.stop("replaced");
await existing.disable().catch(() => undefined);
}
this.sessions.set(key, next);
}
public deleteIfCollectorMatch(key: string, collector: T["collector"]): void {
const existing = this.sessions.get(key);
if (!existing) {
return;
}
if (existing.collector === collector) {
this.sessions.delete(key);
}
}
public async stopAll(reason = "shutdown"): Promise<void> {
const entries = [...this.sessions.values()];
this.sessions.clear();
for (const session of entries) {
session.collector.stop(reason);
await session.disable().catch(() => undefined);
}
}
}
@@ -0,0 +1,27 @@
import type { Message } from "discord.js";
const isMessageResult = (value: unknown): value is Message => {
if (!value || typeof value !== "object") {
return false;
}
return "createMessageComponentCollector" in value && "edit" in value;
};
export const resolveReplyMessage = (value: unknown): Message | null => {
if (isMessageResult(value)) {
return value;
}
if (!value || typeof value !== "object" || !("resource" in value)) {
return null;
}
const resource = (value as { resource?: unknown }).resource;
if (!resource || typeof resource !== "object" || !("message" in resource)) {
return null;
}
const message = (resource as { message?: unknown }).message;
return isMessageResult(message) ? message : null;
};
@@ -0,0 +1,254 @@
import {
PermissionsBitField,
type PermissionResolvable,
} from "discord.js";
import type { BotCommand, CommandExecutionContext } from "../../types/command.js";
import type { AppLogger } from "../logging/logger.js";
import type { CooldownStore } from "./cooldownStore.js";
import type {
GlobalRateLimitPolicy,
GlobalRateLimitStore,
} from "./globalRateLimitStore.js";
export interface CommandExecutorDeps {
cooldownStore: CooldownStore;
globalRateLimitStore: GlobalRateLimitStore;
globalRateLimitPolicy: GlobalRateLimitPolicy;
rateLimitFailOpen: boolean;
logger: AppLogger;
}
export class CommandExecutor {
public constructor(private readonly deps: CommandExecutorDeps) {}
public async run(command: BotCommand, ctx: CommandExecutionContext): Promise<void> {
if (this.isSensitiveCommand(command) && !ctx.transport.guild) {
await ctx.reply(
ctx.t("errors.permissions.user", {
permissions: this.formatPermissionLabel("ManageGuild"),
}),
);
return;
}
const rateLimitRetryAfterSeconds = await this.consumeGlobalRateLimit(ctx);
if (rateLimitRetryAfterSeconds > 0) {
await ctx.reply(ctx.t("errors.rateLimit", { seconds: rateLimitRetryAfterSeconds }));
return;
}
const availablePermissions = await this.resolveMemberPermissions(ctx);
const missingUserPermissions = this.getMissingPermissions(command.permissions, availablePermissions);
if (missingUserPermissions.length > 0) {
await ctx.reply(ctx.t("errors.permissions.user", { permissions: missingUserPermissions.join(", ") }));
return;
}
const remainingCooldownSeconds = await this.consumeCooldown(command, ctx);
if (remainingCooldownSeconds > 0) {
await ctx.reply(ctx.t("errors.cooldown", { seconds: remainingCooldownSeconds }));
return;
}
try {
await command.execute(ctx);
} catch (error) {
this.deps.logger.error(
{
requestId: ctx.execution.requestId,
command: command.meta.name,
source: ctx.execution.source,
userId: ctx.execution.actor.userId,
err: error,
},
"command execution failed",
);
await ctx.reply(ctx.t("errors.execution"));
}
}
private async resolveMemberPermissions(
ctx: CommandExecutionContext,
): Promise<Readonly<PermissionsBitField> | null> {
try {
return await ctx.transport.resolveMemberPermissions();
} catch (error) {
this.deps.logger.warn(
{
requestId: ctx.execution.requestId,
source: ctx.execution.source,
userId: ctx.execution.actor.userId,
err: error,
},
"permission resolution failed",
);
return null;
}
}
private getMissingPermissions(
required: PermissionResolvable[],
available: Readonly<PermissionsBitField> | null,
): string[] {
if (required.length === 0) {
return [];
}
if (!available) {
return [...new Set(required.flatMap((permission) => this.permissionToLabels(permission)))];
}
return [
...new Set(
required
.filter((permission) => !available.has(permission))
.flatMap((permission) => this.permissionToLabels(permission)),
),
];
}
private permissionToLabels(permission: PermissionResolvable): string[] {
try {
const resolved = PermissionsBitField.resolve(permission);
const labels = new PermissionsBitField(resolved).toArray().map(this.formatPermissionLabel);
if (labels.length > 0) {
return labels;
}
} catch {
// Fallback handled below.
}
return [String(permission)];
}
private formatPermissionLabel(permission: string): string {
return permission
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.trim();
}
private async consumeCooldown(command: BotCommand, ctx: CommandExecutionContext): Promise<number> {
if (command.cooldown === undefined || command.cooldown <= 0) {
return 0;
}
const botId = this.resolveBotId(ctx);
if (!botId) {
return this.deps.rateLimitFailOpen ? 0 : Math.max(1, command.cooldown);
}
const userId = ctx.execution.actor.userId;
try {
const result = await this.deps.cooldownStore.consume(botId, command.meta.name, userId, command.cooldown);
return result.allowed ? 0 : result.retryAfterSeconds;
} catch (error) {
if (this.deps.rateLimitFailOpen) {
this.deps.logger.error(
{
requestId: ctx.execution.requestId,
command: command.meta.name,
userId,
botId,
err: error,
},
"cooldown store unavailable, fail-open enabled",
);
return 0;
}
this.deps.logger.error(
{
requestId: ctx.execution.requestId,
command: command.meta.name,
userId,
botId,
err: error,
},
"cooldown store unavailable, fail-closed blocking command",
);
return Math.max(1, command.cooldown);
}
}
private async consumeGlobalRateLimit(ctx: CommandExecutionContext): Promise<number> {
const botId = this.resolveBotId(ctx);
if (!botId) {
return this.deps.rateLimitFailOpen ? 0 : Math.max(1, this.deps.globalRateLimitPolicy.windowSeconds);
}
try {
const result = await this.deps.globalRateLimitStore.consume(
botId,
ctx.execution.actor.userId,
this.deps.globalRateLimitPolicy,
);
if (result.allowed) {
return 0;
}
this.deps.logger.warn(
{
requestId: ctx.execution.requestId,
userId: ctx.execution.actor.userId,
botId,
retryAfterSeconds: result.retryAfterSeconds,
remaining: result.remaining,
limit: result.limit,
},
"global rate limit blocked command",
);
return result.retryAfterSeconds;
} catch (error) {
if (this.deps.rateLimitFailOpen) {
this.deps.logger.error(
{
requestId: ctx.execution.requestId,
userId: ctx.execution.actor.userId,
botId,
err: error,
},
"global rate limit store unavailable, fail-open enabled",
);
return 0;
}
this.deps.logger.error(
{
requestId: ctx.execution.requestId,
userId: ctx.execution.actor.userId,
botId,
err: error,
},
"global rate limit store unavailable, fail-closed blocking command",
);
return Math.max(1, this.deps.globalRateLimitPolicy.windowSeconds);
}
}
private resolveBotId(ctx: CommandExecutionContext): string | null {
const botId = ctx.transport.client.user?.id;
if (!botId) {
this.deps.logger.error(
{
requestId: ctx.execution.requestId,
source: ctx.execution.source,
},
"runtime bot id unavailable for scoped execution stores",
);
return null;
}
return botId;
}
private isSensitiveCommand(command: BotCommand): boolean {
return command.sensitive || command.permissions.length > 0;
}
}
@@ -0,0 +1,110 @@
import type { Redis } from "ioredis";
const COOLDOWN_SWEEP_INTERVAL_MS = 60_000;
const COOLDOWN_SWEEP_MIN_ENTRIES = 512;
export interface CooldownConsumeResult {
allowed: boolean;
retryAfterSeconds: number;
}
export interface CooldownStore {
consume(botId: string, commandName: string, subjectId: string, cooldownSeconds: number): Promise<CooldownConsumeResult>;
}
export class MemoryCooldownStore implements CooldownStore {
private readonly cooldowns = new Map<string, number>();
private lastSweepAt = 0;
public async consume(
botId: string,
commandName: string,
subjectId: string,
cooldownSeconds: number,
): Promise<CooldownConsumeResult> {
if (cooldownSeconds <= 0) {
return { allowed: true, retryAfterSeconds: 0 };
}
const key = this.key(botId, commandName, subjectId);
const now = Date.now();
this.sweepExpired(now);
const expiresAt = this.cooldowns.get(key);
if (expiresAt !== undefined && expiresAt > now) {
return {
allowed: false,
retryAfterSeconds: Math.ceil((expiresAt - now) / 1000),
};
}
this.cooldowns.set(key, now + cooldownSeconds * 1000);
return { allowed: true, retryAfterSeconds: 0 };
}
private key(botId: string, commandName: string, subjectId: string): string {
return `${botId}:${commandName}:${subjectId}`;
}
private sweepExpired(now: number): void {
if (this.cooldowns.size === 0) {
return;
}
const shouldSweepBySize = this.cooldowns.size >= COOLDOWN_SWEEP_MIN_ENTRIES;
const shouldSweepByTime = now - this.lastSweepAt >= COOLDOWN_SWEEP_INTERVAL_MS;
if (!shouldSweepBySize && !shouldSweepByTime) {
return;
}
for (const [key, expiresAt] of this.cooldowns.entries()) {
if (expiresAt <= now) {
this.cooldowns.delete(key);
}
}
this.lastSweepAt = now;
}
}
export class RedisCooldownStore implements CooldownStore {
public constructor(
private readonly redis: Redis,
private readonly keyPrefix = "bot",
) {}
public async consume(
botId: string,
commandName: string,
subjectId: string,
cooldownSeconds: number,
): Promise<CooldownConsumeResult> {
if (cooldownSeconds <= 0) {
return { allowed: true, retryAfterSeconds: 0 };
}
const key = this.key(botId, commandName, subjectId);
const result = await this.redis.set(key, "1", "EX", cooldownSeconds, "NX");
if (result === "OK") {
return {
allowed: true,
retryAfterSeconds: 0,
};
}
let ttl = await this.redis.ttl(key);
if (ttl <= 0) {
await this.redis.expire(key, cooldownSeconds);
ttl = cooldownSeconds;
}
return {
allowed: false,
retryAfterSeconds: ttl,
};
}
private key(botId: string, commandName: string, subjectId: string): string {
return `${this.keyPrefix}:${botId}:cooldown:${commandName}:${subjectId}`;
}
}
@@ -0,0 +1,110 @@
import type {
BotCommand,
CommandArgValue,
CommandExecutionContext,
CommandSource,
SupportedLang,
} from "../../types/command.js";
import type { AppLogger } from "../logging/logger.js";
import type { CommandExecutor } from "./CommandExecutor.js";
export interface CommandDispatchPort {
dispatch(command: BotCommand, ctx: CommandExecutionContext): Promise<void>;
}
export type CommandDispatchMode = "local" | "worker";
export interface CommandExecutionJob {
botId: string;
requestId: string;
commandName: string;
source: CommandSource;
lang: SupportedLang;
actor: {
userId: string;
guildId: string | null;
channelId: string | null;
};
args: Record<string, unknown>;
queuedAt: number;
}
export interface CommandJobPublisher {
publish(job: CommandExecutionJob): Promise<void>;
}
export class LocalCommandDispatchPort implements CommandDispatchPort {
public constructor(private readonly executor: CommandExecutor) {}
public async dispatch(command: BotCommand, ctx: CommandExecutionContext): Promise<void> {
await this.executor.run(command, ctx);
}
}
export class WorkerCommandDispatchPort implements CommandDispatchPort {
public constructor(
private readonly publisher: CommandJobPublisher,
private readonly logger: AppLogger,
) {}
public async dispatch(command: BotCommand, ctx: CommandExecutionContext): Promise<void> {
const job = toExecutionJob(command, ctx);
await this.publisher.publish(job);
this.logger.info(
{
requestId: ctx.execution.requestId,
command: command.meta.name,
source: ctx.execution.source,
},
"command queued for worker execution",
);
await ctx.reply(ctx.t("errors.executionQueued"));
}
}
const toExecutionJob = (command: BotCommand, ctx: CommandExecutionContext): CommandExecutionJob => {
const botId = ctx.transport.client.user?.id;
if (!botId) {
throw new Error("runtime bot id unavailable for worker command dispatch");
}
const serializedArgs = Object.fromEntries(
Object.entries(ctx.execution.args).map(([key, value]) => [key, serializeArgValue(value)]),
);
return {
botId,
requestId: ctx.execution.requestId,
commandName: command.meta.name,
source: ctx.execution.source,
lang: ctx.i18nContext.lang,
actor: {
userId: ctx.execution.actor.userId,
guildId: ctx.execution.actor.guildId,
channelId: ctx.execution.actor.channelId,
},
args: serializedArgs,
queuedAt: Date.now(),
};
};
const serializeArgValue = (value: CommandArgValue): unknown => {
if (value === null || value === undefined) {
return value;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
if (typeof value === "object" && "id" in value && typeof value.id === "string") {
return {
id: value.id,
};
}
return String(value);
};
@@ -0,0 +1,164 @@
import type { Redis } from "ioredis";
export interface GlobalRateLimitPolicy {
limit: number;
windowSeconds: number;
}
export interface GlobalRateLimitDecision {
allowed: boolean;
retryAfterSeconds: number;
remaining: number;
limit: number;
}
export interface GlobalRateLimitStore {
consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision>;
}
interface WindowCounterEntry {
count: number;
resetAt: number;
}
export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore {
private readonly counters = new Map<string, WindowCounterEntry>();
public async consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision> {
const sanitized = sanitizePolicy(policy);
const now = Date.now();
const key = this.key(botId, userId, sanitized.windowSeconds);
const existing = this.counters.get(key);
if (!existing || existing.resetAt <= now) {
const resetAt = now + sanitized.windowSeconds * 1000;
this.counters.set(key, {
count: 1,
resetAt,
});
return {
allowed: true,
retryAfterSeconds: 0,
remaining: Math.max(0, sanitized.limit - 1),
limit: sanitized.limit,
};
}
existing.count += 1;
const retryAfterSeconds = Math.max(1, Math.ceil((existing.resetAt - now) / 1000));
const allowed = existing.count <= sanitized.limit;
return {
allowed,
retryAfterSeconds: allowed ? 0 : retryAfterSeconds,
remaining: Math.max(0, sanitized.limit - existing.count),
limit: sanitized.limit,
};
}
private key(botId: string, userId: string, windowSeconds: number): string {
return `${botId}:${userId}:${windowSeconds}`;
}
}
export class RedisGlobalRateLimitStore implements GlobalRateLimitStore {
private static readonly CONSUME_WINDOW_SCRIPT = `
local key = KEYS[1]
local window = tonumber(ARGV[1])
if not window or window <= 0 then
return redis.error_reply("invalid window")
end
local count = redis.call("INCR", key)
if count == 1 then
redis.call("EXPIRE", key, window)
return { count, window }
end
local ttl = redis.call("TTL", key)
if ttl < 0 then
redis.call("EXPIRE", key, window)
ttl = window
end
return { count, ttl }
`;
public constructor(
private readonly redis: Redis,
private readonly keyPrefix = "bot",
) {}
public async consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision> {
const sanitized = sanitizePolicy(policy);
const key = this.key(botId, userId);
const rawResult = await this.redis.eval(
RedisGlobalRateLimitStore.CONSUME_WINDOW_SCRIPT,
1,
key,
String(sanitized.windowSeconds),
);
const { count, retryAfterSeconds } = parseScriptResult(rawResult, sanitized.windowSeconds);
const allowed = count <= sanitized.limit;
return {
allowed,
retryAfterSeconds: allowed ? 0 : retryAfterSeconds,
remaining: Math.max(0, sanitized.limit - count),
limit: sanitized.limit,
};
}
private key(botId: string, userId: string): string {
return `${this.keyPrefix}:${botId}:ratelimit:user:${userId}`;
}
}
const sanitizePolicy = (policy: GlobalRateLimitPolicy): GlobalRateLimitPolicy => {
const limit = Number.isFinite(policy.limit) && policy.limit > 0 ? Math.floor(policy.limit) : 1;
const windowSeconds = Number.isFinite(policy.windowSeconds) && policy.windowSeconds > 0
? Math.floor(policy.windowSeconds)
: 1;
return {
limit,
windowSeconds,
};
};
const parseScriptResult = (rawResult: unknown, fallbackWindowSeconds: number): { count: number; retryAfterSeconds: number } => {
if (!Array.isArray(rawResult) || rawResult.length < 2) {
throw new Error("Redis rate limit script returned an unexpected payload");
}
const count = toPositiveInt(rawResult[0]);
if (!count) {
throw new Error("Redis rate limit script returned an invalid counter value");
}
const ttl = toPositiveInt(rawResult[1]) ?? fallbackWindowSeconds;
return {
count,
retryAfterSeconds: ttl,
};
};
const toPositiveInt = (value: unknown): number | null => {
const numeric = typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: Number.NaN;
if (!Number.isFinite(numeric)) {
return null;
}
const integer = Math.floor(numeric);
return integer > 0 ? integer : null;
};
@@ -0,0 +1,30 @@
import type { Redis } from "ioredis";
import type { CommandExecutionJob, CommandJobPublisher } from "./dispatch.js";
export class RedisCommandJobPublisher implements CommandJobPublisher {
public constructor(
private readonly redis: Redis,
private readonly queueName = "bot:${botId}:command-jobs",
) {}
public async publish(job: CommandExecutionJob): Promise<void> {
await this.redis.lpush(this.resolveQueueName(job.botId), JSON.stringify(job));
}
private resolveQueueName(botId: string): string {
if (this.queueName.includes("${botId}")) {
return this.queueName.replaceAll("${botId}", botId);
}
if (this.queueName === "bot:command-jobs") {
return `bot:${botId}:command-jobs`;
}
if (this.queueName.startsWith("bot:")) {
return this.queueName;
}
return `bot:${botId}:${this.queueName}`;
}
}
@@ -0,0 +1,27 @@
import { config as loadEnv } from "dotenv";
import pino, { type Logger, type LoggerOptions } from "pino";
const LOG_LEVEL_DEFAULT = "info";
loadEnv();
const resolveLogLevel = (): string => {
const value = process.env.LOG_LEVEL?.trim();
return value && value.length > 0 ? value : LOG_LEVEL_DEFAULT;
};
const options: LoggerOptions = {
level: resolveLogLevel(),
base: {
service: "discordjs-framework-template",
},
timestamp: pino.stdTimeFunctions.isoTime,
};
export const logger = pino(options);
export const createScopedLogger = (scope: string): Logger => {
return logger.child({ scope });
};
export type AppLogger = Logger;
@@ -0,0 +1,59 @@
import type { Pool } from "pg";
export interface LeaderCoordinator {
runIfLeader(lockName: string, botId: string, task: () => Promise<void>): Promise<boolean>;
}
export class LocalLeaderCoordinator implements LeaderCoordinator {
public async runIfLeader(_lockName: string, _botId: string, task: () => Promise<void>): Promise<boolean> {
await task();
return true;
}
}
export class PostgresLeaderCoordinator implements LeaderCoordinator {
public constructor(
private readonly pool: Pool,
private readonly namespace = "discord-bot",
) {}
public async runIfLeader(lockName: string, botId: string, task: () => Promise<void>): Promise<boolean> {
const advisoryLockKey = hashToInt32(`${this.namespace}:bot:${botId}:leader:${lockName}`);
const client = await this.pool.connect();
try {
const acquiredResult = await client.query<{ locked: boolean }>(
"SELECT pg_try_advisory_lock($1) AS locked",
[advisoryLockKey],
);
const acquired = acquiredResult.rows[0]?.locked ?? false;
if (!acquired) {
return false;
}
try {
await task();
return true;
} finally {
await client.query("SELECT pg_advisory_unlock($1)", [advisoryLockKey]);
}
} finally {
client.release();
}
}
}
const hashToInt32 = (value: string): number => {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
}
if (hash === 0) {
return 1;
}
return hash;
};
@@ -0,0 +1,26 @@
export interface DatabaseInitializable {
name: string;
init: () => Promise<void>;
}
export class DatabaseLifecycle {
public constructor(
private readonly initializables: DatabaseInitializable[],
private readonly shutdownFn: () => Promise<void>,
) {}
public async init(): Promise<void> {
for (const resource of this.initializables) {
try {
await resource.init();
} catch (error) {
await this.shutdownFn().catch(() => undefined);
throw new Error(`[db:init] ${resource.name} failed`, { cause: error });
}
}
}
public async shutdown(): Promise<void> {
await this.shutdownFn();
}
}
@@ -0,0 +1,129 @@
import type { Pool } from "pg";
import type {
LogEventConfig,
LogEventKey,
LogEventRepositoryEntry,
LogEventRow,
} from "../../types/logs.js";
import type { LogEventRepository } from "../../modules/logs/index.js";
const logEventSchemaProbeSql = `
SELECT
bot_id,
guild_id,
event_key,
enabled,
channel_id,
updated_at
FROM bot_log_event_configs
LIMIT 0;
`;
export class PostgresLogEventStore implements LogEventRepository {
public constructor(private readonly pool: Pool) {}
public async init(): Promise<void> {
try {
await this.pool.query(logEventSchemaProbeSql);
} catch (error) {
throw new Error(
"[db:init] missing or incompatible table \"bot_log_event_configs\". Run migrations with \"npm run migrate\".",
{ cause: error },
);
}
}
public async listByBotGuild(botId: string, guildId: string): Promise<LogEventRow[]> {
const result = await this.pool.query<LogEventRow>(
`
SELECT event_key, enabled, channel_id
FROM bot_log_event_configs
WHERE bot_id = $1 AND guild_id = $2
ORDER BY event_key ASC
`,
[botId, guildId],
);
return result.rows;
}
public async upsertByBotGuildEvent(
botId: string,
guildId: string,
eventKey: LogEventKey,
config: LogEventConfig,
): Promise<void> {
await this.pool.query(
`
INSERT INTO bot_log_event_configs (
bot_id,
guild_id,
event_key,
enabled,
channel_id,
updated_at
)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (bot_id, guild_id, event_key)
DO UPDATE SET
enabled = EXCLUDED.enabled,
channel_id = EXCLUDED.channel_id,
updated_at = NOW()
`,
[botId, guildId, eventKey, config.enabled, config.channelId],
);
}
public async upsertManyByBotGuildEvents(
botId: string,
guildId: string,
entries: readonly LogEventRepositoryEntry[],
): Promise<void> {
if (entries.length === 0) {
return;
}
const client = await this.pool.connect();
try {
await client.query("BEGIN");
for (const entry of entries) {
await client.query(
`
INSERT INTO bot_log_event_configs (
bot_id,
guild_id,
event_key,
enabled,
channel_id,
updated_at
)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (bot_id, guild_id, event_key)
DO UPDATE SET
enabled = EXCLUDED.enabled,
channel_id = EXCLUDED.channel_id,
updated_at = NOW()
`,
[botId, guildId, entry.eventKey, entry.config.enabled, entry.config.channelId],
);
}
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
}
public async deleteByBotGuild(botId: string, guildId: string): Promise<void> {
await this.pool.query(
"DELETE FROM bot_log_event_configs WHERE bot_id = $1 AND guild_id = $2",
[botId, guildId],
);
}
}
@@ -0,0 +1,130 @@
import type { Pool } from "pg";
import type {
MemberMessageConfig,
MemberMessageKind,
MemberMessageRow,
} from "../../types/memberMessages.js";
import {
createDefaultMemberMessageConfig,
isMemberMessageRenderTypeValue,
sanitizeMemberMessageRoleIds,
} from "../../validators/memberMessages.js";
import type { MemberMessageRepository } from "../../modules/memberMessages/index.js";
const memberMessageSchemaProbeSql = `
SELECT
bot_id,
guild_id,
kind,
enabled,
channel_id,
message_type,
auto_role_ids,
updated_at
FROM bot_member_message_configs
LIMIT 0;
`;
const parseRoleIds = (serialized: string | null): string[] => {
if (!serialized || serialized.trim().length === 0) {
return [];
}
try {
const parsed = JSON.parse(serialized) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
const roleIds = parsed.filter((value): value is string => typeof value === "string");
return sanitizeMemberMessageRoleIds(roleIds);
} catch {
return [];
}
};
const toConfig = (row: MemberMessageRow): MemberMessageConfig => {
const fallback = createDefaultMemberMessageConfig();
return {
enabled: row.enabled,
channelId: row.channel_id,
messageType: isMemberMessageRenderTypeValue(row.message_type) ? row.message_type : fallback.messageType,
autoRoleIds: parseRoleIds(row.auto_role_ids),
};
};
export class PostgresMemberMessageStore implements MemberMessageRepository {
public constructor(private readonly pool: Pool) {}
public async init(): Promise<void> {
try {
await this.pool.query(memberMessageSchemaProbeSql);
} catch (error) {
throw new Error(
"[db:init] missing or incompatible table \"bot_member_message_configs\". Run migrations with \"npm run migrate\".",
{ cause: error },
);
}
}
public async getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise<MemberMessageConfig> {
const result = await this.pool.query<MemberMessageRow>(
`
SELECT enabled, channel_id, message_type, auto_role_ids
FROM bot_member_message_configs
WHERE bot_id = $1 AND guild_id = $2 AND kind = $3
LIMIT 1
`,
[botId, guildId, kind],
);
const row = result.rows[0];
if (!row) {
return createDefaultMemberMessageConfig();
}
return toConfig(row);
}
public async upsertByBotGuildKind(
botId: string,
guildId: string,
kind: MemberMessageKind,
config: MemberMessageConfig,
): Promise<void> {
const autoRoleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds);
await this.pool.query(
`
INSERT INTO bot_member_message_configs (
bot_id,
guild_id,
kind,
enabled,
channel_id,
message_type,
auto_role_ids,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (bot_id, guild_id, kind)
DO UPDATE SET
enabled = EXCLUDED.enabled,
channel_id = EXCLUDED.channel_id,
message_type = EXCLUDED.message_type,
auto_role_ids = EXCLUDED.auto_role_ids,
updated_at = NOW()
`,
[botId, guildId, kind, config.enabled, config.channelId, config.messageType, JSON.stringify(autoRoleIds)],
);
}
public async deleteByBotGuild(botId: string, guildId: string): Promise<void> {
await this.pool.query(
"DELETE FROM bot_member_message_configs WHERE bot_id = $1 AND guild_id = $2",
[botId, guildId],
);
}
}
@@ -0,0 +1,140 @@
import type { Pool } from "pg";
import type {
PresenceActivityTypeValue,
PresenceRow,
PresenceState,
PresenceStatusValue,
} from "../../types/presence.js";
import {
DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS,
createDefaultPresenceState,
isPresenceActivityTypeValue,
isPresenceStatusValue,
sanitizeActivityText,
sanitizeActivityTexts,
sanitizePresenceRotationIntervalSeconds,
} from "../../validators/presence.js";
import type { PresenceRepository } from "../../modules/presence/index.js";
const presenceSchemaProbeSql = `
SELECT
bot_id,
status,
activity_type,
activity_text,
activity_texts,
rotation_interval_seconds,
updated_at
FROM bot_presence_states
LIMIT 0;
`;
const parseStoredTexts = (rawTexts: string | null, fallbackText: string): string[] => {
if (typeof rawTexts === "string" && rawTexts.trim().length > 0) {
try {
const parsed = JSON.parse(rawTexts) as unknown;
if (Array.isArray(parsed)) {
const stringValues = parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (stringValues.length > 0) {
return sanitizeActivityTexts(stringValues);
}
}
} catch {
// Fallback to legacy single text when malformed JSON is encountered.
}
}
return sanitizeActivityTexts([fallbackText]);
};
const toPresenceState = (row: PresenceRow): PresenceState | null => {
if (!isPresenceStatusValue(row.status) || !isPresenceActivityTypeValue(row.activity_type)) {
return null;
}
const texts = parseStoredTexts(row.activity_texts, row.activity_text);
const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(
row.rotation_interval_seconds ?? DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS,
);
return {
status: row.status as PresenceStatusValue,
activity: {
type: row.activity_type as PresenceActivityTypeValue,
text: texts[0] ?? sanitizeActivityText(row.activity_text),
texts,
rotationIntervalSeconds,
},
};
};
export class PostgresPresenceStore implements PresenceRepository {
public constructor(private readonly pool: Pool) {}
public async init(): Promise<void> {
try {
await this.pool.query(presenceSchemaProbeSql);
} catch (error) {
throw new Error(
"[db:init] missing or incompatible table \"bot_presence_states\". Run migrations with \"npm run migrate\".",
{ cause: error },
);
}
}
public async getByBotId(botId: string): Promise<PresenceState> {
const result = await this.pool.query<PresenceRow>(
"SELECT status, activity_type, activity_text, activity_texts, rotation_interval_seconds FROM bot_presence_states WHERE bot_id = $1 LIMIT 1",
[botId],
);
const row = result.rows[0];
if (!row) {
return createDefaultPresenceState();
}
return toPresenceState(row) ?? createDefaultPresenceState();
}
public async upsertByBotId(botId: string, state: PresenceState): Promise<void> {
const activityTexts = sanitizeActivityTexts(state.activity.texts);
const primaryText = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
await this.pool.query(
`
INSERT INTO bot_presence_states (
bot_id,
status,
activity_type,
activity_text,
activity_texts,
rotation_interval_seconds,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (bot_id)
DO UPDATE SET
status = EXCLUDED.status,
activity_type = EXCLUDED.activity_type,
activity_text = EXCLUDED.activity_text,
activity_texts = EXCLUDED.activity_texts,
rotation_interval_seconds = EXCLUDED.rotation_interval_seconds,
updated_at = NOW()
`,
[
botId,
state.status,
state.activity.type,
primaryText,
JSON.stringify(activityTexts),
rotationIntervalSeconds,
],
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Events, type Client } from "discord.js";
import { createScopedLogger } from "../core/logging/logger.js";
const log = createScopedLogger("event:guildCreate");
/**
* Enregistre le listener `guildCreate` (bot ajouté à un serveur).
*
* Action minimale: log pour monitoring ; peut être étendu (initialisation de configs, etc.).
*/
export const registerGuildCreate = (client: Client): void => {
client.on(Events.GuildCreate, (guild) => {
log.info({ guildId: guild.id, guildName: guild.name }, "joined guild");
});
};
+29
View File
@@ -0,0 +1,29 @@
import { Events, type Client } from "discord.js";
import { createScopedLogger } from "../core/logging/logger.js";
import type { LogEventService } from "../modules/logs/index.js";
import type { MemberMessageService } from "../modules/memberMessages/index.js";
const log = createScopedLogger("event:guildDelete");
export const registerGuildDelete = (
client: Client,
memberMessageService: MemberMessageService,
logEventService: LogEventService,
): void => {
client.on(Events.GuildDelete, (guild) => {
log.info({ guildId: guild.id, guildName: guild.name }, "left guild");
const botId = logEventService.resolveBotId(client);
if (!botId) {
return;
}
void Promise.all([
memberMessageService.cleanupGuild(botId, guild.id),
logEventService.cleanupGuild(botId, guild.id),
]).catch((error) => {
log.error({ guildId: guild.id, botId, err: error }, "failed to cleanup guild config");
});
});
};
@@ -0,0 +1,61 @@
import { Events, type Client } from "discord.js";
import { createScopedLogger } from "../core/logging/logger.js";
import type { I18nService } from "../i18n/index.js";
import type { MemberMessageService } from "../modules/memberMessages/index.js";
const log = createScopedLogger("event:guildMemberAdd");
export const registerGuildMemberAdd = (
client: Client,
i18n: I18nService,
memberMessageService: MemberMessageService,
): void => {
client.on(Events.GuildMemberAdd, (member) => {
void memberMessageService.assignWelcomeAutoRoles({ client, member }).then((result) => {
if (result.assigned) {
return;
}
if (result.reason === "no_roles_configured" || result.reason === "no_assignable_roles") {
return;
}
log.warn(
{
guildId: member.guild.id,
userId: member.user.id,
reason: result.reason,
configuredRoleIds: result.configuredRoleIds,
},
"failed to assign welcome auto roles",
);
}).catch((error) => {
log.error(
{
guildId: member.guild.id,
userId: member.user.id,
err: error,
},
"welcome auto-role dispatch crashed",
);
});
void memberMessageService.dispatch({
client,
i18n,
guild: member.guild,
user: member.user,
kind: "welcome",
}).catch((error) => {
log.error(
{
guildId: member.guild.id,
userId: member.user.id,
err: error,
},
"failed to send welcome message",
);
});
});
};
@@ -0,0 +1,32 @@
import { Events, type Client } from "discord.js";
import { createScopedLogger } from "../core/logging/logger.js";
import type { I18nService } from "../i18n/index.js";
import type { MemberMessageService } from "../modules/memberMessages/index.js";
const log = createScopedLogger("event:guildMemberRemove");
export const registerGuildMemberRemove = (
client: Client,
i18n: I18nService,
memberMessageService: MemberMessageService,
): void => {
client.on(Events.GuildMemberRemove, (member) => {
void memberMessageService.dispatch({
client,
i18n,
guild: member.guild,
user: member.user,
kind: "goodbye",
}).catch((error) => {
log.error(
{
guildId: member.guild.id,
userId: member.user.id,
err: error,
},
"failed to send goodbye message",
);
});
});
};
+40
View File
@@ -0,0 +1,40 @@
import type { ChatInputCommandInteraction, Client, Message } from "discord.js";
import type { AppFeatureServices } from "../app/container.js";
import type { CommandRegistry } from "../core/commands/registry.js";
import type { LeaderCoordinator } from "../core/runtime/leaderCoordinator.js";
import type { I18nService } from "../i18n/index.js";
import { registerGuildCreate } from "./guildCreate.js";
import { registerGuildDelete } from "./guildDelete.js";
import { registerGuildMemberAdd } from "./guildMemberAdd.js";
import { registerGuildMemberRemove } from "./guildMemberRemove.js";
import { registerInteractionCreate } from "./interactionCreate.js";
import { registerLogRuntimeEvents } from "./logsRuntime.js";
import { registerMessageCreate } from "./messageCreate.js";
import type { RuntimeBotAuth } from "./ready.js";
import { registerClientReady } from "./ready.js";
export const registerEvents = (
client: Client,
i18n: I18nService,
handlers: {
onPrefixMessage: (m: Message) => Promise<void>;
onSlashInteraction: (i: ChatInputCommandInteraction) => Promise<void>;
},
registry: CommandRegistry,
services: AppFeatureServices,
leaderCoordinator: LeaderCoordinator,
runtimeAuth?: RuntimeBotAuth,
): void => {
registerMessageCreate(client, handlers.onPrefixMessage);
registerInteractionCreate(client, handlers.onSlashInteraction);
registerLogRuntimeEvents(client, services.logEventService);
registerGuildMemberAdd(client, i18n, services.memberMessageService);
registerGuildMemberRemove(client, i18n, services.memberMessageService);
registerGuildCreate(client);
registerGuildDelete(client, services.memberMessageService, services.logEventService);
registerClientReady(client, registry, i18n, services.presenceService, leaderCoordinator, runtimeAuth);
};
@@ -0,0 +1,25 @@
import { Events, type Client, type ChatInputCommandInteraction } from "discord.js";
import { createScopedLogger } from "../core/logging/logger.js";
const log = createScopedLogger("event:interactionCreate");
/**
* Enregistre le listener `interactionCreate` pour les commandes slash (chat input).
*
* @param client - instance du Client Discord
* @param onSlashInteraction - fonction à appeler pour traiter les interactions slash
*/
export const registerInteractionCreate = (
client: Client,
onSlashInteraction: (interaction: ChatInputCommandInteraction) => Promise<void>,
): void => {
client.on(Events.InteractionCreate, (interaction) => {
if (!interaction.isChatInputCommand()) {
return;
}
void onSlashInteraction(interaction).catch((error) => {
log.error({ err: error }, "handler failed");
});
});
};
+465
View File
@@ -0,0 +1,465 @@
import {
Events,
type Client,
} from "discord.js";
import { createScopedLogger } from "../core/logging/logger.js";
import type { LogEventKey } from "../types/logs.js";
import type { LogEventService } from "../modules/logs/index.js";
const logger = createScopedLogger("event:logsRuntime");
const clamp = (value: string, maxLength: number): string => {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 3)}...`;
};
const formatContent = (content: string | null | undefined): string => {
if (typeof content !== "string") {
return "(no content)";
}
const trimmed = content.trim();
if (trimmed.length === 0) {
return "(no content)";
}
return clamp(trimmed, 400);
};
const emitRuntimeLog = (
client: Client,
logEventService: LogEventService,
eventKey: LogEventKey,
guildId: string | null | undefined,
summary: string,
details: string[] = [],
color?: number,
): void => {
if (!guildId) {
return;
}
void logEventService.dispatchEvent(client, {
eventKey,
guildId,
summary,
...(details.length > 0 ? { details } : {}),
...(color !== undefined ? { color } : {}),
}).catch((error) => {
logger.error({ eventKey, guildId, err: error }, "failed to dispatch runtime log event");
});
};
export const registerLogRuntimeEvents = (client: Client, logEventService: LogEventService): void => {
client.on(Events.MessageCreate, (message) => {
if (!message.guild || message.author.bot) {
return;
}
emitRuntimeLog(
client,
logEventService,
"messageCreate",
message.guild.id,
`Message sent by <@${message.author.id}> in <#${message.channelId}>`,
[
`messageId=${message.id}`,
`author=${message.author.tag} (${message.author.id})`,
`content=${formatContent(message.content)}`,
],
0x57f287,
);
});
client.on(Events.MessageDelete, (message) => {
const authorId = message.author?.id ?? "unknown";
const authorTag = message.author?.tag ?? "unknown";
emitRuntimeLog(
client,
logEventService,
"messageDelete",
message.guild?.id,
`Message deleted in <#${message.channelId}>`,
[
`messageId=${message.id}`,
`author=${authorTag} (${authorId})`,
`content=${formatContent(message.content)}`,
],
0xed4245,
);
});
client.on(Events.MessageUpdate, (oldMessage, newMessage) => {
emitRuntimeLog(
client,
logEventService,
"messageUpdate",
newMessage.guild?.id,
`Message updated in <#${newMessage.channelId}>`,
[
`messageId=${newMessage.id}`,
`before=${formatContent(oldMessage.content)}`,
`after=${formatContent(newMessage.content)}`,
],
0xfee75c,
);
});
client.on(Events.MessageBulkDelete, (messages) => {
const first = messages.first();
emitRuntimeLog(
client,
logEventService,
"messageBulkDelete",
first?.guild?.id,
`Bulk delete: ${messages.size} messages removed`,
[
`channelId=${first?.channelId ?? "unknown"}`,
`count=${messages.size}`,
],
0xed4245,
);
});
client.on(Events.GuildMemberAdd, (member) => {
emitRuntimeLog(
client,
logEventService,
"guildMemberAdd",
member.guild.id,
`Member joined: <@${member.user.id}>`,
[`user=${member.user.tag} (${member.user.id})`],
0x57f287,
);
});
client.on(Events.GuildMemberRemove, (member) => {
emitRuntimeLog(
client,
logEventService,
"guildMemberRemove",
member.guild.id,
`Member left: <@${member.user.id}>`,
[`user=${member.user.tag} (${member.user.id})`],
0xed4245,
);
});
client.on(Events.GuildMemberUpdate, (oldMember, newMember) => {
emitRuntimeLog(
client,
logEventService,
"guildMemberUpdate",
newMember.guild.id,
`Member updated: <@${newMember.user.id}>`,
[
`user=${newMember.user.tag} (${newMember.user.id})`,
`oldNick=${oldMember.nickname ?? "none"}`,
`newNick=${newMember.nickname ?? "none"}`,
],
0x5865f2,
);
});
client.on(Events.InteractionCreate, (interaction) => {
if (!interaction.guildId || interaction.user.bot) {
return;
}
let summary = `Interaction by <@${interaction.user.id}>`;
if (interaction.isChatInputCommand()) {
summary = `Slash command /${interaction.commandName} by <@${interaction.user.id}>`;
} else if (interaction.isButton()) {
summary = `Button interaction by <@${interaction.user.id}>`;
} else if (interaction.isStringSelectMenu()) {
summary = `Select menu interaction by <@${interaction.user.id}>`;
} else if (interaction.isModalSubmit()) {
summary = `Modal submit by <@${interaction.user.id}>`;
}
emitRuntimeLog(
client,
logEventService,
"interactionCreate",
interaction.guildId,
summary,
[
`interactionId=${interaction.id}`,
`channelId=${interaction.channelId ?? "unknown"}`,
],
0x5865f2,
);
});
client.on(Events.ChannelCreate, (channel) => {
if (!("guild" in channel) || !("name" in channel)) {
return;
}
emitRuntimeLog(
client,
logEventService,
"channelCreate",
channel.guild.id,
`Channel created: #${channel.name}`,
[
`channelId=${channel.id}`,
`type=${channel.type}`,
],
0x57f287,
);
});
client.on(Events.ChannelDelete, (channel) => {
if (!("guild" in channel) || !("name" in channel)) {
return;
}
emitRuntimeLog(
client,
logEventService,
"channelDelete",
channel.guild.id,
`Channel deleted: #${channel.name}`,
[
`channelId=${channel.id}`,
`type=${channel.type}`,
],
0xed4245,
);
});
client.on(Events.ChannelUpdate, (oldChannel, newChannel) => {
if (!("guild" in newChannel) || !("name" in newChannel)) {
return;
}
const oldName = "name" in oldChannel ? oldChannel.name : "unknown";
emitRuntimeLog(
client,
logEventService,
"channelUpdate",
newChannel.guild.id,
`Channel updated: #${newChannel.name}`,
[
`channelId=${newChannel.id}`,
`oldName=${oldName}`,
`newName=${newChannel.name}`,
],
0xfee75c,
);
});
client.on(Events.GuildRoleCreate, (role) => {
emitRuntimeLog(
client,
logEventService,
"roleCreate",
role.guild.id,
`Role created: @${role.name}`,
[`roleId=${role.id}`],
0x57f287,
);
});
client.on(Events.GuildRoleDelete, (role) => {
emitRuntimeLog(
client,
logEventService,
"roleDelete",
role.guild.id,
`Role deleted: @${role.name}`,
[`roleId=${role.id}`],
0xed4245,
);
});
client.on(Events.GuildRoleUpdate, (oldRole, newRole) => {
emitRuntimeLog(
client,
logEventService,
"roleUpdate",
newRole.guild.id,
`Role updated: @${newRole.name}`,
[
`roleId=${newRole.id}`,
`oldName=${oldRole.name}`,
`newName=${newRole.name}`,
],
0xfee75c,
);
});
client.on(Events.ThreadCreate, (thread) => {
emitRuntimeLog(
client,
logEventService,
"threadCreate",
thread.guild?.id,
`Thread created: ${thread.name}`,
[`threadId=${thread.id}`],
0x57f287,
);
});
client.on(Events.ThreadDelete, (thread) => {
emitRuntimeLog(
client,
logEventService,
"threadDelete",
thread.guild?.id,
`Thread deleted: ${thread.name}`,
[`threadId=${thread.id}`],
0xed4245,
);
});
client.on(Events.ThreadUpdate, (oldThread, newThread) => {
emitRuntimeLog(
client,
logEventService,
"threadUpdate",
newThread.guild?.id,
`Thread updated: ${newThread.name}`,
[
`threadId=${newThread.id}`,
`oldName=${oldThread.name}`,
`newName=${newThread.name}`,
],
0xfee75c,
);
});
client.on(Events.GuildEmojiCreate, (emoji) => {
emitRuntimeLog(
client,
logEventService,
"emojiCreate",
emoji.guild?.id,
`Emoji created: ${emoji.name ?? "unknown"}`,
[`emojiId=${emoji.id ?? "unknown"}`],
0x57f287,
);
});
client.on(Events.GuildEmojiDelete, (emoji) => {
emitRuntimeLog(
client,
logEventService,
"emojiDelete",
emoji.guild?.id,
`Emoji deleted: ${emoji.name ?? "unknown"}`,
[`emojiId=${emoji.id ?? "unknown"}`],
0xed4245,
);
});
client.on(Events.GuildEmojiUpdate, (oldEmoji, newEmoji) => {
emitRuntimeLog(
client,
logEventService,
"emojiUpdate",
newEmoji.guild?.id,
`Emoji updated: ${newEmoji.name ?? "unknown"}`,
[
`emojiId=${newEmoji.id ?? "unknown"}`,
`oldName=${oldEmoji.name ?? "unknown"}`,
`newName=${newEmoji.name ?? "unknown"}`,
],
0xfee75c,
);
});
client.on(Events.GuildUpdate, (oldGuild, newGuild) => {
emitRuntimeLog(
client,
logEventService,
"guildUpdate",
newGuild.id,
`Guild updated: ${newGuild.name}`,
[
`guildId=${newGuild.id}`,
`oldName=${oldGuild.name}`,
`newName=${newGuild.name}`,
],
0xfee75c,
);
});
client.on(Events.GuildUnavailable, (guild) => {
emitRuntimeLog(
client,
logEventService,
"guildUnavailable",
guild.id,
`Guild temporarily unavailable: ${guild.name}`,
[`guildId=${guild.id}`],
0xed4245,
);
});
client.on(Events.GuildBanAdd, (ban) => {
emitRuntimeLog(
client,
logEventService,
"guildBanAdd",
ban.guild.id,
`User banned: <@${ban.user.id}>`,
[
`user=${ban.user.tag} (${ban.user.id})`,
`guildId=${ban.guild.id}`,
],
0xed4245,
);
});
client.on(Events.GuildBanRemove, (ban) => {
emitRuntimeLog(
client,
logEventService,
"guildBanRemove",
ban.guild.id,
`User unbanned: <@${ban.user.id}>`,
[
`user=${ban.user.tag} (${ban.user.id})`,
`guildId=${ban.guild.id}`,
],
0x57f287,
);
});
client.on(Events.InviteCreate, (invite) => {
emitRuntimeLog(
client,
logEventService,
"inviteCreate",
invite.guild?.id,
`Invite created: ${invite.code}`,
[
`channelId=${invite.channelId ?? "unknown"}`,
`inviter=${invite.inviter?.tag ?? "unknown"}`,
],
0x57f287,
);
});
client.on(Events.InviteDelete, (invite) => {
emitRuntimeLog(
client,
logEventService,
"inviteDelete",
invite.guild?.id,
`Invite deleted: ${invite.code}`,
[
`channelId=${invite.channelId ?? "unknown"}`,
],
0xed4245,
);
});
};
@@ -0,0 +1,18 @@
import { Events, type Client, type Message } from "discord.js";
import { createScopedLogger } from "../core/logging/logger.js";
const log = createScopedLogger("event:messageCreate");
/**
* Enregistre le listener `messageCreate` en déléguant au handler fourni.
*
* @param client - instance du Client Discord
* @param onPrefixMessage - fonction à appeler pour traiter les messages (préfixe)
*/
export const registerMessageCreate = (client: Client, onPrefixMessage: (message: Message) => Promise<void>): void => {
client.on(Events.MessageCreate, (message: Message) => {
void onPrefixMessage(message).catch((error) => {
log.error({ err: error }, "handler failed");
});
});
};
+80
View File
@@ -0,0 +1,80 @@
import { Events, type Client } from "discord.js";
import { env } from "../config/env.js";
import { deployApplicationCommands } from "../core/commands/deploy.js";
import { createScopedLogger } from "../core/logging/logger.js";
import type { LeaderCoordinator } from "../core/runtime/leaderCoordinator.js";
import type { CommandRegistry } from "../core/commands/registry.js";
import type { I18nService } from "../i18n/index.js";
import type { PresenceService } from "../modules/presence/index.js";
const log = createScopedLogger("event:ready");
export interface RuntimeBotAuth {
token: string;
clientId: string;
}
export const registerClientReady = (
client: Client,
registry: CommandRegistry,
i18n: I18nService,
presenceService: PresenceService,
leaderCoordinator: LeaderCoordinator,
runtimeAuth?: RuntimeBotAuth,
): void => {
client.once(Events.ClientReady, async () => {
log.info({ botTag: client.user?.tag ?? "unknown" }, "client ready");
const botId = client.user?.id;
if (!botId) {
log.error("client ready event received without bot id, leader-only tasks skipped");
return;
}
try {
const restoredByLeader = await leaderCoordinator.runIfLeader("presence-restore", botId, async () => {
await presenceService.restoreFromStorage(client);
});
if (!restoredByLeader) {
log.info("presence restore skipped: leader lock already held by another instance");
}
} catch (error) {
log.error({ err: error }, "failed to restore bot presence");
}
if (env.AUTO_DEPLOY_SLASH) {
if (!runtimeAuth) {
log.warn("slash sync skipped: runtime auth is missing for this bot instance");
return;
}
try {
const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", botId, async () => {
const result = await deployApplicationCommands({
token: runtimeAuth.token,
clientId: runtimeAuth.clientId,
registry,
i18n,
...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}),
});
log.info(
{
scope: result.scope,
count: result.count,
},
"slash sync completed",
);
});
if (!deployedByLeader) {
log.info("slash sync skipped: leader lock already held by another instance");
}
} catch (error) {
log.error({ err: error }, "slash sync failed");
}
}
});
};
@@ -0,0 +1,54 @@
import type {
LogEventCategoryKey,
LogEventDefinition,
} from "../../types/logs.js";
export const LOG_EVENT_DEFINITIONS: readonly LogEventDefinition[] = [
{ key: "messageCreate", category: "message" },
{ key: "messageDelete", category: "message" },
{ key: "messageUpdate", category: "message" },
{ key: "messageBulkDelete", category: "message" },
{ key: "guildMemberAdd", category: "member" },
{ key: "guildMemberRemove", category: "member" },
{ key: "guildMemberUpdate", category: "member" },
{ key: "interactionCreate", category: "interaction" },
{ key: "channelCreate", category: "channel" },
{ key: "channelDelete", category: "channel" },
{ key: "channelUpdate", category: "channel" },
{ key: "roleCreate", category: "role" },
{ key: "roleDelete", category: "role" },
{ key: "roleUpdate", category: "role" },
{ key: "threadCreate", category: "thread" },
{ key: "threadDelete", category: "thread" },
{ key: "threadUpdate", category: "thread" },
{ key: "emojiCreate", category: "emoji" },
{ key: "emojiDelete", category: "emoji" },
{ key: "emojiUpdate", category: "emoji" },
{ key: "guildUpdate", category: "guild" },
{ key: "guildUnavailable", category: "guild" },
{ key: "guildBanAdd", category: "moderation" },
{ key: "guildBanRemove", category: "moderation" },
{ key: "inviteCreate", category: "invite" },
{ key: "inviteDelete", category: "invite" },
];
export const LOG_EVENT_KEYS = LOG_EVENT_DEFINITIONS.map((definition) => definition.key);
export const LOG_EVENT_KEYS_SET = new Set(LOG_EVENT_KEYS);
export const LOG_CHANNEL_NAME_BY_CATEGORY: Record<LogEventCategoryKey, string> = {
message: "logs-message",
member: "logs-member",
interaction: "logs-interaction",
channel: "logs-channel",
role: "logs-role",
thread: "logs-thread",
emoji: "logs-emoji",
guild: "logs-guild",
moderation: "logs-moderation",
invite: "logs-invite",
};
export const LOG_EVENT_CATEGORY_KEYS = Object.keys(LOG_CHANNEL_NAME_BY_CATEGORY) as LogEventCategoryKey[];
export const LOGS_PANEL_EVENTS_PER_PAGE = 15;
@@ -0,0 +1,372 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ContainerBuilder,
MessageFlags,
TextDisplayBuilder,
} from "discord.js";
import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js";
import { createScopedLogger } from "../../core/logging/logger.js";
import type { CommandExecutionContext } from "../../types/command.js";
import type {
LogEventDefinition,
LogEventKey,
LogEventStateByKey,
LogPanelCustomIds,
} from "../../types/logs.js";
import {
LOG_EVENT_DEFINITIONS,
LOG_EVENT_KEYS,
LOGS_PANEL_EVENTS_PER_PAGE,
} from "./catalog.js";
import type { LogEventService } from "./service.js";
const logger = createScopedLogger("command:logs");
interface LogPanelUiState {
pageIndex: number;
feedback: string | null;
}
const chunk = <T>(items: readonly T[], size: number): T[][] => {
const output: T[][] = [];
for (let index = 0; index < items.length; index += size) {
output.push(items.slice(index, index + size));
}
return output;
};
const createCustomIds = (): LogPanelCustomIds => {
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
const toggleButtonsByEvent = {} as Record<LogEventKey, string>;
for (const eventKey of LOG_EVENT_KEYS) {
toggleButtonsByEvent[eventKey] = `logs:toggle:${eventKey}:${nonce}`;
}
return {
enableAllButton: `logs:enable-all:${nonce}`,
disableAllButton: `logs:disable-all:${nonce}`,
createChannelsButton: `logs:create-channels:${nonce}`,
previousPageButton: `logs:page-prev:${nonce}`,
nextPageButton: `logs:page-next:${nonce}`,
toggleButtonsByEvent,
};
};
const clampPageIndex = (index: number, pageCount: number): number => {
if (index < 0) {
return 0;
}
if (index >= pageCount) {
return pageCount - 1;
}
return index;
};
const pageCount = (): number => Math.max(1, Math.ceil(LOG_EVENT_DEFINITIONS.length / LOGS_PANEL_EVENTS_PER_PAGE));
const pageEvents = (uiState: LogPanelUiState): LogEventDefinition[] => {
const totalPages = pageCount();
uiState.pageIndex = clampPageIndex(uiState.pageIndex, totalPages);
const start = uiState.pageIndex * LOGS_PANEL_EVENTS_PER_PAGE;
return LOG_EVENT_DEFINITIONS.slice(start, start + LOGS_PANEL_EVENTS_PER_PAGE);
};
const eventStatusLabel = (ctx: CommandExecutionContext, enabled: boolean): string => {
return enabled ? ctx.ct("ui.status.enabled") : ctx.ct("ui.status.disabled");
};
const panelContent = (
ctx: CommandExecutionContext,
state: LogEventStateByKey,
uiState: LogPanelUiState,
): string => {
const totalPages = pageCount();
const currentEvents = pageEvents(uiState);
const enabledCount = LOG_EVENT_KEYS.filter((eventKey) => state[eventKey].enabled).length;
const lines = [
`## ${ctx.ct("ui.embed.title")}`,
ctx.ct("ui.embed.description"),
"",
ctx.ct("ui.pageLabel", { page: uiState.pageIndex + 1, pageCount: totalPages }),
ctx.ct("ui.enabledCountLabel", { enabledCount, totalCount: LOG_EVENT_KEYS.length }),
"",
`${ctx.ct("ui.eventsHeader")}:`,
];
for (const definition of currentEvents) {
const eventConfig = state[definition.key];
const channelDisplay = eventConfig.channelId ? `<#${eventConfig.channelId}>` : ctx.ct("ui.channelNotConfigured");
lines.push(`- ${definition.key} | ${eventStatusLabel(ctx, eventConfig.enabled)} | ${ctx.ct("ui.channelLabel")}: ${channelDisplay}`);
}
if (uiState.feedback) {
lines.push("", `${ctx.ct("ui.feedbackLabel")}: ${uiState.feedback}`);
}
return lines.join("\n");
};
const buildContainer = (
ctx: CommandExecutionContext,
state: LogEventStateByKey,
customIds: LogPanelCustomIds,
uiState: LogPanelUiState,
disabled = false,
): ContainerBuilder => {
const totalPages = pageCount();
const currentEvents = pageEvents(uiState);
const enableAllButton = new ButtonBuilder()
.setCustomId(customIds.enableAllButton)
.setLabel(ctx.ct("ui.buttons.enableAll"))
.setStyle(ButtonStyle.Success)
.setDisabled(disabled);
const disableAllButton = new ButtonBuilder()
.setCustomId(customIds.disableAllButton)
.setLabel(ctx.ct("ui.buttons.disableAll"))
.setStyle(ButtonStyle.Danger)
.setDisabled(disabled);
const createChannelsButton = new ButtonBuilder()
.setCustomId(customIds.createChannelsButton)
.setLabel(ctx.ct("ui.buttons.createChannels"))
.setStyle(ButtonStyle.Primary)
.setDisabled(disabled);
const previousPageButton = new ButtonBuilder()
.setCustomId(customIds.previousPageButton)
.setLabel(ctx.ct("ui.buttons.previousPage"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled || uiState.pageIndex <= 0);
const nextPageButton = new ButtonBuilder()
.setCustomId(customIds.nextPageButton)
.setLabel(ctx.ct("ui.buttons.nextPage"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled || uiState.pageIndex >= totalPages - 1);
const container = new ContainerBuilder();
container.addTextDisplayComponents(new TextDisplayBuilder().setContent(panelContent(ctx, state, uiState)));
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(enableAllButton, disableAllButton, createChannelsButton),
);
for (const definitionChunk of chunk(currentEvents, 5)) {
const rowButtons = definitionChunk.map((definition) => {
const eventConfig = state[definition.key];
const label = eventConfig.enabled
? ctx.ct("ui.buttons.disableEvent", { event: definition.key })
: ctx.ct("ui.buttons.enableEvent", { event: definition.key });
return new ButtonBuilder()
.setCustomId(customIds.toggleButtonsByEvent[definition.key])
.setLabel(label)
.setStyle(eventConfig.enabled ? ButtonStyle.Danger : ButtonStyle.Success)
.setDisabled(disabled);
});
container.addActionRowComponents(new ActionRowBuilder<ButtonBuilder>().addComponents(...rowButtons));
}
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(previousPageButton, nextPageButton),
);
return container;
};
const applyAllEnabled = (state: LogEventStateByKey, enabled: boolean): void => {
for (const eventKey of LOG_EVENT_KEYS) {
state[eventKey].enabled = enabled;
}
};
const toToggleLookup = (customIds: LogPanelCustomIds): Map<string, LogEventKey> => {
return new Map(
LOG_EVENT_KEYS.map((eventKey) => [customIds.toggleButtonsByEvent[eventKey], eventKey]),
);
};
export const createLogsCommandExecute = (logEventService: LogEventService) => {
return async (ctx: CommandExecutionContext): Promise<void> => {
if (!ctx.guild) {
await ctx.reply(ctx.ct("responses.guildOnly"));
return;
}
const guild = ctx.guild;
const state = await logEventService.loadGuildState(ctx.client, guild.id);
const customIds = createCustomIds();
const toggleLookup = toToggleLookup(customIds);
const uiState: LogPanelUiState = {
pageIndex: 0,
feedback: null,
};
const replyResult = await ctx.reply({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, uiState)],
withResponse: true,
});
const replyMessage = resolveReplyMessage(replyResult);
if (!replyMessage) {
return;
}
const ownerId = ctx.user.id;
const sessionKey = logEventService.panelSessionKey(ctx.client, guild.id, ownerId);
const disablePanel = async (): Promise<void> => {
await replyMessage
.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, uiState, true)],
})
.catch(() => undefined);
};
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
await logEventService.replacePanelSession(sessionKey, { collector, disable: disablePanel });
collector.on("collect", async (interaction) => {
if (interaction.user.id !== ownerId) {
await interaction.reply({
content: ctx.ct("responses.notOwner"),
flags: [MessageFlags.Ephemeral],
});
return;
}
try {
if (!interaction.isButton()) {
await interaction.reply({
content: ctx.ct("responses.invalidSelection"),
flags: [MessageFlags.Ephemeral],
});
return;
}
if (interaction.customId === customIds.enableAllButton) {
applyAllEnabled(state, true);
await logEventService.persistGuildState(ctx.client, guild.id, state);
uiState.feedback = ctx.ct("responses.allEnabled");
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, uiState)],
});
return;
}
if (interaction.customId === customIds.disableAllButton) {
applyAllEnabled(state, false);
await logEventService.persistGuildState(ctx.client, guild.id, state);
uiState.feedback = ctx.ct("responses.allDisabled");
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, uiState)],
});
return;
}
if (interaction.customId === customIds.createChannelsButton) {
await interaction.deferUpdate();
const result = await logEventService.createCategoryChannels(ctx.client, guild, state);
if (result.failedCategories.length === 0) {
uiState.feedback = ctx.ct("responses.channelsCreated", {
created: result.createdCount,
reused: result.reusedCount,
});
} else if (result.createdCount + result.reusedCount > 0) {
uiState.feedback = ctx.ct("responses.channelsPartial", {
created: result.createdCount,
reused: result.reusedCount,
failed: result.failedCategories.join(", "),
});
} else {
uiState.feedback = ctx.ct("responses.channelsFailed", {
failed: result.failedCategories.join(", "),
});
}
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, uiState)],
});
return;
}
if (interaction.customId === customIds.previousPageButton) {
uiState.pageIndex = Math.max(0, uiState.pageIndex - 1);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, uiState)],
});
return;
}
if (interaction.customId === customIds.nextPageButton) {
uiState.pageIndex = Math.min(pageCount() - 1, uiState.pageIndex + 1);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, uiState)],
});
return;
}
const eventKey = toggleLookup.get(interaction.customId);
if (eventKey) {
state[eventKey].enabled = !state[eventKey].enabled;
await logEventService.persistGuildState(ctx.client, guild.id, state);
uiState.feedback = state[eventKey].enabled
? ctx.ct("responses.eventEnabled", { event: eventKey })
: ctx.ct("responses.eventDisabled", { event: eventKey });
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, uiState)],
});
return;
}
await interaction.reply({
content: ctx.ct("responses.invalidSelection"),
flags: [MessageFlags.Ephemeral],
});
} catch (error) {
logger.error({ err: error }, "interaction failed");
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
content: ctx.t("errors.execution"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
return;
}
await interaction.followUp({
content: ctx.t("errors.execution"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
});
collector.on("end", async () => {
logEventService.deletePanelSessionIfCollectorMatch(sessionKey, collector);
await disablePanel();
});
};
};
@@ -0,0 +1,13 @@
import type {
LogEventConfig,
LogEventKey,
LogEventRepositoryEntry,
LogEventRow,
} from "../../types/logs.js";
export interface LogEventRepository {
listByBotGuild(botId: string, guildId: string): Promise<LogEventRow[]>;
upsertByBotGuildEvent(botId: string, guildId: string, eventKey: LogEventKey, config: LogEventConfig): Promise<void>;
upsertManyByBotGuildEvents(botId: string, guildId: string, entries: readonly LogEventRepositoryEntry[]): Promise<void>;
deleteByBotGuild(botId: string, guildId: string): Promise<void>;
}
@@ -0,0 +1,331 @@
import {
ChannelType,
EmbedBuilder,
PermissionFlagsBits,
type Client,
type Guild,
} from "discord.js";
import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js";
import { createScopedLogger } from "../../core/logging/logger.js";
import {
LOG_CHANNEL_NAME_BY_CATEGORY,
LOG_EVENT_CATEGORY_KEYS,
LOG_EVENT_DEFINITIONS,
LOG_EVENT_KEYS,
} from "./catalog.js";
import type { LogEventRepository } from "./repository.js";
import type {
LogEventCategoryKey,
LogChannelProvisionResult,
LogEventRepositoryEntry,
LogEventStateByKey,
LogPanelSession,
LogRuntimeDispatchInput,
} from "../../types/logs.js";
import {
cloneLogEventState,
createDefaultLogEventState,
mergeLogEventRowsIntoState,
} from "../../validators/logs.js";
const logger = createScopedLogger("feature:logs");
const LOG_CHANNEL_CATEGORY_NAME = "📁 ➜ Espace Logs";
const LOG_CHANNEL_NAME_PREFIX = "📁・";
const hasSendMethod = (value: unknown): value is { send: (payload: unknown) => Promise<unknown> } => {
if (!value || typeof value !== "object") {
return false;
}
return "send" in value && typeof (value as { send?: unknown }).send === "function";
};
const hasPermissionsFor = (value: unknown): value is {
permissionsFor: (member: unknown) => { has: (permission: unknown) => boolean } | null;
} => {
if (!value || typeof value !== "object") {
return false;
}
return "permissionsFor" in value && typeof (value as { permissionsFor?: unknown }).permissionsFor === "function";
};
const clampField = (value: string, maxLength: number): string => {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 3)}...`;
};
const buildChannelName = (category: LogEventCategoryKey): string => {
return `${LOG_CHANNEL_NAME_PREFIX}${LOG_CHANNEL_NAME_BY_CATEGORY[category]}`;
};
export class LogEventService {
private readonly stateCache = new Map<string, LogEventStateByKey>();
private readonly panelSessions = new ComponentSessionRegistry<LogPanelSession>();
public constructor(private readonly repository: LogEventRepository) {}
public resolveBotId(client: Client): string | null {
return client.user?.id ?? null;
}
public panelSessionKey(client: Client, guildId: string, userId: string): string {
return `${this.resolveBotId(client) ?? "unbound"}:${guildId}:${userId}`;
}
public async replacePanelSession(key: string, session: LogPanelSession): Promise<void> {
await this.panelSessions.replace(key, session);
}
public deletePanelSessionIfCollectorMatch(key: string, collector: LogPanelSession["collector"]): void {
this.panelSessions.deleteIfCollectorMatch(key, collector);
}
public async loadGuildState(client: Client, guildId: string): Promise<LogEventStateByKey> {
const botId = this.resolveBotId(client);
if (!botId) {
return createDefaultLogEventState();
}
const cacheKey = this.cacheKey(botId, guildId);
const cached = this.stateCache.get(cacheKey);
if (cached) {
return cloneLogEventState(cached);
}
const rows = await this.repository.listByBotGuild(botId, guildId);
const state = mergeLogEventRowsIntoState(rows);
this.stateCache.set(cacheKey, state);
return cloneLogEventState(state);
}
public async persistGuildState(client: Client, guildId: string, state: LogEventStateByKey): Promise<void> {
const botId = this.resolveBotId(client);
if (!botId) {
return;
}
const entries: LogEventRepositoryEntry[] = LOG_EVENT_KEYS.map((eventKey) => ({
eventKey,
config: {
enabled: state[eventKey].enabled,
channelId: state[eventKey].channelId,
},
}));
await this.repository.upsertManyByBotGuildEvents(botId, guildId, entries);
this.stateCache.set(this.cacheKey(botId, guildId), cloneLogEventState(state));
}
public async createCategoryChannels(
client: Client,
guild: Guild,
state: LogEventStateByKey,
): Promise<LogChannelProvisionResult> {
const categoryToChannelId = new Map<LogEventCategoryKey, string>();
let createdCount = 0;
let reusedCount = 0;
const failedCategories: LogEventCategoryKey[] = [];
const existingChannels = await guild.channels.fetch();
let logCategory = existingChannels.find((channel) => {
if (!channel) {
return false;
}
return channel.type === ChannelType.GuildCategory && channel.name === LOG_CHANNEL_CATEGORY_NAME;
});
if (!logCategory) {
try {
logCategory = await guild.channels.create({
name: LOG_CHANNEL_CATEGORY_NAME,
type: ChannelType.GuildCategory,
});
} catch (error) {
logger.warn({ guildId: guild.id, err: error }, "failed to create logs category");
return {
createdCount,
reusedCount,
failedCategories: [...LOG_EVENT_CATEGORY_KEYS],
};
}
}
for (const category of LOG_EVENT_CATEGORY_KEYS) {
const expectedName = buildChannelName(category);
const legacyName = LOG_CHANNEL_NAME_BY_CATEGORY[category];
const existingByExpectedName = existingChannels.find((channel) => {
if (!channel) {
return false;
}
return channel.type === ChannelType.GuildText && channel.name === expectedName;
});
const existingByLegacyName = existingByExpectedName
? null
: existingChannels.find((channel) => {
if (!channel) {
return false;
}
return channel.type === ChannelType.GuildText && channel.name === legacyName;
});
const existing = existingByExpectedName ?? existingByLegacyName;
if (existing) {
if (existing.name !== expectedName) {
await existing.setName(expectedName).catch((error) => {
logger.warn({ guildId: guild.id, category, err: error }, "failed to rename logs channel");
});
}
if (existing.parentId !== logCategory.id) {
await existing.setParent(logCategory.id).catch((error) => {
logger.warn({ guildId: guild.id, category, err: error }, "failed to move logs channel to category");
});
}
categoryToChannelId.set(category, existing.id);
reusedCount += 1;
continue;
}
try {
const permissionOverwrites = [
{
id: guild.roles.everyone.id,
allow: [PermissionFlagsBits.ViewChannel],
deny: [PermissionFlagsBits.SendMessages],
},
];
if (client.user?.id) {
permissionOverwrites.push({
id: client.user.id,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.EmbedLinks,
],
deny: [],
});
}
const created = await guild.channels.create({
name: expectedName,
type: ChannelType.GuildText,
parent: logCategory.id,
topic: `Logs for ${category} events`,
permissionOverwrites,
});
categoryToChannelId.set(category, created.id);
createdCount += 1;
} catch (error) {
logger.warn({ guildId: guild.id, category, err: error }, "failed to create logs channel");
failedCategories.push(category);
}
}
for (const definition of LOG_EVENT_DEFINITIONS) {
const channelId = categoryToChannelId.get(definition.category);
if (!channelId) {
continue;
}
state[definition.key].channelId = channelId;
}
await this.persistGuildState(client, guild.id, state);
return {
createdCount,
reusedCount,
failedCategories,
};
}
public async dispatchEvent(client: Client, input: LogRuntimeDispatchInput): Promise<void> {
const botId = this.resolveBotId(client);
if (!botId) {
return;
}
const cacheKey = this.cacheKey(botId, input.guildId);
let state = this.stateCache.get(cacheKey);
if (!state) {
const rows = await this.repository.listByBotGuild(botId, input.guildId);
state = mergeLogEventRowsIntoState(rows);
this.stateCache.set(cacheKey, state);
}
const eventConfig = state[input.eventKey];
if (!eventConfig.enabled || !eventConfig.channelId) {
return;
}
const guild = client.guilds.cache.get(input.guildId) ?? await client.guilds.fetch(input.guildId).catch(() => null);
if (!guild) {
return;
}
const channel = guild.channels.cache.get(eventConfig.channelId)
?? await guild.channels.fetch(eventConfig.channelId).catch(() => null);
if (!channel || !hasSendMethod(channel)) {
return;
}
const me = guild.members.me;
if (me && hasPermissionsFor(channel)) {
const permissions = channel.permissionsFor(me);
if (!permissions || !permissions.has(PermissionFlagsBits.ViewChannel) || !permissions.has(PermissionFlagsBits.SendMessages)) {
return;
}
}
const embed = new EmbedBuilder()
.setColor(input.color ?? 0x5865f2)
.setTitle(`Event: ${input.eventKey}`)
.setDescription(clampField(input.summary, 4096))
.setTimestamp(new Date());
if (input.details && input.details.length > 0) {
const detailsValue = clampField(input.details.map((line) => `- ${line}`).join("\n"), 1024);
if (detailsValue.length > 0) {
embed.addFields({
name: "Details",
value: detailsValue,
});
}
}
await channel.send({
embeds: [embed],
allowedMentions: { parse: [] },
});
}
public async cleanupGuild(botId: string, guildId: string): Promise<void> {
await this.repository.deleteByBotGuild(botId, guildId);
this.stateCache.delete(this.cacheKey(botId, guildId));
}
public async shutdown(): Promise<void> {
this.stateCache.clear();
await this.panelSessions.stopAll("shutdown");
}
private cacheKey(botId: string, guildId: string): string {
return `${botId}:${guildId}`;
}
}
@@ -0,0 +1,468 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChannelSelectMenuBuilder,
ChannelType,
ContainerBuilder,
MessageFlags,
RoleSelectMenuBuilder,
StringSelectMenuBuilder,
TextDisplayBuilder,
} from "discord.js";
import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js";
import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js";
import { createScopedLogger } from "../../core/logging/logger.js";
import type { I18nService } from "../../i18n/index.js";
import type { CommandExecutionContext } from "../../types/command.js";
import type {
MemberMessageConfig,
MemberMessageCustomIds,
MemberMessageKind,
MemberMessagePanelSession,
MemberMessagePanelUiState,
MemberMessageRenderType,
} from "../../types/memberMessages.js";
import {
MEMBER_MESSAGE_RENDER_TYPES,
isMemberMessageRenderTypeValue,
sanitizeMemberMessageRoleIds,
} from "../../validators/memberMessages.js";
import type { MemberMessageService } from "./service.js";
const log = createScopedLogger("command:memberMessagesPanel");
const panelSessions = new ComponentSessionRegistry<MemberMessagePanelSession>();
const panelSessionKey = (kind: MemberMessageKind, botId: string, guildId: string, userId: string): string => {
return `${kind}:${botId}:${guildId}:${userId}`;
};
const createCustomIds = (kind: MemberMessageKind): MemberMessageCustomIds => {
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
return {
toggleButton: `${kind}:toggle:${nonce}`,
channelButton: `${kind}:channel:${nonce}`,
channelCancelButton: `${kind}:channel-cancel:${nonce}`,
roleButton: `${kind}:roles:${nonce}`,
roleCancelButton: `${kind}:roles-cancel:${nonce}`,
roleClearButton: `${kind}:roles-clear:${nonce}`,
typeSelect: `${kind}:type:${nonce}`,
channelSelect: `${kind}:channel-select:${nonce}`,
roleSelect: `${kind}:role-select:${nonce}`,
testButton: `${kind}:test:${nonce}`,
};
};
const statusLabel = (ctx: CommandExecutionContext, enabled: boolean): string => {
return enabled ? ctx.ct("ui.status.enabled") : ctx.ct("ui.status.disabled");
};
const messageTypeLabel = (ctx: CommandExecutionContext, messageType: MemberMessageRenderType): string => {
return ctx.ct(`ui.type.options.${messageType}.label`);
};
const autoRolesLabel = (ctx: CommandExecutionContext, config: MemberMessageConfig): string => {
const roleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds);
if (roleIds.length === 0) {
return ctx.ct("ui.autoRolesNotConfigured");
}
return roleIds.map((roleId) => `<@&${roleId}>`).join(", ");
};
const panelContent = (
ctx: CommandExecutionContext,
kind: MemberMessageKind,
config: MemberMessageConfig,
uiState: MemberMessagePanelUiState,
): string => {
const channelDisplay = config.channelId ? `<#${config.channelId}>` : ctx.ct("ui.channelNotConfigured");
const lines = [
`## ${ctx.ct("ui.embed.title")}`,
ctx.ct("ui.embed.description"),
"",
`${ctx.ct("ui.embed.fields.status")}: ${statusLabel(ctx, config.enabled)}`,
`${ctx.ct("ui.embed.fields.channel")}: ${channelDisplay}`,
`${ctx.ct("ui.embed.fields.type")}: ${messageTypeLabel(ctx, config.messageType)}`,
];
if (kind === "welcome") {
lines.push(`${ctx.ct("ui.embed.fields.autoRoles")}: ${autoRolesLabel(ctx, config)}`);
}
if (uiState.channelPickerOpen) {
lines.push("", `${ctx.ct("ui.embed.fields.channelPicker")}: ${ctx.ct("ui.channelPickerHint")}`);
}
if (kind === "welcome" && uiState.rolePickerOpen) {
lines.push("", `${ctx.ct("ui.embed.fields.autoRoles")}: ${ctx.ct("ui.rolePickerHint")}`);
}
return lines.join("\n");
};
const buildContainer = (
ctx: CommandExecutionContext,
kind: MemberMessageKind,
customIds: MemberMessageCustomIds,
config: MemberMessageConfig,
uiState: MemberMessagePanelUiState,
disabled = false,
): ContainerBuilder => {
const toggleButton = new ButtonBuilder()
.setCustomId(customIds.toggleButton)
.setLabel(ctx.ct("ui.buttons.toggle"))
.setStyle(config.enabled ? ButtonStyle.Success : ButtonStyle.Secondary)
.setDisabled(disabled);
const channelButton = new ButtonBuilder()
.setCustomId(uiState.channelPickerOpen ? customIds.channelCancelButton : customIds.channelButton)
.setLabel(uiState.channelPickerOpen ? ctx.ct("ui.buttons.channelCancel") : ctx.ct("ui.buttons.channel"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const roleButton = new ButtonBuilder()
.setCustomId(uiState.rolePickerOpen ? customIds.roleCancelButton : customIds.roleButton)
.setLabel(uiState.rolePickerOpen ? ctx.ct("ui.buttons.rolesCancel") : ctx.ct("ui.buttons.roles"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const clearRolesButton = new ButtonBuilder()
.setCustomId(customIds.roleClearButton)
.setLabel(ctx.ct("ui.buttons.rolesClear"))
.setStyle(ButtonStyle.Danger)
.setDisabled(disabled || config.autoRoleIds.length === 0);
const testButton = new ButtonBuilder()
.setCustomId(customIds.testButton)
.setLabel(ctx.ct("ui.buttons.test"))
.setStyle(ButtonStyle.Primary)
.setDisabled(disabled);
const typeSelect = new StringSelectMenuBuilder()
.setCustomId(customIds.typeSelect)
.setPlaceholder(ctx.ct("ui.type.placeholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setOptions(
MEMBER_MESSAGE_RENDER_TYPES.map((renderType) => ({
label: ctx.ct(`ui.type.options.${renderType}.label`),
description: ctx.ct(`ui.type.options.${renderType}.description`),
value: renderType,
default: renderType === config.messageType,
})),
);
const container = new ContainerBuilder();
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(panelContent(ctx, kind, config, uiState)),
);
if (kind === "welcome") {
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(
toggleButton,
channelButton,
roleButton,
clearRolesButton,
testButton,
),
);
} else {
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(toggleButton, channelButton, testButton),
);
}
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(typeSelect),
);
if (uiState.channelPickerOpen) {
const channelSelect = new ChannelSelectMenuBuilder()
.setCustomId(customIds.channelSelect)
.setPlaceholder(ctx.ct("ui.channelPickerPlaceholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement);
container.addActionRowComponents(
new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(channelSelect),
);
}
if (kind === "welcome" && uiState.rolePickerOpen) {
const roleSelect = new RoleSelectMenuBuilder()
.setCustomId(customIds.roleSelect)
.setPlaceholder(ctx.ct("ui.rolePickerPlaceholder"))
.setMinValues(1)
.setMaxValues(25)
.setDisabled(disabled);
container.addActionRowComponents(
new ActionRowBuilder<RoleSelectMenuBuilder>().addComponents(roleSelect),
);
}
return container;
};
const testFeedbackKey = (reason: string): string => {
switch (reason) {
case "disabled":
return "responses.testDisabled";
case "missing_channel":
return "responses.testMissingChannel";
case "channel_not_found":
case "channel_not_sendable":
return "responses.testChannelUnavailable";
case "missing_permissions":
return "responses.testMissingPermissions";
default:
return "responses.testFailed";
}
};
export const createMemberMessagePanelExecute = (
kind: MemberMessageKind,
memberMessageService: MemberMessageService,
panelI18n: I18nService,
) => {
return async (ctx: CommandExecutionContext): Promise<void> => {
if (!ctx.guild) {
await ctx.reply(ctx.ct("responses.guildOnly"));
return;
}
const guild = ctx.guild;
const botId = memberMessageService.resolveBotId(ctx.client);
if (!botId) {
await ctx.reply(ctx.t("errors.execution"));
return;
}
const config = await memberMessageService.getConfig(botId, guild.id, kind);
const customIds = createCustomIds(kind);
const uiState: MemberMessagePanelUiState = {
channelPickerOpen: false,
rolePickerOpen: false,
};
const replyResult = await ctx.reply({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, kind, customIds, config, uiState)],
withResponse: true,
});
const replyMessage = resolveReplyMessage(replyResult);
if (!replyMessage) {
return;
}
const ownerId = ctx.user.id;
const sessionKey = panelSessionKey(kind, botId, guild.id, ownerId);
const saveConfig = async (): Promise<void> => {
await memberMessageService.saveConfig(botId, guild.id, kind, config);
};
const disablePanel = async (): Promise<void> => {
await replyMessage
.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, kind, customIds, config, uiState, true)],
})
.catch(() => undefined);
};
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
await panelSessions.replace(sessionKey, {
collector,
disable: disablePanel,
});
collector.on("collect", async (interaction) => {
if (interaction.user.id !== ownerId) {
await interaction.reply({
content: ctx.ct("responses.notOwner"),
flags: [MessageFlags.Ephemeral],
});
return;
}
try {
if (interaction.isButton()) {
if (interaction.customId === customIds.toggleButton) {
config.enabled = !config.enabled;
await saveConfig();
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, kind, customIds, config, uiState)],
});
return;
}
if (interaction.customId === customIds.channelButton) {
uiState.channelPickerOpen = true;
uiState.rolePickerOpen = false;
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, kind, customIds, config, uiState)],
});
return;
}
if (interaction.customId === customIds.channelCancelButton) {
uiState.channelPickerOpen = false;
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, kind, customIds, config, uiState)],
});
return;
}
if (kind === "welcome" && interaction.customId === customIds.roleButton) {
uiState.rolePickerOpen = true;
uiState.channelPickerOpen = false;
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, kind, customIds, config, uiState)],
});
return;
}
if (kind === "welcome" && interaction.customId === customIds.roleCancelButton) {
uiState.rolePickerOpen = false;
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, kind, customIds, config, uiState)],
});
return;
}
if (kind === "welcome" && interaction.customId === customIds.roleClearButton) {
config.autoRoleIds = [];
await saveConfig();
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, kind, customIds, config, uiState)],
});
return;
}
if (interaction.customId === customIds.testButton) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const testResult = await memberMessageService.dispatch({
client: ctx.client,
i18n: panelI18n,
guild,
user: ctx.user,
kind,
ignoreEnabled: true,
});
if (testResult.sent) {
await interaction.editReply({
content: ctx.ct("responses.testSuccess", {
channel: testResult.channelId ? `<#${testResult.channelId}>` : ctx.ct("ui.channelNotConfigured"),
}),
});
return;
}
await interaction.editReply({
content: ctx.ct(testFeedbackKey(testResult.reason)),
});
return;
}
await interaction.reply({
content: ctx.ct("responses.invalidSelection"),
flags: [MessageFlags.Ephemeral],
});
return;
}
if (interaction.isStringSelectMenu() && interaction.customId === customIds.typeSelect) {
const nextType = interaction.values[0];
if (!nextType || !isMemberMessageRenderTypeValue(nextType)) {
await interaction.reply({
content: ctx.ct("responses.invalidSelection"),
flags: [MessageFlags.Ephemeral],
});
return;
}
config.messageType = nextType;
await saveConfig();
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, kind, customIds, config, uiState)],
});
return;
}
if (kind === "welcome" && interaction.isRoleSelectMenu() && interaction.customId === customIds.roleSelect) {
config.autoRoleIds = sanitizeMemberMessageRoleIds([...config.autoRoleIds, ...interaction.values]);
uiState.rolePickerOpen = false;
await saveConfig();
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, kind, customIds, config, uiState)],
});
return;
}
if (interaction.isChannelSelectMenu() && interaction.customId === customIds.channelSelect) {
const channelId = interaction.values[0];
if (!channelId) {
await interaction.reply({
content: ctx.ct("responses.invalidSelection"),
flags: [MessageFlags.Ephemeral],
});
return;
}
config.channelId = channelId;
uiState.channelPickerOpen = false;
await saveConfig();
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, kind, customIds, config, uiState)],
});
return;
}
await interaction.reply({
content: ctx.ct("responses.invalidSelection"),
flags: [MessageFlags.Ephemeral],
});
} catch (error) {
log.error({ kind, err: error }, "interaction failed");
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
content: ctx.t("errors.execution"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
return;
}
await interaction.followUp({
content: ctx.t("errors.execution"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
});
collector.on("end", async () => {
panelSessions.deleteIfCollectorMatch(sessionKey, collector);
await disablePanel();
});
};
};
@@ -0,0 +1,178 @@
import { createCanvas, loadImage, type SKRSContext2D } from "@napi-rs/canvas";
import type { MemberMessageImageInput } from "../../types/memberMessages.js";
const CARD_WIDTH = 1024;
const CARD_HEIGHT = 320;
const AVATAR_SIZE = 220;
const AVATAR_X = 42;
const AVATAR_Y = (CARD_HEIGHT - AVATAR_SIZE) / 2;
const TEXT_X = AVATAR_X + AVATAR_SIZE + 56;
const TEXT_MAX_WIDTH = CARD_WIDTH - TEXT_X - 36;
const FONT_FAMILY = '"DejaVu Sans", sans-serif';
const fitFontSize = (
context: SKRSContext2D,
text: string,
startSize: number,
minSize: number,
maxWidth: number,
fontWeight = 700,
): number => {
let size = startSize;
while (size > minSize) {
context.font = `${fontWeight} ${size}px ${FONT_FAMILY}`;
if (context.measureText(text).width <= maxWidth) {
break;
}
size -= 2;
}
return size;
};
const wrapText = (
context: SKRSContext2D,
text: string,
maxWidth: number,
maxLines: number,
): string[] => {
const words = text.trim().split(/\s+/g).filter((value) => value.length > 0);
if (words.length === 0) {
return [""];
}
const lines: string[] = [];
let current = "";
for (const word of words) {
const next = current.length === 0 ? word : `${current} ${word}`;
if (context.measureText(next).width <= maxWidth) {
current = next;
continue;
}
if (current.length > 0) {
lines.push(current);
current = word;
} else {
lines.push(word);
current = "";
}
if (lines.length >= maxLines - 1) {
break;
}
}
if (lines.length < maxLines && current.length > 0) {
lines.push(current);
}
if (lines.length > maxLines) {
lines.length = maxLines;
}
const lastLine = lines[maxLines - 1];
if (lastLine && lines.length === maxLines) {
let value = lastLine;
while (context.measureText(`${value}...`).width > maxWidth && value.length > 0) {
value = value.slice(0, -1);
}
lines[maxLines - 1] = value.length === lastLine.length ? value : `${value}...`;
}
return lines;
};
const drawAvatar = async (
context: SKRSContext2D,
username: string,
avatarUrl: string,
): Promise<void> => {
context.fillStyle = "#1f2937";
context.beginPath();
context.arc(AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2, AVATAR_SIZE / 2, 0, Math.PI * 2);
context.closePath();
context.fill();
try {
const avatar = await loadImage(avatarUrl);
context.save();
context.beginPath();
context.arc(AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2, AVATAR_SIZE / 2, 0, Math.PI * 2);
context.closePath();
context.clip();
context.drawImage(avatar, AVATAR_X, AVATAR_Y, AVATAR_SIZE, AVATAR_SIZE);
context.restore();
} catch {
const initial = username.trim().charAt(0).toUpperCase() || "?";
context.fillStyle = "#f8fafc";
context.font = `700 92px ${FONT_FAMILY}`;
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(initial, AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2);
context.textAlign = "left";
context.textBaseline = "alphabetic";
}
context.strokeStyle = "rgba(248, 250, 252, 0.9)";
context.lineWidth = 6;
context.beginPath();
context.arc(AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2, AVATAR_SIZE / 2 - 3, 0, Math.PI * 2);
context.closePath();
context.stroke();
};
export const renderMemberMessageImage = async (input: MemberMessageImageInput): Promise<Buffer> => {
const canvas = createCanvas(CARD_WIDTH, CARD_HEIGHT);
const context = canvas.getContext("2d");
const gradient = context.createLinearGradient(0, 0, CARD_WIDTH, CARD_HEIGHT);
if (input.kind === "welcome") {
gradient.addColorStop(0, "#2b303b");
gradient.addColorStop(1, "#1e232d");
} else {
gradient.addColorStop(0, "#36252a");
gradient.addColorStop(1, "#241b21");
}
context.fillStyle = gradient;
context.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
context.globalAlpha = 0.14;
context.fillStyle = input.kind === "welcome" ? "#93c5fd" : "#fca5a5";
context.beginPath();
context.arc(CARD_WIDTH - 110, -20, 220, 0, Math.PI * 2);
context.closePath();
context.fill();
context.globalAlpha = 1;
await drawAvatar(context, input.username, input.avatarUrl);
const headlineSize = fitFontSize(context, input.title, 84, 52, TEXT_MAX_WIDTH, 700);
context.font = `700 ${headlineSize}px ${FONT_FAMILY}`;
context.fillStyle = "#f8fafc";
context.fillText(input.title, TEXT_X, 116);
const subtitleSize = fitFontSize(context, input.subtitle, 58, 30, TEXT_MAX_WIDTH, 500);
context.font = `500 ${subtitleSize}px ${FONT_FAMILY}`;
context.fillStyle = "#e5e7eb";
const subtitleLines = wrapText(context, input.subtitle, TEXT_MAX_WIDTH, 2);
const subtitleStartY = 162;
const subtitleLineHeight = subtitleSize + 8;
subtitleLines.forEach((line, index) => {
context.fillText(line, TEXT_X, subtitleStartY + index * subtitleLineHeight);
});
const usernameSize = fitFontSize(context, input.username, 54, 28, TEXT_MAX_WIDTH, 700);
context.font = `700 ${usernameSize}px ${FONT_FAMILY}`;
context.fillStyle = input.kind === "welcome" ? "#93c5fd" : "#fda4af";
context.fillText(input.username, TEXT_X, CARD_HEIGHT - 34);
return canvas.toBuffer("image/png");
};
@@ -0,0 +1,10 @@
import type {
MemberMessageConfig,
MemberMessageKind,
} from "../../types/memberMessages.js";
export interface MemberMessageRepository {
getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise<MemberMessageConfig>;
upsertByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind, config: MemberMessageConfig): Promise<void>;
deleteByBotGuild(botId: string, guildId: string): Promise<void>;
}
@@ -0,0 +1,421 @@
import {
AttachmentBuilder,
ContainerBuilder,
EmbedBuilder,
MessageFlags,
PermissionFlagsBits,
TextDisplayBuilder,
type Guild,
type GuildMember,
type MessageCreateOptions,
type User,
} from "discord.js";
import type { I18nService } from "../../i18n/index.js";
import type { SupportedLang } from "../../types/command.js";
import type {
AssignWelcomeAutoRolesInput,
AssignWelcomeAutoRolesResult,
DispatchMemberMessageInput,
DispatchMemberMessageResult,
MemberMessageConfig,
MemberMessageKind,
MemberMessageRenderType,
SendableChannel,
TemplateSuffix,
} from "../../types/memberMessages.js";
import { sanitizeMemberMessageRoleIds } from "../../validators/memberMessages.js";
import { renderMemberMessageImage } from "./imageRenderer.js";
import type { MemberMessageRepository } from "./repository.js";
export type {
DispatchMemberMessageFailureReason,
DispatchMemberMessageResult,
} from "../../types/memberMessages.js";
const hasSendMethod = (value: unknown): value is SendableChannel => {
if (!value || typeof value !== "object") {
return false;
}
return "send" in value && typeof (value as { send?: unknown }).send === "function";
};
const hasCode = (error: unknown): error is { code: number } => {
if (!error || typeof error !== "object") {
return false;
}
return "code" in error && typeof (error as { code?: unknown }).code === "number";
};
const messageTemplateVars = (guild: Guild, user: User): Record<string, string> => ({
user: `<@${user.id}>`,
username: user.username,
guild: guild.name,
});
const messageColor = (kind: MemberMessageKind): number => {
return kind === "welcome" ? 0x57f287 : 0xed4245;
};
const defaultTemplate = (
kind: MemberMessageKind,
suffix: TemplateSuffix,
vars: Record<string, string>,
): string => {
if (kind === "welcome") {
switch (suffix) {
case "simple":
return `🎉 Welcome ${vars.user} to **${vars.guild}**!`;
case "embedTitle":
return "Welcome!";
case "embedDescription":
return `${vars.user} just joined **${vars.guild}**.`;
case "containerTitle":
return "Welcome";
case "containerDescription":
return `${vars.user} just joined **${vars.guild}**.`;
case "imageTitle":
return "New member";
case "imageDescription":
return `Glad to have you here, ${vars.user}!`;
default:
return "";
}
}
switch (suffix) {
case "simple":
return `👋 ${vars.user} left **${vars.guild}**.`;
case "embedTitle":
return "Goodbye";
case "embedDescription":
return `${vars.user} has left **${vars.guild}**.`;
case "containerTitle":
return "Goodbye";
case "containerDescription":
return `${vars.user} has left **${vars.guild}**.`;
case "imageTitle":
return "Member left";
case "imageDescription":
return `${vars.user} has left the server.`;
default:
return "";
}
};
const resolveTemplate = (
i18n: I18nService | undefined,
lang: SupportedLang,
kind: MemberMessageKind,
suffix: TemplateSuffix,
vars: Record<string, string>,
): string => {
if (!i18n) {
return defaultTemplate(kind, suffix, vars);
}
const key = `commands.${kind}.templates.${suffix}`;
const translated = i18n.t(lang, key, vars);
return translated === key ? defaultTemplate(kind, suffix, vars) : translated;
};
const resolveNonEmptyTemplate = (
i18n: I18nService | undefined,
lang: SupportedLang,
kind: MemberMessageKind,
suffix: TemplateSuffix,
vars: Record<string, string>,
): string => {
const resolved = resolveTemplate(i18n, lang, kind, suffix, vars).trim();
if (resolved.length > 0) {
return resolved;
}
const fallback = defaultTemplate(kind, suffix, vars).trim();
return fallback.length > 0 ? fallback : suffix;
};
const resolveAssignableRoleId = async (member: GuildMember, roleId: string): Promise<string | null> => {
if (roleId === member.guild.id) {
return null;
}
const role = member.guild.roles.cache.get(roleId) ?? await member.guild.roles.fetch(roleId).catch(() => null);
if (!role || !role.editable) {
return null;
}
return role.id;
};
const buildMemberMessagePayload = async (
i18n: I18nService | undefined,
lang: SupportedLang,
kind: MemberMessageKind,
renderType: MemberMessageRenderType,
guild: Guild,
user: User,
): Promise<MessageCreateOptions> => {
const vars = messageTemplateVars(guild, user);
const allowedMentions: NonNullable<MessageCreateOptions["allowedMentions"]> = {
parse: [],
users: [user.id],
};
if (renderType === "simple") {
return {
content: resolveTemplate(i18n, lang, kind, "simple", vars),
allowedMentions,
};
}
if (renderType === "embed") {
return {
allowedMentions,
embeds: [
new EmbedBuilder()
.setColor(messageColor(kind))
.setTitle(resolveTemplate(i18n, lang, kind, "embedTitle", vars))
.setDescription(resolveTemplate(i18n, lang, kind, "embedDescription", vars)),
],
};
}
if (renderType === "container") {
const container = new ContainerBuilder();
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`## ${resolveTemplate(i18n, lang, kind, "containerTitle", vars)}\n${resolveTemplate(i18n, lang, kind, "containerDescription", vars)}`,
),
);
return {
flags: MessageFlags.IsComponentsV2,
components: [container],
allowedMentions,
};
}
const imageTitle = resolveNonEmptyTemplate(i18n, lang, kind, "imageTitle", vars);
const imageSubtitle = resolveNonEmptyTemplate(i18n, lang, kind, "imageDescription", vars);
const imageBuffer = await renderMemberMessageImage({
kind,
title: imageTitle,
subtitle: imageSubtitle,
username: user.globalName ?? user.username,
avatarUrl: user.displayAvatarURL({ extension: "png", size: 512 }),
});
const fileName = `${kind}-${guild.id}-${user.id}.png`;
return {
allowedMentions,
content: resolveTemplate(i18n, lang, kind, "simple", vars),
files: [new AttachmentBuilder(imageBuffer, { name: fileName })],
};
};
export class MemberMessageService {
public constructor(private readonly repository: MemberMessageRepository) {}
public resolveBotId(client: DispatchMemberMessageInput["client"]): string | null {
return client.user?.id ?? null;
}
public async getConfig(botId: string, guildId: string, kind: MemberMessageKind) {
return this.repository.getByBotGuildKind(botId, guildId, kind);
}
public async saveConfig(
botId: string,
guildId: string,
kind: MemberMessageKind,
config: MemberMessageConfig,
): Promise<void> {
await this.repository.upsertByBotGuildKind(botId, guildId, kind, config);
}
public async cleanupGuild(botId: string, guildId: string): Promise<void> {
await this.repository.deleteByBotGuild(botId, guildId);
}
public async assignWelcomeAutoRoles(input: AssignWelcomeAutoRolesInput): Promise<AssignWelcomeAutoRolesResult> {
const botId = this.resolveBotId(input.client);
if (!botId) {
return {
assigned: false,
reason: "bot_not_ready",
configuredRoleIds: [],
appliedRoleIds: [],
skippedRoleIds: [],
};
}
const config = await this.repository.getByBotGuildKind(botId, input.member.guild.id, "welcome");
const configuredRoleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds);
if (configuredRoleIds.length === 0) {
return {
assigned: false,
reason: "no_roles_configured",
configuredRoleIds,
appliedRoleIds: [],
skippedRoleIds: [],
};
}
const me = input.member.guild.members.me;
if (!me || !me.permissions.has(PermissionFlagsBits.ManageRoles)) {
return {
assigned: false,
reason: "missing_permissions",
configuredRoleIds,
appliedRoleIds: [],
skippedRoleIds: [...configuredRoleIds],
};
}
if (!input.member.manageable) {
return {
assigned: false,
reason: "member_not_manageable",
configuredRoleIds,
appliedRoleIds: [],
skippedRoleIds: [...configuredRoleIds],
};
}
const resolvedRoleIds = await Promise.all(
configuredRoleIds.map((roleId) => resolveAssignableRoleId(input.member, roleId)),
);
const appliedRoleIds = resolvedRoleIds.filter((roleId): roleId is string => roleId !== null);
const skippedRoleIds = configuredRoleIds.filter((roleId) => !appliedRoleIds.includes(roleId));
if (appliedRoleIds.length === 0) {
return {
assigned: false,
reason: "no_assignable_roles",
configuredRoleIds,
appliedRoleIds: [],
skippedRoleIds,
};
}
try {
await input.member.roles.add(appliedRoleIds, "welcome auto roles");
return {
assigned: true,
reason: "assigned",
configuredRoleIds,
appliedRoleIds,
skippedRoleIds,
};
} catch (error) {
if (hasCode(error) && error.code === 50013) {
return {
assigned: false,
reason: "missing_permissions",
configuredRoleIds,
appliedRoleIds: [],
skippedRoleIds: [...configuredRoleIds],
};
}
return {
assigned: false,
reason: "assign_failed",
configuredRoleIds,
appliedRoleIds: [],
skippedRoleIds: [...configuredRoleIds],
};
}
}
public async dispatch(input: DispatchMemberMessageInput): Promise<DispatchMemberMessageResult> {
const botId = this.resolveBotId(input.client);
if (!botId) {
return {
sent: false,
reason: "bot_not_ready",
channelId: null,
};
}
const config = await this.repository.getByBotGuildKind(botId, input.guild.id, input.kind);
if (!input.ignoreEnabled && !config.enabled) {
return {
sent: false,
reason: "disabled",
channelId: config.channelId,
};
}
if (!config.channelId) {
return {
sent: false,
reason: "missing_channel",
channelId: null,
};
}
const channel = await input.guild.channels.fetch(config.channelId).catch(() => null);
if (!channel) {
return {
sent: false,
reason: "channel_not_found",
channelId: config.channelId,
};
}
if (!hasSendMethod(channel)) {
return {
sent: false,
reason: "channel_not_sendable",
channelId: config.channelId,
};
}
const me = input.guild.members.me;
if (me && "permissionsFor" in channel && typeof channel.permissionsFor === "function") {
const permissions = channel.permissionsFor(me);
if (!permissions || !permissions.has(PermissionFlagsBits.ViewChannel) || !permissions.has(PermissionFlagsBits.SendMessages)) {
return {
sent: false,
reason: "missing_permissions",
channelId: config.channelId,
};
}
}
const lang = input.i18n?.resolveLang(input.guild.preferredLocale ?? null) ?? "en";
const payload = await buildMemberMessagePayload(input.i18n, lang, input.kind, config.messageType, input.guild, input.user);
try {
await channel.send(payload);
return {
sent: true,
reason: "sent",
channelId: config.channelId,
};
} catch (error) {
if (hasCode(error) && error.code === 50013) {
return {
sent: false,
reason: "missing_permissions",
channelId: config.channelId,
};
}
return {
sent: false,
reason: "send_failed",
channelId: config.channelId,
};
}
}
}
@@ -0,0 +1,377 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ContainerBuilder,
MessageFlags,
ModalBuilder,
StringSelectMenuBuilder,
TextDisplayBuilder,
TextInputBuilder,
TextInputStyle,
} from "discord.js";
import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js";
import type { CommandExecutionContext } from "../../types/command.js";
import {
MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
PRESENCE_ACTIVITY_TYPES,
PRESENCE_STATUSES,
isPresenceActivityTypeValue,
isPresenceRotationIntervalSecondsValue,
isPresenceStatusValue,
sanitizeActivityTexts,
} from "../../validators/presence.js";
import { getPresenceTemplateHelpText } from "./templateVariables.js";
import type { PresenceService } from "./service.js";
import type { PresenceCustomIds } from "./types.js";
import { createScopedLogger } from "../../core/logging/logger.js";
const log = createScopedLogger("command:presence");
const createCustomIds = (): PresenceCustomIds => {
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
return {
statusSelect: `presence:status:${nonce}`,
activitySelect: `presence:activity:${nonce}`,
textButton: `presence:text:${nonce}`,
intervalButton: `presence:interval:${nonce}`,
textModal: `presence:modal:${nonce}`,
textInput: `presence:text-input:${nonce}`,
intervalModal: `presence:interval-modal:${nonce}`,
intervalInput: `presence:interval-input:${nonce}`,
};
};
const statusLabel = (ctx: CommandExecutionContext, status: string): string =>
ctx.ct(`ui.status.options.${status}.label`);
const activityLabel = (ctx: CommandExecutionContext, activityType: string): string =>
ctx.ct(`ui.activity.options.${activityType}.label`);
const panelContent = (
ctx: CommandExecutionContext,
service: PresenceService,
state: Awaited<ReturnType<PresenceService["loadState"]>>,
): string => {
const runtimeState = service.getRuntimeState(ctx.client);
service.normalizeState(state, runtimeState);
const templateText = service.getActiveTemplateText(state, runtimeState);
const activityPreview = service.renderPreview(ctx.client, templateText);
const activityTexts = state.activity.texts.map((text, index) => `${index + 1}. ${text}`).join(" | ");
const currentIndex = Math.min(runtimeState.activePresenceTextIndex + 1, state.activity.texts.length);
const summary = ctx.ct("responses.panel", {
status: statusLabel(ctx, state.status),
activityType: activityLabel(ctx, state.activity.type),
activityText: templateText,
activityPreview,
activityTexts,
textCount: state.activity.texts.length,
currentTextIndex: currentIndex,
rotationIntervalSeconds: state.activity.rotationIntervalSeconds,
doubleBracesHint: "{{var}}",
variables: getPresenceTemplateHelpText(),
});
return `## ${ctx.ct("ui.embed.title")}\n${ctx.ct("ui.embed.description")}\n\n${summary}`;
};
const buildContainer = (
ctx: CommandExecutionContext,
service: PresenceService,
state: Awaited<ReturnType<PresenceService["loadState"]>>,
customIds: PresenceCustomIds,
disabled = false,
): ContainerBuilder => {
const statusSelect = new StringSelectMenuBuilder()
.setCustomId(customIds.statusSelect)
.setPlaceholder(ctx.ct("ui.status.placeholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setOptions(
PRESENCE_STATUSES.map((status) => ({
label: statusLabel(ctx, status),
description: ctx.ct(`ui.status.options.${status}.description`),
value: status,
default: status === state.status,
})),
);
const activitySelect = new StringSelectMenuBuilder()
.setCustomId(customIds.activitySelect)
.setPlaceholder(ctx.ct("ui.activity.placeholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setOptions(
PRESENCE_ACTIVITY_TYPES.map((activityType) => ({
label: activityLabel(ctx, activityType),
description: ctx.ct(`ui.activity.options.${activityType}.description`),
value: activityType,
default: activityType === state.activity.type,
})),
);
const textButton = new ButtonBuilder()
.setCustomId(customIds.textButton)
.setLabel(ctx.ct("ui.textButton"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const intervalButton = new ButtonBuilder()
.setCustomId(customIds.intervalButton)
.setLabel(ctx.ct("ui.intervalButton"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const container = new ContainerBuilder();
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(panelContent(ctx, service, state)),
);
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(statusSelect),
);
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(activitySelect),
);
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(textButton, intervalButton),
);
return container;
};
export const createPresenceCommandExecute = (presenceService: PresenceService) => {
return async (ctx: CommandExecutionContext): Promise<void> => {
const state = await presenceService.loadState(ctx.client);
presenceService.applyState(ctx.client, state);
const customIds = createCustomIds();
const replyResult = await ctx.reply({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
withResponse: true,
});
const replyMessage = resolveReplyMessage(replyResult);
if (!replyMessage) {
return;
}
const ownerId = ctx.user.id;
const sessionKey = presenceService.panelSessionKey(ctx.client, ownerId);
const disablePanel = async (): Promise<void> => {
await replyMessage
.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds, true)],
})
.catch(() => undefined);
};
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
await presenceService.replacePanelSession(sessionKey, { collector, disable: disablePanel });
collector.on("collect", async (interaction) => {
if (interaction.user.id !== ownerId) {
await interaction.reply({
content: ctx.ct("responses.notOwner"),
flags: [MessageFlags.Ephemeral],
});
return;
}
try {
if (interaction.isStringSelectMenu()) {
if (interaction.customId === customIds.statusSelect) {
const nextStatus = interaction.values[0];
if (!nextStatus || !isPresenceStatusValue(nextStatus)) {
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
state.status = nextStatus;
await presenceService.persistAndApply(ctx.client, state);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
return;
}
if (interaction.customId === customIds.activitySelect) {
const nextType = interaction.values[0];
if (!nextType || !isPresenceActivityTypeValue(nextType)) {
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
state.activity.type = nextType;
await presenceService.persistAndApply(ctx.client, state);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
if (interaction.isButton()) {
if (interaction.customId === customIds.textButton) {
const modal = new ModalBuilder()
.setCustomId(customIds.textModal)
.setTitle(ctx.ct("ui.modal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.textInput)
.setLabel(ctx.ct("ui.modal.label"))
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder(ctx.ct("ui.modal.placeholder"))
.setRequired(true)
.setMaxLength(1_800)
.setValue(state.activity.texts.join("\n")),
),
);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.textModal
&& modalInteraction.user.id === ownerId,
});
const nextTexts = sanitizeActivityTexts(
submitted.fields.getTextInputValue(customIds.textInput).split(/\r?\n/g),
);
state.activity.texts = nextTexts;
state.activity.text = nextTexts[0] ?? state.activity.text;
const runtimeState = presenceService.getRuntimeState(ctx.client);
runtimeState.activePresenceTextIndex = 0;
await presenceService.persistAndApply(ctx.client, state);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
return;
}
if (interaction.customId === customIds.intervalButton) {
const modal = new ModalBuilder()
.setCustomId(customIds.intervalModal)
.setTitle(ctx.ct("ui.intervalModal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.intervalInput)
.setLabel(ctx.ct("ui.intervalModal.label"))
.setStyle(TextInputStyle.Short)
.setPlaceholder(ctx.ct("ui.intervalModal.placeholder"))
.setRequired(true)
.setMaxLength(4)
.setValue(String(state.activity.rotationIntervalSeconds)),
),
);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.intervalModal
&& modalInteraction.user.id === ownerId,
});
const rawSeconds = submitted.fields.getTextInputValue(customIds.intervalInput).trim();
if (!/^\d+$/.test(rawSeconds)) {
await submitted.reply({
content: ctx.ct("responses.invalidInterval", {
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}),
flags: [MessageFlags.Ephemeral],
});
return;
}
const nextSeconds = Number(rawSeconds);
if (!isPresenceRotationIntervalSecondsValue(nextSeconds)) {
await submitted.reply({
content: ctx.ct("responses.invalidInterval", {
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}),
flags: [MessageFlags.Ephemeral],
});
return;
}
state.activity.rotationIntervalSeconds = nextSeconds;
await presenceService.persistAndApply(ctx.client, state);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
} catch (error) {
log.error({ err: error }, "interaction failed");
const fallback = ctx.t("errors.execution");
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
return;
}
await interaction.followUp({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
}
});
collector.on("end", async () => {
presenceService.deletePanelSessionIfCollectorMatch(sessionKey, collector);
await disablePanel();
});
};
};
@@ -0,0 +1,6 @@
import type { PresenceState } from "../../types/presence.js";
export interface PresenceRepository {
getByBotId(botId: string): Promise<PresenceState>;
upsertByBotId(botId: string, state: PresenceState): Promise<void>;
}
@@ -0,0 +1,234 @@
import { ActivityType, type Client } from "discord.js";
import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js";
import type {
PresenceActivityTypeValue,
PresenceState,
PresenceStatusValue,
} from "../../types/presence.js";
import {
createDefaultPresenceState,
sanitizeActivityText,
sanitizeActivityTexts,
sanitizePresenceRotationIntervalSeconds,
} from "../../validators/presence.js";
import {
PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS,
containsPresenceTemplateVariables,
renderPresenceTemplate,
} from "./templateVariables.js";
import type { PresenceRepository } from "./repository.js";
import type { PresencePanelSession, PresenceRuntimeState } from "./types.js";
const DISCORD_ACTIVITY_TYPES: Record<PresenceActivityTypeValue, ActivityType> = {
PLAYING: ActivityType.Playing,
STREAMING: ActivityType.Streaming,
WATCHING: ActivityType.Watching,
LISTENING: ActivityType.Listening,
COMPETING: ActivityType.Competing,
CUSTOM: ActivityType.Custom,
};
const createRuntimeState = (): PresenceRuntimeState => ({
dynamicPresenceRefreshTimer: null,
presenceRotationTimer: null,
activePresenceTextIndex: 0,
});
const clearRuntimeTimers = (runtimeState: PresenceRuntimeState): void => {
if (runtimeState.dynamicPresenceRefreshTimer) {
clearInterval(runtimeState.dynamicPresenceRefreshTimer);
runtimeState.dynamicPresenceRefreshTimer = null;
}
if (runtimeState.presenceRotationTimer) {
clearInterval(runtimeState.presenceRotationTimer);
runtimeState.presenceRotationTimer = null;
}
};
const resolveDiscordStatus = (status: PresenceStatusValue): "online" | "idle" | "dnd" | "invisible" => {
return status === "streaming" ? "online" : status;
};
export class PresenceService {
private readonly runtimeByBotId = new Map<string, PresenceRuntimeState>();
private readonly panelSessions = new ComponentSessionRegistry<PresencePanelSession>();
public constructor(
private readonly repository: PresenceRepository,
private readonly streamUrl: string,
) {}
public resolveBotId(client: Client): string | null {
return client.user?.id ?? null;
}
public getRuntimeState(client: Client): PresenceRuntimeState {
const botId = this.resolveBotId(client) ?? "unbound";
const existing = this.runtimeByBotId.get(botId);
if (existing) {
return existing;
}
const next = createRuntimeState();
this.runtimeByBotId.set(botId, next);
return next;
}
public normalizeState(state: PresenceState, runtimeState: PresenceRuntimeState): void {
const activityTexts = sanitizeActivityTexts(state.activity.texts);
state.activity.texts = activityTexts;
state.activity.text = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
state.activity.rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
if (runtimeState.activePresenceTextIndex >= activityTexts.length) {
runtimeState.activePresenceTextIndex = 0;
}
}
public getActiveTemplateText(state: PresenceState, runtimeState: PresenceRuntimeState): string {
this.normalizeState(state, runtimeState);
return state.activity.texts[runtimeState.activePresenceTextIndex] ?? state.activity.text;
}
public renderPreview(client: Client, templateText: string): string {
return renderPresenceTemplate(client, templateText);
}
public async loadState(client: Client): Promise<PresenceState> {
const botId = this.resolveBotId(client);
if (!botId) {
return createDefaultPresenceState();
}
return this.repository.getByBotId(botId);
}
public applyState(client: Client, state: PresenceState): void {
const runtimeState = this.getRuntimeState(client);
this.applyPresenceState(client, state, runtimeState);
this.syncDynamicPresenceTimers(client, state, runtimeState);
}
public async persistAndApply(client: Client, state: PresenceState): Promise<void> {
this.applyState(client, state);
const botId = this.resolveBotId(client);
if (!botId) {
return;
}
await this.repository.upsertByBotId(botId, state);
}
public async restoreFromStorage(client: Client): Promise<void> {
const state = await this.loadState(client);
const runtimeState = this.getRuntimeState(client);
runtimeState.activePresenceTextIndex = 0;
this.applyPresenceState(client, state, runtimeState);
this.syncDynamicPresenceTimers(client, state, runtimeState);
}
public panelSessionKey(client: Client, userId: string): string {
return `${this.resolveBotId(client) ?? "unbound"}:${userId}`;
}
public async replacePanelSession(key: string, session: PresencePanelSession): Promise<void> {
await this.panelSessions.replace(key, session);
}
public deletePanelSessionIfCollectorMatch(key: string, collector: PresencePanelSession["collector"]): void {
this.panelSessions.deleteIfCollectorMatch(key, collector);
}
public async shutdown(): Promise<void> {
for (const runtimeState of this.runtimeByBotId.values()) {
clearRuntimeTimers(runtimeState);
}
this.runtimeByBotId.clear();
await this.panelSessions.stopAll("shutdown");
}
private applyPresenceState(client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void {
if (!client.user) {
return;
}
this.normalizeState(state, runtimeState);
const status = resolveDiscordStatus(state.status);
const templateText = this.getActiveTemplateText(state, runtimeState);
const text = renderPresenceTemplate(client, templateText);
if (state.status === "streaming" || state.activity.type === "STREAMING") {
client.user.setPresence({
status,
activities: [
{
type: ActivityType.Streaming,
name: text,
url: this.streamUrl,
},
],
});
return;
}
if (state.activity.type === "CUSTOM") {
client.user.setPresence({
status,
activities: [
{
type: ActivityType.Custom,
name: "Custom Status",
state: text,
},
],
});
return;
}
client.user.setPresence({
status,
activities: [
{
type: DISCORD_ACTIVITY_TYPES[state.activity.type],
name: text,
},
],
});
}
private syncDynamicPresenceTimers(client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void {
this.normalizeState(state, runtimeState);
clearRuntimeTimers(runtimeState);
if (state.activity.texts.length > 1) {
runtimeState.presenceRotationTimer = setInterval(() => {
this.normalizeState(state, runtimeState);
if (state.activity.texts.length <= 1) {
runtimeState.activePresenceTextIndex = 0;
return;
}
runtimeState.activePresenceTextIndex = (runtimeState.activePresenceTextIndex + 1) % state.activity.texts.length;
this.applyPresenceState(client, state, runtimeState);
}, state.activity.rotationIntervalSeconds * 1_000);
runtimeState.presenceRotationTimer.unref?.();
}
const hasKnownVariable = state.activity.texts.some((templateText) => containsPresenceTemplateVariables(templateText));
if (!hasKnownVariable) {
return;
}
runtimeState.dynamicPresenceRefreshTimer = setInterval(() => {
this.applyPresenceState(client, state, runtimeState);
}, PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS);
runtimeState.dynamicPresenceRefreshTimer.unref?.();
}
}
@@ -0,0 +1,89 @@
import type { Client } from "discord.js";
import { env } from "../../config/env.js";
import { hasTemplateVariable, renderTemplate } from "../../utils/templateVariables.js";
import { sanitizeActivityText } from "../../validators/presence.js";
export const PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS = 60_000;
export const PRESENCE_VISIBLE_TEMPLATE_VARIABLES = [
"bot_name",
"bot_tag",
"bot_id",
"guild_count",
"member_count",
"channel_count",
"uptime",
"uptime_seconds",
"prefix",
] as const;
const PRESENCE_TEMPLATE_VARIABLE_ALIASES: Record<string, string> = {
bot: "bot_name",
guilds: "guild_count",
servers: "guild_count",
users: "member_count",
members: "member_count",
channels: "channel_count",
};
const PRESENCE_KNOWN_TEMPLATE_VARIABLES = new Set<string>([
...PRESENCE_VISIBLE_TEMPLATE_VARIABLES,
...Object.keys(PRESENCE_TEMPLATE_VARIABLE_ALIASES),
]);
const formatUptime = (totalSeconds: number): string => {
const days = Math.floor(totalSeconds / 86_400);
const hours = Math.floor((totalSeconds % 86_400) / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
const parts: string[] = [];
if (days > 0) {
parts.push(`${days}d`);
}
if (hours > 0 || days > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0 || hours > 0 || days > 0) {
parts.push(`${minutes}m`);
}
parts.push(`${seconds}s`);
return parts.join(" ");
};
const buildPresenceTemplateValues = (client: Client): Record<string, string> => {
const guildCount = client.guilds.cache.size;
const memberCount = client.guilds.cache.reduce((total, guild) => total + (guild.memberCount ?? 0), 0);
const channelCount = client.channels.cache.size;
const uptimeSeconds = Math.max(0, Math.floor((client.uptime ?? process.uptime() * 1_000) / 1_000));
return {
bot_name: client.user?.username ?? "bot",
bot_tag: client.user?.tag ?? "bot#0000",
bot_id: client.user?.id ?? "unknown",
guild_count: String(guildCount),
member_count: String(memberCount),
channel_count: String(channelCount),
uptime: formatUptime(uptimeSeconds),
uptime_seconds: String(uptimeSeconds),
prefix: env.PREFIX,
};
};
export const renderPresenceTemplate = (client: Client, template: string): string => {
const sanitizedTemplate = sanitizeActivityText(template);
const rendered = renderTemplate(sanitizedTemplate, buildPresenceTemplateValues(client), {
aliases: PRESENCE_TEMPLATE_VARIABLE_ALIASES,
keepUnknown: true,
});
return sanitizeActivityText(rendered);
};
export const containsPresenceTemplateVariables = (template: string): boolean =>
hasTemplateVariable(template, PRESENCE_KNOWN_TEMPLATE_VARIABLES, PRESENCE_TEMPLATE_VARIABLE_ALIASES);
export const getPresenceTemplateHelpText = (): string =>
PRESENCE_VISIBLE_TEMPLATE_VARIABLES.map((name) => `{{${name}}}`).join(", ");
@@ -0,0 +1,20 @@
import type { ComponentPanelSession } from "../../core/discord/componentSessionRegistry.js";
export interface PresenceCustomIds {
statusSelect: string;
activitySelect: string;
textButton: string;
intervalButton: string;
textModal: string;
textInput: string;
intervalModal: string;
intervalInput: string;
}
export interface PresenceRuntimeState {
dynamicPresenceRefreshTimer: NodeJS.Timeout | null;
presenceRotationTimer: NodeJS.Timeout | null;
activePresenceTextIndex: number;
}
export interface PresencePanelSession extends ComponentPanelSession {}
@@ -0,0 +1,89 @@
import type {
CommandExecutionContext,
ExecutionContext,
I18nContext,
SupportedLang,
TransportContext,
TranslationVars,
} from "../types/command.js";
import type { BuildExecutionContextInput, HandlerExecutionDeps } from "../types/handlers.js";
import type { I18nService } from "../i18n/index.js";
import { createDiscordMemberPermissionsResolver } from "./discordPermissionResolver.js";
export const createTranslator = (
i18n: I18nService,
lang: SupportedLang,
): ((key: string, vars?: TranslationVars) => string) => {
return (key: string, vars?: TranslationVars): string => i18n.t(lang, key, vars);
};
export const buildCommandExecutionContext = (
deps: HandlerExecutionDeps,
input: BuildExecutionContextInput,
): CommandExecutionContext => {
const t = createTranslator(deps.i18n, input.lang);
const ct = (relativeKey: string, vars?: TranslationVars): string =>
deps.i18n.commandT(input.lang, input.command.meta.name, relativeKey, vars);
const execution: ExecutionContext = {
requestId: input.requestId,
receivedAt: Date.now(),
source: input.source,
commandName: input.command.meta.name,
commandCategory: input.command.meta.category,
args: input.args,
actor: {
userId: input.user.id,
guildId: input.guild?.id ?? null,
channelId: input.channel?.id ?? null,
},
};
const transport: TransportContext = {
kind: "discord",
client: input.client,
user: input.user,
guild: input.guild,
channel: input.channel,
raw: input.raw,
reply: input.reply,
resolveMemberPermissions: createDiscordMemberPermissionsResolver(input),
};
const i18nContext: I18nContext = {
lang: input.lang,
t,
ct,
commandText: deps.i18n.commandObject(input.lang, input.command.meta.name),
format: (template, vars) => deps.i18n.format(template, vars),
i18n: deps.i18n,
registry: deps.registry,
prefix: deps.prefix,
defaultLang: deps.defaultLang,
};
return {
execution,
transport,
i18nContext,
client: transport.client,
user: transport.user,
guild: transport.guild,
channel: transport.channel,
lang: i18nContext.lang,
args: execution.args,
command: input.command,
source: execution.source,
t: i18nContext.t,
ct: i18nContext.ct,
commandText: i18nContext.commandText,
format: i18nContext.format,
i18n: i18nContext.i18n,
raw: transport.raw,
reply: transport.reply,
registry: i18nContext.registry,
prefix: i18nContext.prefix,
defaultLang: i18nContext.defaultLang,
};
};
@@ -0,0 +1,82 @@
import {
PermissionsBitField,
type ChatInputCommandInteraction,
type Guild,
type Message,
} from "discord.js";
import type { BuildExecutionContextInput } from "../types/handlers.js";
const resolveFromGuild = async (guild: Guild | null, userId: string): Promise<Readonly<PermissionsBitField> | null> => {
if (!guild) {
return null;
}
const cached = guild.members.cache.get(userId);
if (cached?.permissions) {
return cached.permissions;
}
const fetched = await guild.members.fetch(userId).catch(() => null);
return fetched?.permissions ?? null;
};
const resolveFromInteractionMember = (
interaction: ChatInputCommandInteraction,
): Readonly<PermissionsBitField> | null => {
if (interaction.memberPermissions) {
return interaction.memberPermissions;
}
const member = interaction.member;
if (member && typeof member === "object" && "permissions" in member) {
const rawPermissions = (member as { permissions?: string | number | bigint }).permissions;
if (rawPermissions !== undefined) {
try {
const normalized = typeof rawPermissions === "string" || typeof rawPermissions === "number"
? BigInt(rawPermissions)
: rawPermissions;
return new PermissionsBitField(normalized);
} catch {
return null;
}
}
}
return null;
};
export const createDiscordMemberPermissionsResolver = (
input: BuildExecutionContextInput,
): (() => Promise<Readonly<PermissionsBitField> | null>) => {
if (input.source === "slash") {
const interaction = input.raw as ChatInputCommandInteraction;
return async (): Promise<Readonly<PermissionsBitField> | null> => {
const direct = resolveFromInteractionMember(interaction);
if (direct) {
return direct;
}
return resolveFromGuild(interaction.guild, interaction.user.id);
};
}
const message = input.raw as Message;
return async (): Promise<Readonly<PermissionsBitField> | null> => {
const directFromMessage = message.member?.permissions;
if (directFromMessage) {
return directFromMessage;
}
const fallbackFromGuildMember = message.guild?.members.resolve(message.author.id)?.permissions;
if (fallbackFromGuildMember) {
return fallbackFromGuildMember;
}
return resolveFromGuild(message.guild, message.author.id);
};
};
@@ -0,0 +1,122 @@
import { randomUUID } from "node:crypto";
import type { Message } from "discord.js";
import type { BotCommand, SupportedLang } from "../types/command.js";
import type { PrefixHandlerDeps } from "../types/handlers.js";
import { parsePrefixArgs } from "../core/commands/argParser.js";
import { buildPrefixUsage } from "../core/commands/usage.js";
import {
buildCommandExecutionContext,
createTranslator,
} from "./commandExecutionContext.js";
import { createPrefixReply } from "./replyAdapter.js";
const PREFIX_PRE_PARSE_THROTTLE_MS = 300;
const PREFIX_PRE_PARSE_THROTTLE_SWEEP_INTERVAL_MS = 60_000;
const PREFIX_PRE_PARSE_THROTTLE_MAX_AGE_MS = 5 * 60_000;
const prefixPreParseThrottle = new Map<string, number>();
let prefixPreParseThrottleLastSweepAt = 0;
const shouldThrottlePrefixPreParse = (userId: string): boolean => {
const now = Date.now();
const lastSeenAt = prefixPreParseThrottle.get(userId);
if (lastSeenAt !== undefined && now - lastSeenAt < PREFIX_PRE_PARSE_THROTTLE_MS) {
return true;
}
prefixPreParseThrottle.set(userId, now);
if (now - prefixPreParseThrottleLastSweepAt >= PREFIX_PRE_PARSE_THROTTLE_SWEEP_INTERVAL_MS) {
for (const [trackedUserId, trackedAt] of prefixPreParseThrottle.entries()) {
if (now - trackedAt >= PREFIX_PRE_PARSE_THROTTLE_MAX_AGE_MS) {
prefixPreParseThrottle.delete(trackedUserId);
}
}
prefixPreParseThrottleLastSweepAt = now;
}
return false;
};
const resolvePrefixLang = (
deps: PrefixHandlerDeps,
command: BotCommand,
trigger: string,
fallbackLang: SupportedLang,
guildPreferredLocale?: string | null,
): SupportedLang => {
const contextualLang = deps.i18n.resolveLang(guildPreferredLocale);
const contextualTrigger = deps.i18n.commandTrigger(contextualLang, command.meta.name);
if (contextualTrigger === trigger) {
return contextualLang;
}
return fallbackLang;
};
export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
return async (message: Message): Promise<void> => {
if (message.author.bot || !message.content.startsWith(deps.prefix)) {
return;
}
const content = message.content.slice(deps.prefix.length).trim();
if (!content) {
return;
}
const firstSpaceIndex = content.indexOf(" ");
const trigger = (firstSpaceIndex === -1 ? content : content.slice(0, firstSpaceIndex)).toLowerCase();
const rawArgs = firstSpaceIndex === -1 ? "" : content.slice(firstSpaceIndex + 1);
const match = deps.registry.findByAnyPrefixTrigger(trigger);
if (!match) {
return;
}
if (shouldThrottlePrefixPreParse(message.author.id)) {
return;
}
const reply = createPrefixReply(message);
const command = match.command;
const lang = resolvePrefixLang(deps, command, trigger, match.lang, message.guild?.preferredLocale ?? null);
const t = createTranslator(deps.i18n, lang);
const parsed = await parsePrefixArgs(message, command.args, rawArgs);
if (parsed.errors.length > 0) {
const firstError = parsed.errors[0];
if (!firstError) {
return;
}
const usage = buildPrefixUsage(command, deps.prefix, lang, deps.defaultLang, deps.i18n);
await reply(t(firstError.key, { ...(firstError.vars ?? {}), usage }));
return;
}
await deps.dispatcher.dispatch(
command,
buildCommandExecutionContext(deps, {
requestId: randomUUID(),
command,
source: "prefix",
lang,
args: parsed.values,
client: message.client,
user: message.author,
guild: message.guild,
channel: message.channel,
raw: message,
reply,
}),
);
};
};
@@ -0,0 +1,174 @@
import type {
ChatInputCommandInteraction,
InteractionReplyOptions,
Message,
MessageReplyOptions,
} from "discord.js";
import type { ReplyPayload } from "../types/command.js";
import type { PrefixReplyObject } from "../types/reply.js";
const PREFIX_EPHEMERAL_DELETE_DELAY_MS = 10_000;
const PREFIX_ALLOWED_MENTIONS_DEFAULT: NonNullable<MessageReplyOptions["allowedMentions"]> = {
parse: [],
repliedUser: false,
};
const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable<InteractionReplyOptions["allowedMentions"]> = {
parse: [],
};
const EPHEMERAL_FLAG = 64n;
const FLAG_NAME_TO_BITS: Record<string, bigint> = {
Ephemeral: 64n,
SuppressEmbeds: 4n,
SuppressNotifications: 4096n,
IsComponentsV2: 32768n,
};
const hasBitfieldLike = (value: unknown): value is { bitfield: bigint | number } => {
return Boolean(value) && typeof value === "object" && "bitfield" in (value as Record<string, unknown>);
};
const toFlagBits = (flags: unknown): bigint | null => {
if (flags === undefined || flags === null) {
return null;
}
if (typeof flags === "number" || typeof flags === "bigint") {
return BigInt(flags);
}
if (typeof flags === "string") {
if (/^\d+$/.test(flags)) {
return BigInt(flags);
}
return FLAG_NAME_TO_BITS[flags] ?? null;
}
if (Array.isArray(flags)) {
let merged = 0n;
for (const entry of flags) {
const bits = toFlagBits(entry);
if (bits !== null) {
merged |= bits;
}
}
return merged;
}
if (hasBitfieldLike(flags)) {
return BigInt(flags.bitfield);
}
return null;
};
const hasEphemeralInFlags = (flags: unknown): boolean => {
const bits = toFlagBits(flags);
return bits !== null && (bits & EPHEMERAL_FLAG) !== 0n;
};
const sanitizePrefixFlags = (flags: unknown): MessageReplyOptions["flags"] | undefined => {
const bits = toFlagBits(flags);
if (bits === null) {
return undefined;
}
const sanitizedBits = bits & ~EPHEMERAL_FLAG;
if (sanitizedBits === 0n) {
return undefined;
}
return Number(sanitizedBits);
};
const hasEphemeral = (payload: PrefixReplyObject): boolean => {
const slashPayload = payload as InteractionReplyOptions;
return hasEphemeralInFlags(slashPayload.flags);
};
const scheduleDelete = (message: Message): void => {
setTimeout(() => {
void message.delete().catch(() => undefined);
}, PREFIX_EPHEMERAL_DELETE_DELAY_MS);
};
const withPrefixAllowedMentions = (options: MessageReplyOptions): MessageReplyOptions => {
return {
...options,
allowedMentions: {
...PREFIX_ALLOWED_MENTIONS_DEFAULT,
...(options.allowedMentions ?? {}),
},
};
};
const withSlashAllowedMentions = (options: InteractionReplyOptions): InteractionReplyOptions => {
return {
...options,
allowedMentions: {
...SLASH_ALLOWED_MENTIONS_DEFAULT,
...(options.allowedMentions ?? {}),
},
};
};
const toMessageReplyOptions = (payload: Exclude<ReplyPayload, string>): MessageReplyOptions => {
const rest = { ...(payload as Record<string, unknown>) };
const sanitizedFlags = sanitizePrefixFlags((payload as InteractionReplyOptions).flags);
// Drop interaction-only fields so prefix replies stay valid message payloads.
delete rest.fetchReply;
delete rest.withResponse;
delete rest.ephemeral;
delete rest.flags;
if (sanitizedFlags !== undefined) {
rest.flags = sanitizedFlags;
}
return rest as MessageReplyOptions;
};
export const createPrefixReply = (message: Message): ((payload: ReplyPayload) => Promise<unknown>) => {
return async (payload: ReplyPayload): Promise<unknown> => {
if (typeof payload === "string") {
return message.reply(withPrefixAllowedMentions({ content: payload }));
}
const shouldDeleteAfterDelay = hasEphemeral(payload);
const sent = await message.reply(withPrefixAllowedMentions(toMessageReplyOptions(payload)));
if (shouldDeleteAfterDelay) {
scheduleDelete(sent);
}
return sent;
};
};
export const createSlashReply = (
interaction: ChatInputCommandInteraction,
): ((payload: ReplyPayload) => Promise<unknown>) => {
return async (payload: ReplyPayload): Promise<unknown> => {
if (typeof payload === "string") {
const options = withSlashAllowedMentions({ content: payload });
if (interaction.replied || interaction.deferred) {
return interaction.followUp(options);
}
return interaction.reply(options);
}
const options = withSlashAllowedMentions(payload as InteractionReplyOptions);
if (interaction.replied || interaction.deferred) {
return interaction.followUp(options);
}
return interaction.reply(options);
};
};
@@ -0,0 +1,57 @@
import { randomUUID } from "node:crypto";
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import type { SlashHandlerDeps } from "../types/handlers.js";
import { parseSlashArgs } from "../core/commands/argParser.js";
import { buildSlashUsage } from "../core/commands/usage.js";
import {
buildCommandExecutionContext,
createTranslator,
} from "./commandExecutionContext.js";
import { createSlashReply } from "./replyAdapter.js";
export const createSlashHandler = (deps: SlashHandlerDeps) => {
return async (interaction: ChatInputCommandInteraction): Promise<void> => {
const command = deps.registry.findBySlashTrigger(interaction.commandName);
if (!command) {
return;
}
const lang = deps.i18n.resolveLang(interaction.locale ?? interaction.guildLocale);
const reply = createSlashReply(interaction);
const t = createTranslator(deps.i18n, lang);
const parsed = parseSlashArgs(interaction, command.args);
if (parsed.errors.length > 0) {
const firstError = parsed.errors[0];
if (!firstError) {
return;
}
const usage = buildSlashUsage(command, lang, deps.i18n);
await reply({
content: t(firstError.key, { ...(firstError.vars ?? {}), usage }),
flags: [MessageFlags.Ephemeral],
});
return;
}
await deps.dispatcher.dispatch(
command,
buildCommandExecutionContext(deps, {
requestId: randomUUID(),
command,
source: "slash",
lang,
args: parsed.values,
client: interaction.client,
user: interaction.user,
guild: interaction.guild,
channel: interaction.channel,
raw: interaction,
reply,
}),
);
};
};
+376
View File
@@ -0,0 +1,376 @@
{
"errors": {
"args": {
"missing": "Missing argument {{arg}}. Usage: {{usage}}",
"invalidInt": "Argument must be an integer. Received: {{value}}",
"invalidNumber": "Argument must be a number. Received: {{value}}",
"invalidBoolean": "Argument must be a boolean value. Received: {{value}}",
"invalidUser": "Could not resolve a user from {{value}}",
"invalidChannel": "Could not resolve a channel from {{value}}",
"invalidRole": "Could not resolve a role from {{value}}",
"tooMany": "Too many arguments detected ({{count}}): {{extras}}. Usage: {{usage}}"
},
"permissions": {
"user": "You are missing permissions: {{permissions}}"
},
"execution": "An unexpected error happened while running this command.",
"cooldown": "Please wait {{seconds}}s before using this command again.",
"rateLimit": "Too many requests. Please wait {{seconds}}s before retrying.",
"executionQueued": "Command accepted and queued for processing."
},
"categories": {
"fun": "Fun",
"utility": "Utility",
"core": "Core"
},
"commands": {
"kiss": {
"name": "kiss",
"description": "Send a kiss to another user.",
"args": {
"user": "Target user"
},
"examples": {
"basic": "Send a kiss to a member"
},
"responses": {
"success": "{{from}} sends a kiss to {{to}}."
}
},
"ping": {
"name": "ping",
"description": "Check bot websocket latency.",
"examples": {
"basic": "Basic health check"
},
"responses": {
"pong": "Pong. Websocket latency: {{latency}}ms."
}
},
"presence": {
"name": "presence",
"description": "Configure bot presence with an interactive panel.",
"examples": {
"slash": "Open the interactive presence panel"
},
"responses": {
"panel": "Presence panel\nStatus: {{status}}\nActivity: {{activityType}}\nRotation: every {{rotationIntervalSeconds}}s\nActive text ({{currentTextIndex}}/{{textCount}}): {{activityText}}\nRendered preview: {{activityPreview}}\nTexts: {{activityTexts}}\nVariables ({{doubleBracesHint}}): {{variables}}",
"notOwner": "Only the command author can use this panel.",
"invalidSelection": "Invalid selection received.",
"invalidInterval": "Invalid interval. Use an integer between {{minSeconds}} and {{maxSeconds}} seconds.",
"modalTimeout": "Text update timed out. Click the button again."
},
"ui": {
"status": {
"placeholder": "Choose a status",
"options": {
"online": {
"label": "Online",
"description": "Displayed as online"
},
"idle": {
"label": "Idle",
"description": "Displayed as idle"
},
"dnd": {
"label": "Do Not Disturb",
"description": "Displayed as do not disturb"
},
"invisible": {
"label": "Invisible",
"description": "Displayed as offline"
},
"streaming": {
"label": "Streaming",
"description": "Displayed in streaming mode"
}
}
},
"activity": {
"placeholder": "Choose an activity type",
"options": {
"PLAYING": {
"label": "Playing",
"description": "Shows as Playing ..."
},
"STREAMING": {
"label": "Streaming",
"description": "Shows as Streaming ..."
},
"WATCHING": {
"label": "Watching",
"description": "Shows as Watching ..."
},
"LISTENING": {
"label": "Listening",
"description": "Shows as Listening to ..."
},
"COMPETING": {
"label": "Competing",
"description": "Shows as Competing in ..."
},
"CUSTOM": {
"label": "CustomStatus",
"description": "Shows a custom status"
}
}
},
"embed": {
"title": "Presence Configuration",
"description": "Change status, activity type, multiple texts, and rotation interval in real time.",
"footer": "Interactive /presence panel",
"fields": {
"status": "Status",
"activity": "Activity type",
"text": "Text"
}
},
"textButton": "📝 Edit texts",
"intervalButton": "⏱️ Interval",
"modal": {
"title": "Presence texts",
"label": "One text per line (use {{var}})",
"placeholder": "Maintenance\n{{guild_count}} servers | {{prefix}}help"
},
"intervalModal": {
"title": "Rotation interval",
"label": "Seconds between each text",
"placeholder": "Example: 30"
}
}
},
"logs": {
"name": "logs",
"description": "Configure Discord event logs with an interactive panel.",
"examples": {
"slash": "Open the interactive logs panel"
},
"responses": {
"slashOnly": "Use /logs to open this interactive panel.",
"guildOnly": "This command can only be used inside a server.",
"notOwner": "Only the command author can use this panel.",
"invalidSelection": "Invalid selection.",
"allEnabled": "All log events are now enabled.",
"allDisabled": "All log events are now disabled.",
"eventEnabled": "Event {{event}} enabled.",
"eventDisabled": "Event {{event}} disabled.",
"channelsCreated": "Log channels ready. Created: {{created}}, reused: {{reused}}.",
"channelsPartial": "Log channels partially ready. Created: {{created}}, reused: {{reused}}, failed categories: {{failed}}.",
"channelsFailed": "Could not create log channels. Failed categories: {{failed}}."
},
"ui": {
"embed": {
"title": "Logs Configuration",
"description": "Enable or disable each Discord event and route logs by category channels."
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"pageLabel": "Page {{page}}/{{pageCount}}",
"enabledCountLabel": "Enabled events: {{enabledCount}}/{{totalCount}}",
"eventsHeader": "Events",
"channelLabel": "Channel",
"channelNotConfigured": "#not-configured",
"feedbackLabel": "Last action",
"buttons": {
"enableAll": "✅ Enable all logs",
"disableAll": "❌ Disable all logs",
"createChannels": "📁 Create log channels",
"previousPage": "Previous",
"nextPage": "Next",
"enableEvent": "Enable {{event}}",
"disableEvent": "Disable {{event}}"
}
}
},
"welcome": {
"name": "welcome",
"description": "Configure welcome messages with an interactive panel.",
"examples": {
"slash": "Open the welcome message configuration panel"
},
"responses": {
"guildOnly": "This command can only be used inside a server.",
"notOwner": "Only the command author can use this panel.",
"invalidSelection": "Invalid selection.",
"testSuccess": "Test message sent in {{channel}}.",
"testDisabled": "Welcome module is disabled.",
"testMissingChannel": "No channel configured yet. Use the channel button first.",
"testChannelUnavailable": "Configured channel was not found or is not supported.",
"testMissingPermissions": "Bot is missing permission to send messages in that channel.",
"testFailed": "Failed to send test message. Please try again later."
},
"ui": {
"embed": {
"title": "Welcome Configuration",
"description": "Configure status, channel, message type, and run a test send.",
"fields": {
"status": "Status",
"channel": "Channel",
"type": "Type",
"channelPicker": "Channel picker",
"autoRoles": "Auto roles"
}
},
"status": {
"enabled": "🟢 Enabled",
"disabled": "🔴 Disabled"
},
"channelNotConfigured": "#not-configured",
"autoRolesNotConfigured": "No auto role configured",
"channelPickerPlaceholder": "Select a welcome channel",
"channelPickerHint": "Use the select menu below to choose the destination channel.",
"rolePickerPlaceholder": "Select one or more auto roles",
"rolePickerHint": "Use the role select menu below to append roles to the auto-role list.",
"buttons": {
"toggle": "Toggle status",
"channel": "Set channel",
"channelCancel": "Close channel picker",
"roles": "Add auto roles",
"rolesCancel": "Close role picker",
"rolesClear": "Clear auto roles",
"test": "Test"
},
"type": {
"placeholder": "Choose a message type",
"options": {
"simple": {
"label": "Simple message",
"description": "Send a classic text message"
},
"embed": {
"label": "Embed",
"description": "Send a rich embed message"
},
"container": {
"label": "Container (V2)",
"description": "Send a Components V2 container"
},
"image": {
"label": "Image",
"description": "Send an embed with an image"
}
}
}
},
"templates": {
"simple": "🎉 Welcome {{user}} to **{{guild}}**!",
"embedTitle": "Welcome!",
"embedDescription": "{{user}} just joined **{{guild}}**.",
"containerTitle": "Welcome",
"containerDescription": "{{user}} just joined **{{guild}}**.",
"imageTitle": "Welcome",
"imageDescription": "on the Discord server {{guild}}"
}
},
"goodbye": {
"name": "goodbye",
"description": "Configure goodbye messages with an interactive panel.",
"examples": {
"slash": "Open the goodbye message configuration panel"
},
"responses": {
"guildOnly": "This command can only be used inside a server.",
"notOwner": "Only the command author can use this panel.",
"invalidSelection": "Invalid selection.",
"testSuccess": "Test message sent in {{channel}}.",
"testDisabled": "Goodbye module is disabled.",
"testMissingChannel": "No channel configured yet. Use the channel button first.",
"testChannelUnavailable": "Configured channel was not found or is not supported.",
"testMissingPermissions": "Bot is missing permission to send messages in that channel.",
"testFailed": "Failed to send test message. Please try again later."
},
"ui": {
"embed": {
"title": "Goodbye Configuration",
"description": "Configure status, channel, message type, and run a test send.",
"fields": {
"status": "Status",
"channel": "Channel",
"type": "Type",
"channelPicker": "Channel picker"
}
},
"status": {
"enabled": "🟢 Enabled",
"disabled": "🔴 Disabled"
},
"channelNotConfigured": "#not-configured",
"channelPickerPlaceholder": "Select a goodbye channel",
"channelPickerHint": "Use the select menu below to choose the destination channel.",
"buttons": {
"toggle": "Toggle status",
"channel": "Set channel",
"channelCancel": "Close channel picker",
"test": "Test"
},
"type": {
"placeholder": "Choose a message type",
"options": {
"simple": {
"label": "Simple message",
"description": "Send a classic text message"
},
"embed": {
"label": "Embed",
"description": "Send a rich embed message"
},
"container": {
"label": "Container (V2)",
"description": "Send a Components V2 container"
},
"image": {
"label": "Image",
"description": "Send an embed with an image"
}
}
}
},
"templates": {
"simple": "👋 {{user}} left **{{guild}}**.",
"embedTitle": "Goodbye",
"embedDescription": "{{user}} has left **{{guild}}**.",
"containerTitle": "Goodbye",
"containerDescription": "{{user}} has left **{{guild}}**.",
"imageTitle": "Goodbye",
"imageDescription": "from the Discord server {{guild}}"
}
},
"help": {
"name": "help",
"description": "Display command list or details for one command.",
"args": {
"command": "Optional command name or trigger"
},
"examples": {
"basic": "List all commands",
"single": "Show details for one command"
},
"errors": {
"notFound": "No command matches {{query}}."
},
"embed": {
"title": "Command Help",
"description": "Use {{usage}} to inspect one command.",
"categoryEmpty": "No commands in this category.",
"detailsTitle": "Command: {{name}}",
"detailsDescription": "{{description}}",
"footer": "Category: {{source}}",
"fields": {
"usage": "Usage",
"arguments": "Arguments",
"examples": "Examples"
}
},
"labels": {
"prefix": "Prefix",
"slash": "Slash",
"required": "required",
"optional": "optional",
"noArgs": "No arguments.",
"noExamples": "No examples."
}
}
}
}
+376
View File
@@ -0,0 +1,376 @@
{
"errors": {
"args": {
"missing": "Falta el argumento {{arg}}. Uso: {{usage}}",
"invalidInt": "El argumento debe ser un entero. Recibido: {{value}}",
"invalidNumber": "El argumento debe ser un numero. Recibido: {{value}}",
"invalidBoolean": "El argumento debe ser un valor booleano. Recibido: {{value}}",
"invalidUser": "No se pudo resolver un usuario desde {{value}}",
"invalidChannel": "No se pudo resolver un canal desde {{value}}",
"invalidRole": "No se pudo resolver un rol desde {{value}}",
"tooMany": "Se detectaron demasiados argumentos ({{count}}): {{extras}}. Uso: {{usage}}"
},
"permissions": {
"user": "Te faltan permisos: {{permissions}}"
},
"execution": "Ocurrio un error inesperado al ejecutar este comando.",
"cooldown": "Espera {{seconds}}s antes de volver a usar este comando.",
"rateLimit": "Demasiadas solicitudes. Espera {{seconds}}s antes de reintentar.",
"executionQueued": "Comando aceptado y encolado para procesamiento."
},
"categories": {
"fun": "Diversion",
"utility": "Utilidad",
"core": "Nucleo"
},
"commands": {
"kiss": {
"name": "beso",
"description": "Enviar un beso a otro usuario.",
"args": {
"user": "Usuario objetivo"
},
"examples": {
"basic": "Enviar un beso a un miembro"
},
"responses": {
"success": "{{from}} envia un beso a {{to}}."
}
},
"ping": {
"name": "ping",
"description": "Revisar latencia websocket del bot.",
"examples": {
"basic": "Chequeo basico"
},
"responses": {
"pong": "Pong. Latencia websocket: {{latency}}ms."
}
},
"presence": {
"name": "presence",
"description": "Configurar la presencia del bot con un panel interactivo.",
"examples": {
"slash": "Abrir el panel interactivo de presencia"
},
"responses": {
"panel": "Panel de presencia\nEstado: {{status}}\nActividad: {{activityType}}\nRotacion: cada {{rotationIntervalSeconds}}s\nTexto activo ({{currentTextIndex}}/{{textCount}}): {{activityText}}\nVista previa: {{activityPreview}}\nTextos: {{activityTexts}}\nVariables ({{doubleBracesHint}}): {{variables}}",
"notOwner": "Solo el autor del comando puede usar este panel.",
"invalidSelection": "Seleccion invalida.",
"invalidInterval": "Intervalo invalido. Usa un entero entre {{minSeconds}} y {{maxSeconds}} segundos.",
"modalTimeout": "Tiempo agotado para editar el texto. Pulsa el boton otra vez."
},
"ui": {
"status": {
"placeholder": "Elegir un estado",
"options": {
"online": {
"label": "En linea",
"description": "Muestra el bot en linea"
},
"idle": {
"label": "Inactivo",
"description": "Muestra el bot inactivo"
},
"dnd": {
"label": "No molestar",
"description": "Muestra el bot en no molestar"
},
"invisible": {
"label": "Desconectado",
"description": "Muestra el bot fuera de linea"
},
"streaming": {
"label": "Streaming",
"description": "Muestra el bot en modo streaming"
}
}
},
"activity": {
"placeholder": "Elegir un tipo de actividad",
"options": {
"PLAYING": {
"label": "Jugando",
"description": "Muestra Jugando a ..."
},
"STREAMING": {
"label": "Streaming",
"description": "Muestra En directo en ..."
},
"WATCHING": {
"label": "Viendo",
"description": "Muestra Viendo ..."
},
"LISTENING": {
"label": "Escuchando",
"description": "Muestra Escuchando ..."
},
"COMPETING": {
"label": "Compitiendo",
"description": "Muestra Compitiendo en ..."
},
"CUSTOM": {
"label": "CustomStatus",
"description": "Muestra un estado personalizado"
}
}
},
"embed": {
"title": "Configuracion de Presencia",
"description": "Cambia el estado, el tipo de actividad, varios textos y el intervalo de rotacion en tiempo real.",
"footer": "Panel interactivo /presence",
"fields": {
"status": "Estado",
"activity": "Tipo de actividad",
"text": "Texto"
}
},
"textButton": "📝 Editar textos",
"intervalButton": "⏱️ Intervalo",
"modal": {
"title": "Textos de presencia",
"label": "Un texto por linea (usa {{var}})",
"placeholder": "Mantenimiento\n{{guild_count}} servidores | {{prefix}}ayuda"
},
"intervalModal": {
"title": "Intervalo de rotacion",
"label": "Segundos entre cada texto",
"placeholder": "Ejemplo: 30"
}
}
},
"logs": {
"name": "logs",
"description": "Configurar logs de eventos Discord con un panel interactivo.",
"examples": {
"slash": "Abrir el panel interactivo de logs"
},
"responses": {
"slashOnly": "Usa /logs para abrir este panel interactivo.",
"guildOnly": "Este comando solo puede usarse dentro de un servidor.",
"notOwner": "Solo el autor del comando puede usar este panel.",
"invalidSelection": "Seleccion invalida.",
"allEnabled": "Todos los logs de eventos estan activados.",
"allDisabled": "Todos los logs de eventos estan desactivados.",
"eventEnabled": "Evento {{event}} activado.",
"eventDisabled": "Evento {{event}} desactivado.",
"channelsCreated": "Canales de logs listos. Creados: {{created}}, reutilizados: {{reused}}.",
"channelsPartial": "Canales de logs parcialmente listos. Creados: {{created}}, reutilizados: {{reused}}, categorias fallidas: {{failed}}.",
"channelsFailed": "No se pudieron crear los canales de logs. Categorias fallidas: {{failed}}."
},
"ui": {
"embed": {
"title": "Configuracion de Logs",
"description": "Activa o desactiva cada evento de Discord y enruta logs por canales de categoria."
},
"status": {
"enabled": "Activado",
"disabled": "Desactivado"
},
"pageLabel": "Pagina {{page}}/{{pageCount}}",
"enabledCountLabel": "Eventos activados: {{enabledCount}}/{{totalCount}}",
"eventsHeader": "Eventos",
"channelLabel": "Canal",
"channelNotConfigured": "#sin-configurar",
"feedbackLabel": "Ultima accion",
"buttons": {
"enableAll": "✅ Activar todos los logs",
"disableAll": "❌ Desactivar todos los logs",
"createChannels": "📁 Crear canales de logs",
"previousPage": "Anterior",
"nextPage": "Siguiente",
"enableEvent": "Activar {{event}}",
"disableEvent": "Desactivar {{event}}"
}
}
},
"welcome": {
"name": "bienvenida",
"description": "Configurar mensajes de bienvenida con un panel interactivo.",
"examples": {
"slash": "Abrir el panel de configuracion de bienvenida"
},
"responses": {
"guildOnly": "Este comando solo puede usarse dentro de un servidor.",
"notOwner": "Solo el autor del comando puede usar este panel.",
"invalidSelection": "Seleccion invalida.",
"testSuccess": "Mensaje de prueba enviado en {{channel}}.",
"testDisabled": "El modulo de bienvenida esta desactivado.",
"testMissingChannel": "No hay canal configurado. Usa primero el boton de canal.",
"testChannelUnavailable": "El canal configurado no existe o no es compatible.",
"testMissingPermissions": "El bot no tiene permiso para enviar mensajes en ese canal.",
"testFailed": "No se pudo enviar el mensaje de prueba. Intentalo mas tarde."
},
"ui": {
"embed": {
"title": "Configuracion Welcome",
"description": "Ajusta estado, canal, tipo de mensaje y ejecuta una prueba.",
"fields": {
"status": "Estado",
"channel": "Canal",
"type": "Tipo",
"channelPicker": "Selector de canal",
"autoRoles": "Roles automaticos"
}
},
"status": {
"enabled": "🟢 Activado",
"disabled": "🔴 Desactivado"
},
"channelNotConfigured": "#sin-configurar",
"autoRolesNotConfigured": "Ningun rol automatico configurado",
"channelPickerPlaceholder": "Selecciona un canal de bienvenida",
"channelPickerHint": "Usa el menu de seleccion de abajo para elegir el canal destino.",
"rolePickerPlaceholder": "Selecciona uno o varios roles automaticos",
"rolePickerHint": "Usa el menu de roles de abajo para agregarlos a la lista automatica.",
"buttons": {
"toggle": "Alternar estado",
"channel": "Configurar canal",
"channelCancel": "Cerrar selector",
"roles": "Agregar roles auto",
"rolesCancel": "Cerrar selector de roles",
"rolesClear": "Limpiar roles auto",
"test": "Probar"
},
"type": {
"placeholder": "Elige un tipo de mensaje",
"options": {
"simple": {
"label": "Mensaje simple",
"description": "Enviar un mensaje de texto clasico"
},
"embed": {
"label": "Embed",
"description": "Enviar un embed de bienvenida"
},
"container": {
"label": "Container (V2)",
"description": "Enviar un container Components V2"
},
"image": {
"label": "Imagen",
"description": "Enviar un embed con imagen"
}
}
}
},
"templates": {
"simple": "🎉 Bienvenido {{user}} a **{{guild}}**!",
"embedTitle": "Bienvenido",
"embedDescription": "{{user}} acaba de entrar en **{{guild}}**.",
"containerTitle": "Bienvenido",
"containerDescription": "{{user}} acaba de entrar en **{{guild}}**.",
"imageTitle": "Bienvenido",
"imageDescription": "al servidor de Discord {{guild}}"
}
},
"goodbye": {
"name": "despedida",
"description": "Configurar mensajes de despedida con un panel interactivo.",
"examples": {
"slash": "Abrir el panel de configuracion de despedida"
},
"responses": {
"guildOnly": "Este comando solo puede usarse dentro de un servidor.",
"notOwner": "Solo el autor del comando puede usar este panel.",
"invalidSelection": "Seleccion invalida.",
"testSuccess": "Mensaje de prueba enviado en {{channel}}.",
"testDisabled": "El modulo de despedida esta desactivado.",
"testMissingChannel": "No hay canal configurado. Usa primero el boton de canal.",
"testChannelUnavailable": "El canal configurado no existe o no es compatible.",
"testMissingPermissions": "El bot no tiene permiso para enviar mensajes en ese canal.",
"testFailed": "No se pudo enviar el mensaje de prueba. Intentalo mas tarde."
},
"ui": {
"embed": {
"title": "Configuracion Goodbye",
"description": "Ajusta estado, canal, tipo de mensaje y ejecuta una prueba.",
"fields": {
"status": "Estado",
"channel": "Canal",
"type": "Tipo",
"channelPicker": "Selector de canal"
}
},
"status": {
"enabled": "🟢 Activado",
"disabled": "🔴 Desactivado"
},
"channelNotConfigured": "#sin-configurar",
"channelPickerPlaceholder": "Selecciona un canal de despedida",
"channelPickerHint": "Usa el menu de seleccion de abajo para elegir el canal destino.",
"buttons": {
"toggle": "Alternar estado",
"channel": "Configurar canal",
"channelCancel": "Cerrar selector",
"test": "Probar"
},
"type": {
"placeholder": "Elige un tipo de mensaje",
"options": {
"simple": {
"label": "Mensaje simple",
"description": "Enviar un mensaje de texto clasico"
},
"embed": {
"label": "Embed",
"description": "Enviar un embed de despedida"
},
"container": {
"label": "Container (V2)",
"description": "Enviar un container Components V2"
},
"image": {
"label": "Imagen",
"description": "Enviar un embed con imagen"
}
}
}
},
"templates": {
"simple": "👋 {{user}} salio de **{{guild}}**.",
"embedTitle": "Despedida",
"embedDescription": "{{user}} salio de **{{guild}}**.",
"containerTitle": "Despedida",
"containerDescription": "{{user}} salio de **{{guild}}**.",
"imageTitle": "Hasta luego",
"imageDescription": "del servidor de Discord {{guild}}"
}
},
"help": {
"name": "ayuda",
"description": "Mostrar lista de comandos o detalles de un comando.",
"args": {
"command": "Nombre o trigger opcional del comando"
},
"examples": {
"basic": "Listar todos los comandos",
"single": "Mostrar detalles de un comando"
},
"errors": {
"notFound": "Ningun comando coincide con {{query}}."
},
"embed": {
"title": "Ayuda de Comandos",
"description": "Usa {{usage}} para inspeccionar un comando.",
"categoryEmpty": "No hay comandos en esta categoria.",
"detailsTitle": "Comando: {{name}}",
"detailsDescription": "{{description}}",
"footer": "Categoria: {{source}}",
"fields": {
"usage": "Uso",
"arguments": "Argumentos",
"examples": "Ejemplos"
}
},
"labels": {
"prefix": "Prefijo",
"slash": "Slash",
"required": "requerido",
"optional": "opcional",
"noArgs": "Sin argumentos.",
"noExamples": "Sin ejemplos."
}
}
}
}
+376
View File
@@ -0,0 +1,376 @@
{
"errors": {
"args": {
"missing": "Argument manquant {{arg}}. Usage: {{usage}}",
"invalidInt": "L argument doit etre un entier. Recu: {{value}}",
"invalidNumber": "L argument doit etre un nombre. Recu: {{value}}",
"invalidBoolean": "L argument doit etre un booleen. Recu: {{value}}",
"invalidUser": "Impossible de trouver un utilisateur depuis {{value}}",
"invalidChannel": "Impossible de trouver un salon depuis {{value}}",
"invalidRole": "Impossible de trouver un role depuis {{value}}",
"tooMany": "Trop d arguments ({{count}}) detectes: {{extras}}. Usage: {{usage}}"
},
"permissions": {
"user": "Tu n as pas les permissions: {{permissions}}"
},
"execution": "Une erreur inattendue est survenue pendant l execution.",
"cooldown": "Patiente {{seconds}}s avant de reutiliser cette commande.",
"rateLimit": "Trop de requetes. Patiente {{seconds}}s avant de reessayer.",
"executionQueued": "Commande acceptee et mise en file de traitement."
},
"categories": {
"fun": "Fun",
"utility": "Utilitaire",
"core": "Base"
},
"commands": {
"kiss": {
"name": "bisou",
"description": "Envoyer un bisou a un utilisateur.",
"args": {
"user": "Utilisateur cible"
},
"examples": {
"basic": "Envoyer un bisou a un membre"
},
"responses": {
"success": "{{from}} envoie un bisou a {{to}}."
}
},
"ping": {
"name": "ping",
"description": "Verifier la latence websocket du bot.",
"examples": {
"basic": "Verification de sante"
},
"responses": {
"pong": "Pong. Latence websocket: {{latency}}ms."
}
},
"presence": {
"name": "presence",
"description": "Configurer la presence du bot avec un panneau interactif.",
"examples": {
"slash": "Ouvrir le panneau interactif de presence"
},
"responses": {
"panel": "Panneau presence\nStatut: {{status}}\nActivite: {{activityType}}\nRotation: toutes les {{rotationIntervalSeconds}}s\nTexte actif ({{currentTextIndex}}/{{textCount}}): {{activityText}}\nApercu rendu: {{activityPreview}}\nTextes: {{activityTexts}}\nVariables ({{doubleBracesHint}}): {{variables}}",
"notOwner": "Seul l auteur de la commande peut utiliser ce panneau.",
"invalidSelection": "Selection invalide.",
"invalidInterval": "Intervalle invalide. Utilise un entier entre {{minSeconds}} et {{maxSeconds}} secondes.",
"modalTimeout": "Temps depasse pour la modification du texte. Clique a nouveau sur le bouton."
},
"ui": {
"status": {
"placeholder": "Choisir un statut",
"options": {
"online": {
"label": "En ligne",
"description": "Affiche le bot en ligne"
},
"idle": {
"label": "Inactif",
"description": "Affiche le bot inactif"
},
"dnd": {
"label": "Ne pas deranger",
"description": "Affiche le bot en mode ne pas deranger"
},
"invisible": {
"label": "Hors ligne",
"description": "Affiche le bot hors ligne"
},
"streaming": {
"label": "Streaming",
"description": "Affiche le bot en mode streaming"
}
}
},
"activity": {
"placeholder": "Choisir un type d activite",
"options": {
"PLAYING": {
"label": "Joue a",
"description": "Affiche Joue a ..."
},
"STREAMING": {
"label": "Streaming",
"description": "Affiche En direct sur ..."
},
"WATCHING": {
"label": "Regarde",
"description": "Affiche Regarde ..."
},
"LISTENING": {
"label": "Ecoute",
"description": "Affiche Ecoute ..."
},
"COMPETING": {
"label": "Competition",
"description": "Affiche Competition ..."
},
"CUSTOM": {
"label": "CustomStatus",
"description": "Affiche un statut personnalise"
}
}
},
"embed": {
"title": "Configuration de la presence",
"description": "Modifie le statut, le type d activite, plusieurs textes et leur rotation.",
"footer": "Panneau interactif /presence",
"fields": {
"status": "Statut",
"activity": "Type d activite",
"text": "Texte"
}
},
"textButton": "📝 Modifier les textes",
"intervalButton": "⏱️ Intervalle",
"modal": {
"title": "Textes de presence",
"label": "Un texte par ligne (utilise {{var}})",
"placeholder": "Maintenance\n{{guild_count}} serveurs | {{prefix}}aide"
},
"intervalModal": {
"title": "Intervalle de rotation",
"label": "Secondes entre chaque texte",
"placeholder": "Exemple: 30"
}
}
},
"logs": {
"name": "logs",
"description": "Configurer les logs d events Discord avec un panneau interactif.",
"examples": {
"slash": "Ouvrir le panneau interactif des logs"
},
"responses": {
"slashOnly": "Utilise /logs pour ouvrir ce panneau interactif.",
"guildOnly": "Cette commande doit etre utilisee dans un serveur.",
"notOwner": "Seul l auteur de la commande peut utiliser ce panneau.",
"invalidSelection": "Selection invalide.",
"allEnabled": "Tous les logs d events sont actives.",
"allDisabled": "Tous les logs d events sont desactives.",
"eventEnabled": "Event {{event}} active.",
"eventDisabled": "Event {{event}} desactive.",
"channelsCreated": "Salons de logs prets. Crees: {{created}}, reutilises: {{reused}}.",
"channelsPartial": "Salons de logs partiellement prets. Crees: {{created}}, reutilises: {{reused}}, categories en echec: {{failed}}.",
"channelsFailed": "Impossible de creer les salons de logs. Categories en echec: {{failed}}."
},
"ui": {
"embed": {
"title": "Configuration des logs",
"description": "Active ou desactive chaque event Discord et route les logs par salons de categorie."
},
"status": {
"enabled": "Active",
"disabled": "Desactive"
},
"pageLabel": "Page {{page}}/{{pageCount}}",
"enabledCountLabel": "Events actives: {{enabledCount}}/{{totalCount}}",
"eventsHeader": "Events",
"channelLabel": "Salon",
"channelNotConfigured": "#non-configure",
"feedbackLabel": "Derniere action",
"buttons": {
"enableAll": "✅ Activer tous les logs",
"disableAll": "❌ Desactiver tous les logs",
"createChannels": "📁 Creer les salons de logs",
"previousPage": "Precedent",
"nextPage": "Suivant",
"enableEvent": "Activer {{event}}",
"disableEvent": "Desactiver {{event}}"
}
}
},
"welcome": {
"name": "bienvenue",
"description": "Configurer les messages de bienvenue avec un panneau interactif.",
"examples": {
"slash": "Ouvrir le panneau de configuration des messages de bienvenue"
},
"responses": {
"guildOnly": "Cette commande doit etre utilisee dans un serveur.",
"notOwner": "Seul l auteur de la commande peut utiliser ce panneau.",
"invalidSelection": "Selection invalide.",
"testSuccess": "Message de test envoye dans {{channel}}.",
"testDisabled": "Le module de bienvenue est desactive.",
"testMissingChannel": "Aucun salon configure. Utilise le bouton Salon.",
"testChannelUnavailable": "Le salon configure est introuvable ou non compatible.",
"testMissingPermissions": "Le bot n a pas la permission d envoyer des messages dans ce salon.",
"testFailed": "Impossible d envoyer le message de test. Reessaie plus tard."
},
"ui": {
"embed": {
"title": "Configuration Welcome",
"description": "Regle le statut, le salon, le type de message et lance un test.",
"fields": {
"status": "Statut",
"channel": "Salon",
"type": "Type",
"channelPicker": "Selection du salon",
"autoRoles": "Roles auto"
}
},
"status": {
"enabled": "🟢 Active",
"disabled": "🔴 Desactive"
},
"channelNotConfigured": "#non-configure",
"autoRolesNotConfigured": "Aucun role auto configure",
"channelPickerPlaceholder": "Selectionne un salon de bienvenue",
"channelPickerHint": "Choisis le salon dans le select menu ci-dessous.",
"rolePickerPlaceholder": "Selectionne un ou plusieurs roles auto",
"rolePickerHint": "Choisis les roles dans le select menu ci-dessous pour les ajouter a la liste auto.",
"buttons": {
"toggle": "Toggle statut",
"channel": "Configurer salon",
"channelCancel": "Fermer select salon",
"roles": "Ajouter roles auto",
"rolesCancel": "Fermer select roles",
"rolesClear": "Vider roles auto",
"test": "Tester"
},
"type": {
"placeholder": "Choisir un type de message",
"options": {
"simple": {
"label": "Message simple",
"description": "Envoie un message texte classique"
},
"embed": {
"label": "Embed",
"description": "Envoie un embed de bienvenue"
},
"container": {
"label": "Container (V2)",
"description": "Envoie un container Components V2"
},
"image": {
"label": "Image",
"description": "Envoie un embed avec image"
}
}
}
},
"templates": {
"simple": "🎉 Bienvenue {{user}} sur **{{guild}}** !",
"embedTitle": "Bienvenue !",
"embedDescription": "{{user}} vient de rejoindre **{{guild}}**.",
"containerTitle": "Bienvenue",
"containerDescription": "{{user}} vient de rejoindre **{{guild}}**.",
"imageTitle": "Bienvenue",
"imageDescription": "sur le serveur Discord {{guild}}"
}
},
"goodbye": {
"name": "aurevoir",
"description": "Configurer les messages d au revoir avec un panneau interactif.",
"examples": {
"slash": "Ouvrir le panneau de configuration des messages d au revoir"
},
"responses": {
"guildOnly": "Cette commande doit etre utilisee dans un serveur.",
"notOwner": "Seul l auteur de la commande peut utiliser ce panneau.",
"invalidSelection": "Selection invalide.",
"testSuccess": "Message de test envoye dans {{channel}}.",
"testDisabled": "Le module d au revoir est desactive.",
"testMissingChannel": "Aucun salon configure. Utilise le bouton Salon.",
"testChannelUnavailable": "Le salon configure est introuvable ou non compatible.",
"testMissingPermissions": "Le bot n a pas la permission d envoyer des messages dans ce salon.",
"testFailed": "Impossible d envoyer le message de test. Reessaie plus tard."
},
"ui": {
"embed": {
"title": "Configuration Goodbye",
"description": "Regle le statut, le salon, le type de message et lance un test.",
"fields": {
"status": "Statut",
"channel": "Salon",
"type": "Type",
"channelPicker": "Selection du salon"
}
},
"status": {
"enabled": "🟢 Active",
"disabled": "🔴 Desactive"
},
"channelNotConfigured": "#non-configure",
"channelPickerPlaceholder": "Selectionne un salon d au revoir",
"channelPickerHint": "Choisis le salon dans le select menu ci-dessous.",
"buttons": {
"toggle": "Toggle statut",
"channel": "Configurer salon",
"channelCancel": "Fermer select salon",
"test": "Tester"
},
"type": {
"placeholder": "Choisir un type de message",
"options": {
"simple": {
"label": "Message simple",
"description": "Envoie un message texte classique"
},
"embed": {
"label": "Embed",
"description": "Envoie un embed d au revoir"
},
"container": {
"label": "Container (V2)",
"description": "Envoie un container Components V2"
},
"image": {
"label": "Image",
"description": "Envoie un embed avec image"
}
}
}
},
"templates": {
"simple": "👋 {{user}} a quitte **{{guild}}**.",
"embedTitle": "Au revoir",
"embedDescription": "{{user}} a quitte **{{guild}}**.",
"containerTitle": "Au revoir",
"containerDescription": "{{user}} a quitte **{{guild}}**.",
"imageTitle": "Au revoir",
"imageDescription": "du serveur Discord {{guild}}"
}
},
"help": {
"name": "aide",
"description": "Afficher la liste des commandes ou les details d une commande.",
"args": {
"command": "Nom ou trigger de commande optionnel"
},
"examples": {
"basic": "Afficher toutes les commandes",
"single": "Afficher les details d une commande"
},
"errors": {
"notFound": "Aucune commande ne correspond a {{query}}."
},
"embed": {
"title": "Aide Commandes",
"description": "Utilise {{usage}} pour inspecter une commande.",
"categoryEmpty": "Aucune commande dans cette categorie.",
"detailsTitle": "Commande: {{name}}",
"detailsDescription": "{{description}}",
"footer": "Categorie: {{source}}",
"fields": {
"usage": "Usage",
"arguments": "Arguments",
"examples": "Exemples"
}
},
"labels": {
"prefix": "Prefix",
"slash": "Slash",
"required": "requis",
"optional": "optionnel",
"noArgs": "Aucun argument.",
"noExamples": "Aucun exemple."
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { SUPPORTED_LANGS, type SupportedLang, type TranslationVars } from "../types/command.js";
import type { JsonObject } from "../types/i18n.js";
const DISCORD_LOCALE_MAP: Record<string, SupportedLang> = {
en: "en",
"en-us": "en",
"en-gb": "en",
fr: "fr",
"fr-fr": "fr",
es: "es",
"es-es": "es",
"es-419": "es",
};
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const LOCALE_DIR_CANDIDATES = [
CURRENT_DIR,
path.resolve(process.cwd(), "src", "i18n"),
path.resolve(process.cwd(), "build", "i18n"),
];
const resolveLocaleFilePath = (lang: SupportedLang): string => {
for (const directory of LOCALE_DIR_CANDIDATES) {
const filePath = path.join(directory, `${lang}.json`);
if (existsSync(filePath)) {
return filePath;
}
}
throw new Error(`[i18n] missing locale file for "${lang}"`);
};
export class I18nService {
private readonly dictionaries: Record<SupportedLang, JsonObject>;
public constructor(private readonly defaultLang: SupportedLang) {
this.dictionaries = this.loadDictionaries();
}
public resolveLang(input?: string | null): SupportedLang {
if (!input) {
return this.defaultLang;
}
const normalized = input.toLowerCase();
const direct = DISCORD_LOCALE_MAP[normalized];
if (direct) {
return direct;
}
const short = normalized.split("-")[0];
const fromShort = short ? DISCORD_LOCALE_MAP[short] : undefined;
if (fromShort) {
return fromShort;
}
return this.defaultLang;
}
public t(lang: SupportedLang, key: string, vars: TranslationVars = {}): string {
const fromLang = this.lookup(this.dictionaries[lang], key);
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
const template = typeof fromLang === "string" ? fromLang : typeof fromDefault === "string" ? fromDefault : key;
return this.format(template, vars);
}
public commandT(lang: SupportedLang, commandName: string, relativeKey: string, vars: TranslationVars = {}): string {
return this.t(lang, `${this.commandBaseKey(commandName)}.${relativeKey}`, vars);
}
public commandName(lang: SupportedLang, commandName: string): string {
const key = `${this.commandBaseKey(commandName)}.name`;
const fromLang = this.lookup(this.dictionaries[lang], key);
if (typeof fromLang === "string" && fromLang.length > 0) {
return fromLang;
}
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
if (typeof fromDefault === "string" && fromDefault.length > 0) {
return fromDefault;
}
return commandName;
}
public commandTrigger(lang: SupportedLang, commandName: string): string {
return this.commandName(lang, commandName).trim().toLowerCase();
}
public commandObject(lang: SupportedLang, commandName: string): Record<string, unknown> {
const key = this.commandBaseKey(commandName);
const fromLang = this.lookup(this.dictionaries[lang], key);
if (this.isObject(fromLang)) {
return fromLang;
}
const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key);
if (this.isObject(fromDefault)) {
return fromDefault;
}
return {};
}
public format(template: string, vars: TranslationVars = {}): string {
return this.interpolate(template, vars);
}
private loadDictionaries(): Record<SupportedLang, JsonObject> {
return SUPPORTED_LANGS.reduce<Record<SupportedLang, JsonObject>>((acc, lang) => {
const filePath = resolveLocaleFilePath(lang);
const raw = readFileSync(filePath, "utf-8");
acc[lang] = JSON.parse(raw) as JsonObject;
return acc;
}, {} as Record<SupportedLang, JsonObject>);
}
private lookup(source: JsonObject, key: string): unknown {
const parts = key.split(".");
let current: unknown = source;
for (const part of parts) {
if (!current || typeof current !== "object" || Array.isArray(current)) {
return undefined;
}
current = (current as JsonObject)[part];
}
return current;
}
private commandBaseKey(commandName: string): string {
return `commands.${commandName}`;
}
private isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
private interpolate(template: string, vars: TranslationVars): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, variable: string) => {
const value = vars[variable];
return value === undefined || value === null ? "" : String(value);
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { bootstrap } from "./app/bootstrap.js";
import { createScopedLogger } from "./core/logging/logger.js";
const log = createScopedLogger("boot");
bootstrap().catch((error) => {
log.fatal({ err: error }, "fatal startup error");
process.exit(1);
});
@@ -0,0 +1,46 @@
import { defineCommand } from "../../core/commands/defineCommand.js";
import {
createCommandDetailsEmbed,
createGlobalHelpEmbed,
resolveCommandFromQuery,
} from "./service.js";
export const helpCommand = defineCommand({
meta: {
name: "help",
category: "core",
},
args: [
{
name: "command",
type: "string",
required: false,
descriptionKey: "args.command",
},
],
examples: [
{
descriptionKey: "examples.basic",
},
{
args: "<command>",
descriptionKey: "examples.single",
},
],
execute: async (ctx) => {
const queryArg = ctx.args.command;
if (typeof queryArg === "string" && queryArg.trim().length > 0) {
const command = resolveCommandFromQuery(ctx, queryArg.trim());
if (!command) {
await ctx.reply(ctx.ct("errors.notFound", { query: queryArg }));
return;
}
await ctx.reply({ embeds: [createCommandDetailsEmbed(ctx, command)] });
return;
}
await ctx.reply({ embeds: [createGlobalHelpEmbed(ctx, helpCommand)] });
},
});
+127
View File
@@ -0,0 +1,127 @@
import { EmbedBuilder } from "discord.js";
import {
buildPrefixUsage,
buildSlashUsage,
resolvePrefixTrigger,
resolveSlashName,
} from "../../core/commands/usage.js";
import type { BotCommand, CommandExecutionContext } from "../../types/command.js";
const categoryName = (command: BotCommand): string => command.meta.category;
const commandDescription = (ctx: CommandExecutionContext, command: BotCommand): string =>
ctx.i18n.commandT(ctx.lang, command.meta.name, "description");
export const resolveCommandFromQuery = (
ctx: CommandExecutionContext,
query: string,
): BotCommand | undefined => {
const normalized = query.toLowerCase();
const byName = ctx.registry.findByName(normalized);
if (byName) {
return byName;
}
return ctx.registry.findByAnyPrefixTrigger(normalized)?.command;
};
export const createGlobalHelpEmbed = (
ctx: CommandExecutionContext,
helpCommand: BotCommand,
): EmbedBuilder => {
const commands = [...ctx.registry.getAll()];
const grouped = new Map<string, BotCommand[]>();
for (const command of commands) {
const key = categoryName(command);
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key)?.push(command);
}
const helpUsage = buildPrefixUsage(helpCommand, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n);
const embed = new EmbedBuilder()
.setColor(0x2b6cb0)
.setTitle(ctx.ct("embed.title"))
.setDescription(ctx.ct("embed.description", { prefix: ctx.prefix, usage: `${helpUsage} <command>` }));
for (const [category, categoryCommands] of grouped.entries()) {
const lines = categoryCommands
.map((command) => {
const slashLabel = `/${resolveSlashName(command, ctx.lang, ctx.i18n)}`;
const prefixLabel = `${ctx.prefix}${resolvePrefixTrigger(command, ctx.lang, ctx.defaultLang, ctx.i18n)}`;
return `${slashLabel} | ${prefixLabel} - ${commandDescription(ctx, command)}`;
})
.join("\n");
embed.addFields({
name: ctx.t(`categories.${category}`),
value: lines.length > 0 ? lines : ctx.ct("embed.categoryEmpty"),
});
}
return embed;
};
export const createCommandDetailsEmbed = (
ctx: CommandExecutionContext,
command: BotCommand,
): EmbedBuilder => {
const usagePrefix = buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n);
const usageSlash = buildSlashUsage(command, ctx.lang, ctx.i18n);
const prefixTrigger = resolvePrefixTrigger(command, ctx.lang, ctx.defaultLang, ctx.i18n);
const slashTrigger = resolveSlashName(command, ctx.lang, ctx.i18n);
const localizedCommandName = ctx.i18n.commandName(ctx.lang, command.meta.name);
const args = command.args.length === 0
? ctx.ct("labels.noArgs")
: command.args
.map((arg) => {
const description = ctx.i18n.commandT(ctx.lang, command.meta.name, arg.descriptionKey);
const requirement = arg.required ? ctx.ct("labels.required") : ctx.ct("labels.optional");
return `- ${arg.name} (${arg.type}, ${requirement}): ${description}`;
})
.join("\n");
const examples = command.examples.length === 0
? ctx.ct("labels.noExamples")
: command.examples
.map((example) => {
const source = example.source ?? "prefix";
const baseUsage = source === "slash"
? buildSlashUsage(command, ctx.lang, ctx.i18n)
: buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n);
const baseCommand = source === "slash"
? `/${slashTrigger}`
: `${ctx.prefix}${prefixTrigger}`;
const input = example.args ? `${baseCommand} ${example.args}` : baseUsage;
const description = ctx.i18n.commandT(ctx.lang, command.meta.name, example.descriptionKey);
return `- ${input}: ${description}`;
})
.join("\n");
return new EmbedBuilder()
.setColor(0x2f855a)
.setTitle(ctx.ct("embed.detailsTitle", { name: localizedCommandName }))
.setDescription(ctx.ct("embed.detailsDescription", { description: commandDescription(ctx, command) }))
.addFields(
{
name: ctx.ct("embed.fields.usage"),
value: `${ctx.ct("labels.prefix")}: ${usagePrefix}\n${ctx.ct("labels.slash")}: ${usageSlash}`,
},
{
name: ctx.ct("embed.fields.arguments"),
value: args,
},
{
name: ctx.ct("embed.fields.examples"),
value: examples,
},
)
.setFooter({ text: ctx.ct("embed.footer", { source: command.meta.category }) });
};
@@ -0,0 +1,3 @@
export { createLogsCommandExecute } from "../../features/logs/commandPanel.js";
export type { LogEventRepository } from "../../features/logs/repository.js";
export { LogEventService } from "../../features/logs/service.js";
@@ -0,0 +1,3 @@
export { createMemberMessagePanelExecute } from "../../features/memberMessages/commandPanel.js";
export type { MemberMessageRepository } from "../../features/memberMessages/repository.js";
export { MemberMessageService } from "../../features/memberMessages/service.js";
@@ -0,0 +1,3 @@
export { createPresenceCommandExecute } from "../../features/presence/commandPanel.js";
export type { PresenceRepository } from "../../features/presence/repository.js";
export { PresenceService } from "../../features/presence/service.js";
+11
View File
@@ -0,0 +1,11 @@
import type { CommandArgValue, TranslationVars } from "./command.js";
export interface ArgumentParseError {
key: string;
vars?: TranslationVars;
}
export interface ParsedArgumentsResult {
values: Record<string, CommandArgValue>;
errors: ArgumentParseError[];
}
+157
View File
@@ -0,0 +1,157 @@
import type {
ChatInputCommandInteraction,
Client,
Guild,
GuildBasedChannel,
GuildMember,
InteractionReplyOptions,
Message,
MessageCreateOptions,
MessageReplyOptions,
PermissionsBitField,
PermissionResolvable,
Role,
TextBasedChannel,
User,
} from "discord.js";
export const SUPPORTED_LANGS = ["en", "fr", "es"] as const;
export type SupportedLang = (typeof SUPPORTED_LANGS)[number];
export type CommandSource = "prefix" | "slash";
export type CommandArgType = "string" | "user" | "int" | "boolean" | "number" | "channel" | "role";
export type TranslationVars = Record<string, string | number | boolean | null | undefined>;
export type ReplyPayload = string | MessageCreateOptions | MessageReplyOptions | InteractionReplyOptions;
export type CommandArgValue =
| string
| number
| boolean
| User
| GuildMember
| Role
| GuildBasedChannel
| null
| undefined;
export interface CommandMeta {
name: string;
category: string;
}
export interface CommandArgument {
name: string;
type: CommandArgType;
required: boolean;
descriptionKey: string;
}
export interface CommandExample {
source?: CommandSource;
args?: string;
descriptionKey: string;
}
export interface PrefixTriggerMatch {
command: BotCommand;
lang: SupportedLang;
trigger: string;
}
export interface CommandRegistryReader {
getAll(): readonly BotCommand[];
findByName(name: string): BotCommand | undefined;
findByAnyPrefixTrigger(trigger: string): PrefixTriggerMatch | undefined;
}
export interface CommandI18nTools {
commandName: (lang: SupportedLang, commandName: string) => string;
commandTrigger: (lang: SupportedLang, commandName: string) => string;
commandT: (lang: SupportedLang, commandName: string, relativeKey: string, vars?: TranslationVars) => string;
commandObject: (lang: SupportedLang, commandName: string) => Record<string, unknown>;
format: (template: string, vars?: TranslationVars) => string;
}
export interface ExecutionContext {
requestId: string;
receivedAt: number;
source: CommandSource;
commandName: string;
commandCategory: string;
args: Record<string, CommandArgValue>;
actor: {
userId: string;
guildId: string | null;
channelId: string | null;
};
}
export interface TransportContext {
kind: "discord";
client: Client;
user: User;
guild: Guild | null;
channel: TextBasedChannel | null;
raw: Message | ChatInputCommandInteraction;
reply: (payload: ReplyPayload) => Promise<unknown>;
resolveMemberPermissions: () => Promise<Readonly<PermissionsBitField> | null>;
}
export interface I18nContext {
lang: SupportedLang;
t: (key: string, vars?: TranslationVars) => string;
ct: (relativeKey: string, vars?: TranslationVars) => string;
commandText: Record<string, unknown>;
format: (template: string, vars?: TranslationVars) => string;
i18n: CommandI18nTools;
registry: CommandRegistryReader;
prefix: string;
defaultLang: SupportedLang;
}
export interface CommandExecutionContext {
execution: ExecutionContext;
transport: TransportContext;
i18nContext: I18nContext;
// Backward-compatible aliases kept for existing command implementations.
client: Client;
user: User;
guild: Guild | null;
channel: TextBasedChannel | null;
lang: SupportedLang;
args: Record<string, CommandArgValue>;
command: BotCommand;
source: CommandSource;
t: (key: string, vars?: TranslationVars) => string;
ct: (relativeKey: string, vars?: TranslationVars) => string;
commandText: Record<string, unknown>;
format: (template: string, vars?: TranslationVars) => string;
i18n: CommandI18nTools;
raw: Message | ChatInputCommandInteraction;
reply: (payload: ReplyPayload) => Promise<unknown>;
registry: CommandRegistryReader;
prefix: string;
defaultLang: SupportedLang;
}
export interface BotCommandInput {
meta: CommandMeta;
args?: CommandArgument[];
permissions?: PermissionResolvable[];
sensitive?: boolean;
examples?: CommandExample[];
cooldown?: number;
execute: (ctx: CommandExecutionContext) => Promise<void>;
}
export interface BotCommand {
meta: CommandMeta;
args: CommandArgument[];
permissions: PermissionResolvable[];
sensitive: boolean;
examples: CommandExample[];
cooldown?: number;
execute: (ctx: CommandExecutionContext) => Promise<void>;
}
+15
View File
@@ -0,0 +1,15 @@
import type { CommandRegistry } from "../core/commands/registry.js";
import type { I18nService } from "../i18n/index.js";
export interface DeployCommandsOptions {
token: string;
clientId: string;
guildId?: string;
registry: CommandRegistry;
i18n: I18nService;
}
export interface DeployCommandsResult {
scope: "guild" | "global";
count: number;
}
+38
View File
@@ -0,0 +1,38 @@
import type { CommandRegistry } from "../core/commands/registry.js";
import type { CommandDispatchPort } from "../core/execution/dispatch.js";
import type { I18nService } from "../i18n/index.js";
import type {
BotCommand,
CommandExecutionContext,
CommandSource,
SupportedLang,
} from "./command.js";
export interface HandlerExecutionDeps {
registry: CommandRegistry;
i18n: I18nService;
prefix: string;
defaultLang: SupportedLang;
}
export interface BuildExecutionContextInput {
requestId: string;
command: BotCommand;
source: CommandSource;
lang: SupportedLang;
args: CommandExecutionContext["args"];
client: CommandExecutionContext["client"];
user: CommandExecutionContext["user"];
guild: CommandExecutionContext["guild"];
channel: CommandExecutionContext["channel"];
raw: CommandExecutionContext["raw"];
reply: CommandExecutionContext["reply"];
}
export interface PrefixHandlerDeps extends HandlerExecutionDeps {
dispatcher: CommandDispatchPort;
}
export interface SlashHandlerDeps extends HandlerExecutionDeps {
dispatcher: CommandDispatchPort;
}
+1
View File
@@ -0,0 +1 @@
export type JsonObject = Record<string, unknown>;
+92
View File
@@ -0,0 +1,92 @@
import type { Message } from "discord.js";
export type LogEventCategoryKey =
| "message"
| "member"
| "interaction"
| "channel"
| "role"
| "thread"
| "emoji"
| "guild"
| "moderation"
| "invite";
export type LogEventKey =
| "messageCreate"
| "messageDelete"
| "messageUpdate"
| "messageBulkDelete"
| "guildMemberAdd"
| "guildMemberRemove"
| "guildMemberUpdate"
| "interactionCreate"
| "channelCreate"
| "channelDelete"
| "channelUpdate"
| "roleCreate"
| "roleDelete"
| "roleUpdate"
| "threadCreate"
| "threadDelete"
| "threadUpdate"
| "emojiCreate"
| "emojiDelete"
| "emojiUpdate"
| "guildUpdate"
| "guildUnavailable"
| "guildBanAdd"
| "guildBanRemove"
| "inviteCreate"
| "inviteDelete";
export interface LogEventDefinition {
key: LogEventKey;
category: LogEventCategoryKey;
}
export interface LogEventConfig {
enabled: boolean;
channelId: string | null;
}
export type LogEventStateByKey = Record<LogEventKey, LogEventConfig>;
export interface LogEventRow {
event_key: string;
enabled: boolean;
channel_id: string | null;
}
export interface LogEventRepositoryEntry {
eventKey: LogEventKey;
config: LogEventConfig;
}
export interface LogRuntimeDispatchInput {
eventKey: LogEventKey;
guildId: string;
summary: string;
details?: string[];
color?: number;
}
export interface LogPanelCustomIds {
enableAllButton: string;
disableAllButton: string;
createChannelsButton: string;
previousPageButton: string;
nextPageButton: string;
toggleButtonsByEvent: Record<LogEventKey, string>;
}
export interface LogPanelSession {
collector: ReturnType<Message["createMessageComponentCollector"]>;
disable: () => Promise<void>;
}
export interface LogChannelProvisionResult {
createdCount: number;
reusedCount: number;
failedCategories: LogEventCategoryKey[];
}
+116
View File
@@ -0,0 +1,116 @@
import type {
Client,
Guild,
GuildMember,
Message,
MessageCreateOptions,
User,
} from "discord.js";
import type { I18nService } from "../i18n/index.js";
export type MemberMessageKind = "welcome" | "goodbye";
export type MemberMessageRenderType = "simple" | "embed" | "container" | "image";
export interface MemberMessageConfig {
enabled: boolean;
channelId: string | null;
messageType: MemberMessageRenderType;
autoRoleIds: string[];
}
export type DispatchMemberMessageFailureReason =
| "bot_not_ready"
| "disabled"
| "missing_channel"
| "channel_not_found"
| "channel_not_sendable"
| "missing_permissions"
| "send_failed";
export interface DispatchMemberMessageResult {
sent: boolean;
reason: DispatchMemberMessageFailureReason | "sent";
channelId: string | null;
}
export interface DispatchMemberMessageInput {
client: Client;
i18n?: I18nService;
guild: Guild;
user: User;
kind: MemberMessageKind;
ignoreEnabled?: boolean;
}
export type AssignWelcomeAutoRolesFailureReason =
| "bot_not_ready"
| "missing_permissions"
| "member_not_manageable"
| "no_roles_configured"
| "no_assignable_roles"
| "assign_failed";
export interface AssignWelcomeAutoRolesInput {
client: Client;
member: GuildMember;
}
export interface AssignWelcomeAutoRolesResult {
assigned: boolean;
reason: AssignWelcomeAutoRolesFailureReason | "assigned";
configuredRoleIds: string[];
appliedRoleIds: string[];
skippedRoleIds: string[];
}
export interface SendableChannel {
send: (payload: string | MessageCreateOptions) => Promise<unknown>;
}
export type TemplateSuffix =
| "simple"
| "embedTitle"
| "embedDescription"
| "containerTitle"
| "containerDescription"
| "imageTitle"
| "imageDescription";
export interface MemberMessageImageInput {
kind: MemberMessageKind;
title: string;
subtitle: string;
username: string;
avatarUrl: string;
}
export interface MemberMessageRow {
enabled: boolean;
channel_id: string | null;
message_type: string;
auto_role_ids: string | null;
}
export interface MemberMessageCustomIds {
toggleButton: string;
channelButton: string;
channelCancelButton: string;
roleButton: string;
roleCancelButton: string;
roleClearButton: string;
typeSelect: string;
channelSelect: string;
roleSelect: string;
testButton: string;
}
export interface MemberMessagePanelUiState {
channelPickerOpen: boolean;
rolePickerOpen: boolean;
}
export interface MemberMessagePanelSession {
collector: ReturnType<Message["createMessageComponentCollector"]>;
disable: () => Promise<void>;
}
+49
View File
@@ -0,0 +1,49 @@
import type { Message } from "discord.js";
export type PresenceStatusValue = "online" | "idle" | "dnd" | "invisible" | "streaming";
export type PresenceActivityTypeValue = "PLAYING" | "STREAMING" | "WATCHING" | "LISTENING" | "COMPETING" | "CUSTOM";
export interface PresenceActivityState {
type: PresenceActivityTypeValue;
text: string;
texts: string[];
rotationIntervalSeconds: number;
}
export interface PresenceState {
status: PresenceStatusValue;
activity: PresenceActivityState;
}
export interface PresenceRow {
status: string;
activity_type: string;
activity_text: string;
activity_texts: string | null;
rotation_interval_seconds: number | null;
}
export interface PresenceCustomIds {
statusSelect: string;
activitySelect: string;
textButton: string;
intervalButton: string;
textModal: string;
textInput: string;
intervalModal: string;
intervalInput: string;
}
export interface PresencePanelSession {
collector: ReturnType<Message["createMessageComponentCollector"]>;
disable: () => Promise<void>;
}
export interface PresenceRuntimeState {
dynamicPresenceRefreshTimer: NodeJS.Timeout | null;
presenceRotationTimer: NodeJS.Timeout | null;
activePresenceTextIndex: number;
activePanelsByUserId: Map<string, PresencePanelSession>;
}
export type DiscordPresenceStatus = "online" | "idle" | "dnd" | "invisible";
+3
View File
@@ -0,0 +1,3 @@
import type { ReplyPayload } from "./command.js";
export type PrefixReplyObject = Exclude<ReplyPayload, string>;

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