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 eventLabel = (ctx: CommandExecutionContext, eventKey: LogEventKey): string => { return ctx.ct(`ui.events.${eventKey}`); }; 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(`- ${eventLabel(ctx, 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 localizedEvent = eventLabel(ctx, definition.key); const label = eventConfig.enabled ? ctx.ct("ui.buttons.disableEvent", { event: localizedEvent }) : ctx.ct("ui.buttons.enableEvent", { event: localizedEvent }); 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); const localizedEvent = eventLabel(ctx, eventKey); uiState.feedback = state[eventKey].enabled ? ctx.ct("responses.eventEnabled", { event: localizedEvent }) : ctx.ct("responses.eventDisabled", { event: localizedEvent }); 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(); }); }; };