add logs command

This commit is contained in:
Puechberty Arthur
2026-04-16 23:09:28 +02:00
parent 8f9aa4d1bb
commit 30378f2c91
20 changed files with 1793 additions and 4 deletions
@@ -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);
+11
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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");
});
+4
View File
@@ -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;
}
+2
View File
@@ -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,
];
+23
View File
@@ -0,0 +1,23 @@
import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js";
import {
createLogsCommandExecute,
type LogEventService,
} from "../modules/logs/index.js";
export const createLogsCommand = (logEventService: LogEventService) => defineCommand({
meta: {
name: "logs",
category: "utility",
},
permissions: [PermissionFlagsBits.ManageGuild],
sensitive: true,
examples: [
{
source: "slash",
descriptionKey: "examples.slash",
},
],
execute: createLogsCommandExecute(logEventService),
});
+129
View File
@@ -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],
);
}
}
+11 -3
View File
@@ -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");
});
});
+3 -1
View File
@@ -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);
};
+465
View File
@@ -0,0 +1,465 @@
import {
Events,
type Client,
} from "discord.js";
import { createScopedLogger } from "../core/logging/logger.js";
import type { LogEventKey } from "../types/logs.js";
import type { LogEventService } from "../modules/logs/index.js";
const logger = createScopedLogger("event:logsRuntime");
const clamp = (value: string, maxLength: number): string => {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 3)}...`;
};
const formatContent = (content: string | null | undefined): string => {
if (typeof content !== "string") {
return "(no content)";
}
const trimmed = content.trim();
if (trimmed.length === 0) {
return "(no content)";
}
return clamp(trimmed, 400);
};
const emitRuntimeLog = (
client: Client,
logEventService: LogEventService,
eventKey: LogEventKey,
guildId: string | null | undefined,
summary: string,
details: string[] = [],
color?: number,
): void => {
if (!guildId) {
return;
}
void logEventService.dispatchEvent(client, {
eventKey,
guildId,
summary,
...(details.length > 0 ? { details } : {}),
...(color !== undefined ? { color } : {}),
}).catch((error) => {
logger.error({ eventKey, guildId, err: error }, "failed to dispatch runtime log event");
});
};
export const registerLogRuntimeEvents = (client: Client, logEventService: LogEventService): void => {
client.on(Events.MessageCreate, (message) => {
if (!message.guild || message.author.bot) {
return;
}
emitRuntimeLog(
client,
logEventService,
"messageCreate",
message.guild.id,
`Message sent by <@${message.author.id}> in <#${message.channelId}>`,
[
`messageId=${message.id}`,
`author=${message.author.tag} (${message.author.id})`,
`content=${formatContent(message.content)}`,
],
0x57f287,
);
});
client.on(Events.MessageDelete, (message) => {
const authorId = message.author?.id ?? "unknown";
const authorTag = message.author?.tag ?? "unknown";
emitRuntimeLog(
client,
logEventService,
"messageDelete",
message.guild?.id,
`Message deleted in <#${message.channelId}>`,
[
`messageId=${message.id}`,
`author=${authorTag} (${authorId})`,
`content=${formatContent(message.content)}`,
],
0xed4245,
);
});
client.on(Events.MessageUpdate, (oldMessage, newMessage) => {
emitRuntimeLog(
client,
logEventService,
"messageUpdate",
newMessage.guild?.id,
`Message updated in <#${newMessage.channelId}>`,
[
`messageId=${newMessage.id}`,
`before=${formatContent(oldMessage.content)}`,
`after=${formatContent(newMessage.content)}`,
],
0xfee75c,
);
});
client.on(Events.MessageBulkDelete, (messages) => {
const first = messages.first();
emitRuntimeLog(
client,
logEventService,
"messageBulkDelete",
first?.guild?.id,
`Bulk delete: ${messages.size} messages removed`,
[
`channelId=${first?.channelId ?? "unknown"}`,
`count=${messages.size}`,
],
0xed4245,
);
});
client.on(Events.GuildMemberAdd, (member) => {
emitRuntimeLog(
client,
logEventService,
"guildMemberAdd",
member.guild.id,
`Member joined: <@${member.user.id}>`,
[`user=${member.user.tag} (${member.user.id})`],
0x57f287,
);
});
client.on(Events.GuildMemberRemove, (member) => {
emitRuntimeLog(
client,
logEventService,
"guildMemberRemove",
member.guild.id,
`Member left: <@${member.user.id}>`,
[`user=${member.user.tag} (${member.user.id})`],
0xed4245,
);
});
client.on(Events.GuildMemberUpdate, (oldMember, newMember) => {
emitRuntimeLog(
client,
logEventService,
"guildMemberUpdate",
newMember.guild.id,
`Member updated: <@${newMember.user.id}>`,
[
`user=${newMember.user.tag} (${newMember.user.id})`,
`oldNick=${oldMember.nickname ?? "none"}`,
`newNick=${newMember.nickname ?? "none"}`,
],
0x5865f2,
);
});
client.on(Events.InteractionCreate, (interaction) => {
if (!interaction.guildId || interaction.user.bot) {
return;
}
let summary = `Interaction by <@${interaction.user.id}>`;
if (interaction.isChatInputCommand()) {
summary = `Slash command /${interaction.commandName} by <@${interaction.user.id}>`;
} else if (interaction.isButton()) {
summary = `Button interaction by <@${interaction.user.id}>`;
} else if (interaction.isStringSelectMenu()) {
summary = `Select menu interaction by <@${interaction.user.id}>`;
} else if (interaction.isModalSubmit()) {
summary = `Modal submit by <@${interaction.user.id}>`;
}
emitRuntimeLog(
client,
logEventService,
"interactionCreate",
interaction.guildId,
summary,
[
`interactionId=${interaction.id}`,
`channelId=${interaction.channelId ?? "unknown"}`,
],
0x5865f2,
);
});
client.on(Events.ChannelCreate, (channel) => {
if (!("guild" in channel) || !("name" in channel)) {
return;
}
emitRuntimeLog(
client,
logEventService,
"channelCreate",
channel.guild.id,
`Channel created: #${channel.name}`,
[
`channelId=${channel.id}`,
`type=${channel.type}`,
],
0x57f287,
);
});
client.on(Events.ChannelDelete, (channel) => {
if (!("guild" in channel) || !("name" in channel)) {
return;
}
emitRuntimeLog(
client,
logEventService,
"channelDelete",
channel.guild.id,
`Channel deleted: #${channel.name}`,
[
`channelId=${channel.id}`,
`type=${channel.type}`,
],
0xed4245,
);
});
client.on(Events.ChannelUpdate, (oldChannel, newChannel) => {
if (!("guild" in newChannel) || !("name" in newChannel)) {
return;
}
const oldName = "name" in oldChannel ? oldChannel.name : "unknown";
emitRuntimeLog(
client,
logEventService,
"channelUpdate",
newChannel.guild.id,
`Channel updated: #${newChannel.name}`,
[
`channelId=${newChannel.id}`,
`oldName=${oldName}`,
`newName=${newChannel.name}`,
],
0xfee75c,
);
});
client.on(Events.GuildRoleCreate, (role) => {
emitRuntimeLog(
client,
logEventService,
"roleCreate",
role.guild.id,
`Role created: @${role.name}`,
[`roleId=${role.id}`],
0x57f287,
);
});
client.on(Events.GuildRoleDelete, (role) => {
emitRuntimeLog(
client,
logEventService,
"roleDelete",
role.guild.id,
`Role deleted: @${role.name}`,
[`roleId=${role.id}`],
0xed4245,
);
});
client.on(Events.GuildRoleUpdate, (oldRole, newRole) => {
emitRuntimeLog(
client,
logEventService,
"roleUpdate",
newRole.guild.id,
`Role updated: @${newRole.name}`,
[
`roleId=${newRole.id}`,
`oldName=${oldRole.name}`,
`newName=${newRole.name}`,
],
0xfee75c,
);
});
client.on(Events.ThreadCreate, (thread) => {
emitRuntimeLog(
client,
logEventService,
"threadCreate",
thread.guild?.id,
`Thread created: ${thread.name}`,
[`threadId=${thread.id}`],
0x57f287,
);
});
client.on(Events.ThreadDelete, (thread) => {
emitRuntimeLog(
client,
logEventService,
"threadDelete",
thread.guild?.id,
`Thread deleted: ${thread.name}`,
[`threadId=${thread.id}`],
0xed4245,
);
});
client.on(Events.ThreadUpdate, (oldThread, newThread) => {
emitRuntimeLog(
client,
logEventService,
"threadUpdate",
newThread.guild?.id,
`Thread updated: ${newThread.name}`,
[
`threadId=${newThread.id}`,
`oldName=${oldThread.name}`,
`newName=${newThread.name}`,
],
0xfee75c,
);
});
client.on(Events.GuildEmojiCreate, (emoji) => {
emitRuntimeLog(
client,
logEventService,
"emojiCreate",
emoji.guild?.id,
`Emoji created: ${emoji.name ?? "unknown"}`,
[`emojiId=${emoji.id ?? "unknown"}`],
0x57f287,
);
});
client.on(Events.GuildEmojiDelete, (emoji) => {
emitRuntimeLog(
client,
logEventService,
"emojiDelete",
emoji.guild?.id,
`Emoji deleted: ${emoji.name ?? "unknown"}`,
[`emojiId=${emoji.id ?? "unknown"}`],
0xed4245,
);
});
client.on(Events.GuildEmojiUpdate, (oldEmoji, newEmoji) => {
emitRuntimeLog(
client,
logEventService,
"emojiUpdate",
newEmoji.guild?.id,
`Emoji updated: ${newEmoji.name ?? "unknown"}`,
[
`emojiId=${newEmoji.id ?? "unknown"}`,
`oldName=${oldEmoji.name ?? "unknown"}`,
`newName=${newEmoji.name ?? "unknown"}`,
],
0xfee75c,
);
});
client.on(Events.GuildUpdate, (oldGuild, newGuild) => {
emitRuntimeLog(
client,
logEventService,
"guildUpdate",
newGuild.id,
`Guild updated: ${newGuild.name}`,
[
`guildId=${newGuild.id}`,
`oldName=${oldGuild.name}`,
`newName=${newGuild.name}`,
],
0xfee75c,
);
});
client.on(Events.GuildUnavailable, (guild) => {
emitRuntimeLog(
client,
logEventService,
"guildUnavailable",
guild.id,
`Guild temporarily unavailable: ${guild.name}`,
[`guildId=${guild.id}`],
0xed4245,
);
});
client.on(Events.GuildBanAdd, (ban) => {
emitRuntimeLog(
client,
logEventService,
"guildBanAdd",
ban.guild.id,
`User banned: <@${ban.user.id}>`,
[
`user=${ban.user.tag} (${ban.user.id})`,
`guildId=${ban.guild.id}`,
],
0xed4245,
);
});
client.on(Events.GuildBanRemove, (ban) => {
emitRuntimeLog(
client,
logEventService,
"guildBanRemove",
ban.guild.id,
`User unbanned: <@${ban.user.id}>`,
[
`user=${ban.user.tag} (${ban.user.id})`,
`guildId=${ban.guild.id}`,
],
0x57f287,
);
});
client.on(Events.InviteCreate, (invite) => {
emitRuntimeLog(
client,
logEventService,
"inviteCreate",
invite.guild?.id,
`Invite created: ${invite.code}`,
[
`channelId=${invite.channelId ?? "unknown"}`,
`inviter=${invite.inviter?.tag ?? "unknown"}`,
],
0x57f287,
);
});
client.on(Events.InviteDelete, (invite) => {
emitRuntimeLog(
client,
logEventService,
"inviteDelete",
invite.guild?.id,
`Invite deleted: ${invite.code}`,
[
`channelId=${invite.channelId ?? "unknown"}`,
],
0xed4245,
);
});
};
+54
View File
@@ -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;
+372
View File
@@ -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();
});
};
};
+13
View File
@@ -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>;
}
+331
View File
@@ -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}`;
}
}
+45
View File
@@ -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.",
+45
View File
@@ -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.",
+45
View File
@@ -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.",
+3
View File
@@ -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";
+92
View File
@@ -0,0 +1,92 @@
import type { Message } from "discord.js";
export type LogEventCategoryKey =
| "message"
| "member"
| "interaction"
| "channel"
| "role"
| "thread"
| "emoji"
| "guild"
| "moderation"
| "invite";
export type LogEventKey =
| "messageCreate"
| "messageDelete"
| "messageUpdate"
| "messageBulkDelete"
| "guildMemberAdd"
| "guildMemberRemove"
| "guildMemberUpdate"
| "interactionCreate"
| "channelCreate"
| "channelDelete"
| "channelUpdate"
| "roleCreate"
| "roleDelete"
| "roleUpdate"
| "threadCreate"
| "threadDelete"
| "threadUpdate"
| "emojiCreate"
| "emojiDelete"
| "emojiUpdate"
| "guildUpdate"
| "guildUnavailable"
| "guildBanAdd"
| "guildBanRemove"
| "inviteCreate"
| "inviteDelete";
export interface LogEventDefinition {
key: LogEventKey;
category: LogEventCategoryKey;
}
export interface LogEventConfig {
enabled: boolean;
channelId: string | null;
}
export type LogEventStateByKey = Record<LogEventKey, LogEventConfig>;
export interface LogEventRow {
event_key: string;
enabled: boolean;
channel_id: string | null;
}
export interface LogEventRepositoryEntry {
eventKey: LogEventKey;
config: LogEventConfig;
}
export interface LogRuntimeDispatchInput {
eventKey: LogEventKey;
guildId: string;
summary: string;
details?: string[];
color?: number;
}
export interface LogPanelCustomIds {
enableAllButton: string;
disableAllButton: string;
createChannelsButton: string;
previousPageButton: string;
nextPageButton: string;
toggleButtonsByEvent: Record<LogEventKey, string>;
}
export interface LogPanelSession {
collector: ReturnType<Message["createMessageComponentCollector"]>;
disable: () => Promise<void>;
}
export interface LogChannelProvisionResult {
createdCount: number;
reusedCount: number;
failedCategories: LogEventCategoryKey[];
}
+60
View File
@@ -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;
};
+44
View File
@@ -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);
});