mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
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:
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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 ?? {}),
|
||||
],
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface AuthSession {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
discordUserId: string;
|
||||
username: string;
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import type { AuthSession } from "./auth.js";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: AuthSession;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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 ?? {}),
|
||||
],
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { helpCommand } from "../modules/help/command.js";
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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}>`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)] });
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
Reference in New Issue
Block a user