From 30378f2c910100b1659eb8721e14a41cf449d063 Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Thu, 16 Apr 2026 23:09:28 +0200 Subject: [PATCH] add logs command --- .../migrations/0002_log_event_configs.sql | 41 ++ src/app/bootstrap.ts | 11 + src/app/container.ts | 4 + src/commands/index.ts | 2 + src/commands/logs.ts | 23 + src/database/stores/logEventStore.ts | 129 +++++ src/events/guildDelete.ts | 14 +- src/events/index.ts | 4 +- src/events/logsRuntime.ts | 465 ++++++++++++++++++ src/features/logs/catalog.ts | 54 ++ src/features/logs/commandPanel.ts | 372 ++++++++++++++ src/features/logs/repository.ts | 13 + src/features/logs/service.ts | 331 +++++++++++++ src/i18n/en.json | 45 ++ src/i18n/es.json | 45 ++ src/i18n/fr.json | 45 ++ src/modules/logs/index.ts | 3 + src/types/logs.ts | 92 ++++ src/validators/logs.ts | 60 +++ tests/logsTypes.test.ts | 44 ++ 20 files changed, 1793 insertions(+), 4 deletions(-) create mode 100644 database/migrations/0002_log_event_configs.sql create mode 100644 src/commands/logs.ts create mode 100644 src/database/stores/logEventStore.ts create mode 100644 src/events/logsRuntime.ts create mode 100644 src/features/logs/catalog.ts create mode 100644 src/features/logs/commandPanel.ts create mode 100644 src/features/logs/repository.ts create mode 100644 src/features/logs/service.ts create mode 100644 src/modules/logs/index.ts create mode 100644 src/types/logs.ts create mode 100644 src/validators/logs.ts create mode 100644 tests/logsTypes.test.ts diff --git a/database/migrations/0002_log_event_configs.sql b/database/migrations/0002_log_event_configs.sql new file mode 100644 index 0000000..8db2a3b --- /dev/null +++ b/database/migrations/0002_log_event_configs.sql @@ -0,0 +1,41 @@ +CREATE TABLE IF NOT EXISTS bot_log_event_configs ( + bot_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + event_key TEXT NOT NULL CHECK ( + event_key IN ( + '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' + ) + ), + enabled BOOLEAN NOT NULL DEFAULT FALSE, + channel_id TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id, event_key) +); + +CREATE INDEX IF NOT EXISTS idx_bot_log_event_configs_bot_guild + ON bot_log_event_configs (bot_id, guild_id); \ No newline at end of file diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index 8015a32..5a60896 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -25,12 +25,16 @@ import { 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"; @@ -111,11 +115,13 @@ export const bootstrap = async (): Promise => { 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(); @@ -125,6 +131,7 @@ export const bootstrap = async (): Promise => { 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 @@ -179,6 +186,10 @@ export const bootstrap = async (): Promise => { 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"); }); diff --git a/src/app/container.ts b/src/app/container.ts index 1b39fd5..55e0221 100644 --- a/src/app/container.ts +++ b/src/app/container.ts @@ -1,6 +1,9 @@ import type { MemberMessageService, } from "../modules/memberMessages/index.js"; +import type { + LogEventService, +} from "../modules/logs/index.js"; import type { PresenceService, } from "../modules/presence/index.js"; @@ -8,4 +11,5 @@ import type { export interface AppFeatureServices { presenceService: PresenceService; memberMessageService: MemberMessageService; + logEventService: LogEventService; } diff --git a/src/commands/index.ts b/src/commands/index.ts index 12aaeb6..6d2146b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -7,6 +7,7 @@ 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"; @@ -22,5 +23,6 @@ export const createCommandList = (services: AppFeatureServices, i18n: I18nServic createWelcomeCommand(services.memberMessageService, i18n), createGoodbyeCommand(services.memberMessageService, i18n), createPresenceCommand(services.presenceService), + createLogsCommand(services.logEventService), helpCommand, ]; diff --git a/src/commands/logs.ts b/src/commands/logs.ts new file mode 100644 index 0000000..b51d13a --- /dev/null +++ b/src/commands/logs.ts @@ -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), +}); \ No newline at end of file diff --git a/src/database/stores/logEventStore.ts b/src/database/stores/logEventStore.ts new file mode 100644 index 0000000..ba800f9 --- /dev/null +++ b/src/database/stores/logEventStore.ts @@ -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 { + 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 { + const result = await this.pool.query( + ` + 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 { + 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 { + 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 { + await this.pool.query( + "DELETE FROM bot_log_event_configs WHERE bot_id = $1 AND guild_id = $2", + [botId, guildId], + ); + } +} \ No newline at end of file diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 4c1293c..d3c8351 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -1,20 +1,28 @@ 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): void => { +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 = memberMessageService.resolveBotId(client); + const botId = logEventService.resolveBotId(client); if (!botId) { return; } - void memberMessageService.cleanupGuild(botId, guild.id).catch((error) => { + 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"); }); }); diff --git a/src/events/index.ts b/src/events/index.ts index 6126d19..f13de69 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -9,6 +9,7 @@ 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 { registerClientReady } from "./ready.js"; @@ -25,12 +26,13 @@ export const registerEvents = ( ): 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); + registerGuildDelete(client, services.memberMessageService, services.logEventService); registerClientReady(client, registry, i18n, services.presenceService, leaderCoordinator); }; diff --git a/src/events/logsRuntime.ts b/src/events/logsRuntime.ts new file mode 100644 index 0000000..105829c --- /dev/null +++ b/src/events/logsRuntime.ts @@ -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, + ); + }); +}; \ No newline at end of file diff --git a/src/features/logs/catalog.ts b/src/features/logs/catalog.ts new file mode 100644 index 0000000..60636a4 --- /dev/null +++ b/src/features/logs/catalog.ts @@ -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 = { + 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; \ No newline at end of file diff --git a/src/features/logs/commandPanel.ts b/src/features/logs/commandPanel.ts new file mode 100644 index 0000000..3eb8437 --- /dev/null +++ b/src/features/logs/commandPanel.ts @@ -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 = (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; + + 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().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().addComponents(...rowButtons)); + } + + container.addActionRowComponents( + new ActionRowBuilder().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 => { + return new Map( + LOG_EVENT_KEYS.map((eventKey) => [customIds.toggleButtonsByEvent[eventKey], eventKey]), + ); +}; + +export const createLogsCommandExecute = (logEventService: LogEventService) => { + return async (ctx: CommandExecutionContext): Promise => { + + 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 => { + 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(); + }); + }; +}; \ No newline at end of file diff --git a/src/features/logs/repository.ts b/src/features/logs/repository.ts new file mode 100644 index 0000000..b72bb82 --- /dev/null +++ b/src/features/logs/repository.ts @@ -0,0 +1,13 @@ +import type { + LogEventConfig, + LogEventKey, + LogEventRepositoryEntry, + LogEventRow, +} from "../../types/logs.js"; + +export interface LogEventRepository { + listByBotGuild(botId: string, guildId: string): Promise; + upsertByBotGuildEvent(botId: string, guildId: string, eventKey: LogEventKey, config: LogEventConfig): Promise; + upsertManyByBotGuildEvents(botId: string, guildId: string, entries: readonly LogEventRepositoryEntry[]): Promise; + deleteByBotGuild(botId: string, guildId: string): Promise; +} \ No newline at end of file diff --git a/src/features/logs/service.ts b/src/features/logs/service.ts new file mode 100644 index 0000000..4db081d --- /dev/null +++ b/src/features/logs/service.ts @@ -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 } => { + 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(); + private readonly panelSessions = new ComponentSessionRegistry(); + + 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 { + 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 { + 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 { + 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 { + const categoryToChannelId = new Map(); + 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 { + 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 { + await this.repository.deleteByBotGuild(botId, guildId); + this.stateCache.delete(this.cacheKey(botId, guildId)); + } + + public async shutdown(): Promise { + this.stateCache.clear(); + await this.panelSessions.stopAll("shutdown"); + } + + private cacheKey(botId: string, guildId: string): string { + return `${botId}:${guildId}`; + } +} \ No newline at end of file diff --git a/src/i18n/en.json b/src/i18n/en.json index 2a2d7da..891e78d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -139,6 +139,51 @@ } } }, + "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.", diff --git a/src/i18n/es.json b/src/i18n/es.json index 645fee2..b0f0975 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -139,6 +139,51 @@ } } }, + "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.", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index dfa1586..386362e 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -139,6 +139,51 @@ } } }, + "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.", diff --git a/src/modules/logs/index.ts b/src/modules/logs/index.ts new file mode 100644 index 0000000..3c7de07 --- /dev/null +++ b/src/modules/logs/index.ts @@ -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"; \ No newline at end of file diff --git a/src/types/logs.ts b/src/types/logs.ts new file mode 100644 index 0000000..75b91ac --- /dev/null +++ b/src/types/logs.ts @@ -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; + +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; +} + +export interface LogPanelSession { + collector: ReturnType; + disable: () => Promise; +} + +export interface LogChannelProvisionResult { + createdCount: number; + reusedCount: number; + failedCategories: LogEventCategoryKey[]; +} \ No newline at end of file diff --git a/src/validators/logs.ts b/src/validators/logs.ts new file mode 100644 index 0000000..4c718a8 --- /dev/null +++ b/src/validators/logs.ts @@ -0,0 +1,60 @@ +import { + LOG_EVENT_KEYS, + LOG_EVENT_KEYS_SET, +} from "../features/logs/catalog.js"; +import type { + LogEventConfig, + LogEventKey, + LogEventRow, + LogEventStateByKey, +} from "../types/logs.js"; + +export const createDefaultLogEventConfig = (): LogEventConfig => ({ + enabled: false, + channelId: null, +}); + +export const createDefaultLogEventState = (): LogEventStateByKey => { + const state = {} as LogEventStateByKey; + + for (const eventKey of LOG_EVENT_KEYS) { + state[eventKey] = createDefaultLogEventConfig(); + } + + return state; +}; + +export const cloneLogEventState = (state: LogEventStateByKey): LogEventStateByKey => { + const next = {} as LogEventStateByKey; + + for (const eventKey of LOG_EVENT_KEYS) { + const current = state[eventKey]; + next[eventKey] = { + enabled: current.enabled, + channelId: current.channelId, + }; + } + + return next; +}; + +export const isLogEventKey = (value: string): value is LogEventKey => { + return LOG_EVENT_KEYS_SET.has(value as LogEventKey); +}; + +export const mergeLogEventRowsIntoState = (rows: readonly LogEventRow[]): LogEventStateByKey => { + const state = createDefaultLogEventState(); + + for (const row of rows) { + if (!isLogEventKey(row.event_key)) { + continue; + } + + state[row.event_key] = { + enabled: row.enabled, + channelId: row.channel_id, + }; + } + + return state; +}; \ No newline at end of file diff --git a/tests/logsTypes.test.ts b/tests/logsTypes.test.ts new file mode 100644 index 0000000..ea7dd97 --- /dev/null +++ b/tests/logsTypes.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { LOG_EVENT_KEYS } from "../src/features/logs/catalog.js"; +import { + createDefaultLogEventState, + isLogEventKey, + mergeLogEventRowsIntoState, +} from "../src/validators/logs.js"; + +test("createDefaultLogEventState initialise tous les events desactives", () => { + const state = createDefaultLogEventState(); + + assert.equal(Object.keys(state).length, LOG_EVENT_KEYS.length); + for (const eventKey of LOG_EVENT_KEYS) { + assert.equal(state[eventKey].enabled, false); + assert.equal(state[eventKey].channelId, null); + } +}); + +test("isLogEventKey valide les cles connues", () => { + assert.equal(isLogEventKey("messageCreate"), true); + assert.equal(isLogEventKey("not-an-event"), false); +}); + +test("mergeLogEventRowsIntoState fusionne les valeurs stockees", () => { + const state = mergeLogEventRowsIntoState([ + { + event_key: "messageCreate", + enabled: true, + channel_id: "123", + }, + { + event_key: "not-existing", + enabled: true, + channel_id: "999", + }, + ]); + + assert.equal(state.messageCreate.enabled, true); + assert.equal(state.messageCreate.channelId, "123"); + assert.equal(state.messageDelete.enabled, false); + assert.equal(state.messageDelete.channelId, null); +}); \ No newline at end of file