mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
add logs command
This commit is contained in:
@@ -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);
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PermissionFlagsBits } from "discord.js";
|
||||
|
||||
import { defineCommand } from "../core/commands/defineCommand.js";
|
||||
import {
|
||||
createLogsCommandExecute,
|
||||
type LogEventService,
|
||||
} from "../modules/logs/index.js";
|
||||
|
||||
export const createLogsCommand = (logEventService: LogEventService) => defineCommand({
|
||||
meta: {
|
||||
name: "logs",
|
||||
category: "utility",
|
||||
},
|
||||
permissions: [PermissionFlagsBits.ManageGuild],
|
||||
sensitive: true,
|
||||
examples: [
|
||||
{
|
||||
source: "slash",
|
||||
descriptionKey: "examples.slash",
|
||||
},
|
||||
],
|
||||
execute: createLogsCommandExecute(logEventService),
|
||||
});
|
||||
@@ -0,0 +1,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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import {
|
||||
Events,
|
||||
type Client,
|
||||
} from "discord.js";
|
||||
|
||||
import { createScopedLogger } from "../core/logging/logger.js";
|
||||
import type { LogEventKey } from "../types/logs.js";
|
||||
import type { LogEventService } from "../modules/logs/index.js";
|
||||
|
||||
const logger = createScopedLogger("event:logsRuntime");
|
||||
|
||||
const clamp = (value: string, maxLength: number): string => {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, maxLength - 3)}...`;
|
||||
};
|
||||
|
||||
const formatContent = (content: string | null | undefined): string => {
|
||||
if (typeof content !== "string") {
|
||||
return "(no content)";
|
||||
}
|
||||
|
||||
const trimmed = content.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return "(no content)";
|
||||
}
|
||||
|
||||
return clamp(trimmed, 400);
|
||||
};
|
||||
|
||||
const emitRuntimeLog = (
|
||||
client: Client,
|
||||
logEventService: LogEventService,
|
||||
eventKey: LogEventKey,
|
||||
guildId: string | null | undefined,
|
||||
summary: string,
|
||||
details: string[] = [],
|
||||
color?: number,
|
||||
): void => {
|
||||
if (!guildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEventService.dispatchEvent(client, {
|
||||
eventKey,
|
||||
guildId,
|
||||
summary,
|
||||
...(details.length > 0 ? { details } : {}),
|
||||
...(color !== undefined ? { color } : {}),
|
||||
}).catch((error) => {
|
||||
logger.error({ eventKey, guildId, err: error }, "failed to dispatch runtime log event");
|
||||
});
|
||||
};
|
||||
|
||||
export const registerLogRuntimeEvents = (client: Client, logEventService: LogEventService): void => {
|
||||
client.on(Events.MessageCreate, (message) => {
|
||||
if (!message.guild || message.author.bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"messageCreate",
|
||||
message.guild.id,
|
||||
`Message sent by <@${message.author.id}> in <#${message.channelId}>`,
|
||||
[
|
||||
`messageId=${message.id}`,
|
||||
`author=${message.author.tag} (${message.author.id})`,
|
||||
`content=${formatContent(message.content)}`,
|
||||
],
|
||||
0x57f287,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.MessageDelete, (message) => {
|
||||
const authorId = message.author?.id ?? "unknown";
|
||||
const authorTag = message.author?.tag ?? "unknown";
|
||||
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"messageDelete",
|
||||
message.guild?.id,
|
||||
`Message deleted in <#${message.channelId}>`,
|
||||
[
|
||||
`messageId=${message.id}`,
|
||||
`author=${authorTag} (${authorId})`,
|
||||
`content=${formatContent(message.content)}`,
|
||||
],
|
||||
0xed4245,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.MessageUpdate, (oldMessage, newMessage) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"messageUpdate",
|
||||
newMessage.guild?.id,
|
||||
`Message updated in <#${newMessage.channelId}>`,
|
||||
[
|
||||
`messageId=${newMessage.id}`,
|
||||
`before=${formatContent(oldMessage.content)}`,
|
||||
`after=${formatContent(newMessage.content)}`,
|
||||
],
|
||||
0xfee75c,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.MessageBulkDelete, (messages) => {
|
||||
const first = messages.first();
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"messageBulkDelete",
|
||||
first?.guild?.id,
|
||||
`Bulk delete: ${messages.size} messages removed`,
|
||||
[
|
||||
`channelId=${first?.channelId ?? "unknown"}`,
|
||||
`count=${messages.size}`,
|
||||
],
|
||||
0xed4245,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildMemberAdd, (member) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"guildMemberAdd",
|
||||
member.guild.id,
|
||||
`Member joined: <@${member.user.id}>`,
|
||||
[`user=${member.user.tag} (${member.user.id})`],
|
||||
0x57f287,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildMemberRemove, (member) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"guildMemberRemove",
|
||||
member.guild.id,
|
||||
`Member left: <@${member.user.id}>`,
|
||||
[`user=${member.user.tag} (${member.user.id})`],
|
||||
0xed4245,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildMemberUpdate, (oldMember, newMember) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"guildMemberUpdate",
|
||||
newMember.guild.id,
|
||||
`Member updated: <@${newMember.user.id}>`,
|
||||
[
|
||||
`user=${newMember.user.tag} (${newMember.user.id})`,
|
||||
`oldNick=${oldMember.nickname ?? "none"}`,
|
||||
`newNick=${newMember.nickname ?? "none"}`,
|
||||
],
|
||||
0x5865f2,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.InteractionCreate, (interaction) => {
|
||||
if (!interaction.guildId || interaction.user.bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
let summary = `Interaction by <@${interaction.user.id}>`;
|
||||
if (interaction.isChatInputCommand()) {
|
||||
summary = `Slash command /${interaction.commandName} by <@${interaction.user.id}>`;
|
||||
} else if (interaction.isButton()) {
|
||||
summary = `Button interaction by <@${interaction.user.id}>`;
|
||||
} else if (interaction.isStringSelectMenu()) {
|
||||
summary = `Select menu interaction by <@${interaction.user.id}>`;
|
||||
} else if (interaction.isModalSubmit()) {
|
||||
summary = `Modal submit by <@${interaction.user.id}>`;
|
||||
}
|
||||
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"interactionCreate",
|
||||
interaction.guildId,
|
||||
summary,
|
||||
[
|
||||
`interactionId=${interaction.id}`,
|
||||
`channelId=${interaction.channelId ?? "unknown"}`,
|
||||
],
|
||||
0x5865f2,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.ChannelCreate, (channel) => {
|
||||
if (!("guild" in channel) || !("name" in channel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"channelCreate",
|
||||
channel.guild.id,
|
||||
`Channel created: #${channel.name}`,
|
||||
[
|
||||
`channelId=${channel.id}`,
|
||||
`type=${channel.type}`,
|
||||
],
|
||||
0x57f287,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.ChannelDelete, (channel) => {
|
||||
if (!("guild" in channel) || !("name" in channel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"channelDelete",
|
||||
channel.guild.id,
|
||||
`Channel deleted: #${channel.name}`,
|
||||
[
|
||||
`channelId=${channel.id}`,
|
||||
`type=${channel.type}`,
|
||||
],
|
||||
0xed4245,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.ChannelUpdate, (oldChannel, newChannel) => {
|
||||
if (!("guild" in newChannel) || !("name" in newChannel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldName = "name" in oldChannel ? oldChannel.name : "unknown";
|
||||
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"channelUpdate",
|
||||
newChannel.guild.id,
|
||||
`Channel updated: #${newChannel.name}`,
|
||||
[
|
||||
`channelId=${newChannel.id}`,
|
||||
`oldName=${oldName}`,
|
||||
`newName=${newChannel.name}`,
|
||||
],
|
||||
0xfee75c,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildRoleCreate, (role) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"roleCreate",
|
||||
role.guild.id,
|
||||
`Role created: @${role.name}`,
|
||||
[`roleId=${role.id}`],
|
||||
0x57f287,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildRoleDelete, (role) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"roleDelete",
|
||||
role.guild.id,
|
||||
`Role deleted: @${role.name}`,
|
||||
[`roleId=${role.id}`],
|
||||
0xed4245,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildRoleUpdate, (oldRole, newRole) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"roleUpdate",
|
||||
newRole.guild.id,
|
||||
`Role updated: @${newRole.name}`,
|
||||
[
|
||||
`roleId=${newRole.id}`,
|
||||
`oldName=${oldRole.name}`,
|
||||
`newName=${newRole.name}`,
|
||||
],
|
||||
0xfee75c,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.ThreadCreate, (thread) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"threadCreate",
|
||||
thread.guild?.id,
|
||||
`Thread created: ${thread.name}`,
|
||||
[`threadId=${thread.id}`],
|
||||
0x57f287,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.ThreadDelete, (thread) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"threadDelete",
|
||||
thread.guild?.id,
|
||||
`Thread deleted: ${thread.name}`,
|
||||
[`threadId=${thread.id}`],
|
||||
0xed4245,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.ThreadUpdate, (oldThread, newThread) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"threadUpdate",
|
||||
newThread.guild?.id,
|
||||
`Thread updated: ${newThread.name}`,
|
||||
[
|
||||
`threadId=${newThread.id}`,
|
||||
`oldName=${oldThread.name}`,
|
||||
`newName=${newThread.name}`,
|
||||
],
|
||||
0xfee75c,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildEmojiCreate, (emoji) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"emojiCreate",
|
||||
emoji.guild?.id,
|
||||
`Emoji created: ${emoji.name ?? "unknown"}`,
|
||||
[`emojiId=${emoji.id ?? "unknown"}`],
|
||||
0x57f287,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildEmojiDelete, (emoji) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"emojiDelete",
|
||||
emoji.guild?.id,
|
||||
`Emoji deleted: ${emoji.name ?? "unknown"}`,
|
||||
[`emojiId=${emoji.id ?? "unknown"}`],
|
||||
0xed4245,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildEmojiUpdate, (oldEmoji, newEmoji) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"emojiUpdate",
|
||||
newEmoji.guild?.id,
|
||||
`Emoji updated: ${newEmoji.name ?? "unknown"}`,
|
||||
[
|
||||
`emojiId=${newEmoji.id ?? "unknown"}`,
|
||||
`oldName=${oldEmoji.name ?? "unknown"}`,
|
||||
`newName=${newEmoji.name ?? "unknown"}`,
|
||||
],
|
||||
0xfee75c,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildUpdate, (oldGuild, newGuild) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"guildUpdate",
|
||||
newGuild.id,
|
||||
`Guild updated: ${newGuild.name}`,
|
||||
[
|
||||
`guildId=${newGuild.id}`,
|
||||
`oldName=${oldGuild.name}`,
|
||||
`newName=${newGuild.name}`,
|
||||
],
|
||||
0xfee75c,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildUnavailable, (guild) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"guildUnavailable",
|
||||
guild.id,
|
||||
`Guild temporarily unavailable: ${guild.name}`,
|
||||
[`guildId=${guild.id}`],
|
||||
0xed4245,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildBanAdd, (ban) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"guildBanAdd",
|
||||
ban.guild.id,
|
||||
`User banned: <@${ban.user.id}>`,
|
||||
[
|
||||
`user=${ban.user.tag} (${ban.user.id})`,
|
||||
`guildId=${ban.guild.id}`,
|
||||
],
|
||||
0xed4245,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.GuildBanRemove, (ban) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"guildBanRemove",
|
||||
ban.guild.id,
|
||||
`User unbanned: <@${ban.user.id}>`,
|
||||
[
|
||||
`user=${ban.user.tag} (${ban.user.id})`,
|
||||
`guildId=${ban.guild.id}`,
|
||||
],
|
||||
0x57f287,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.InviteCreate, (invite) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"inviteCreate",
|
||||
invite.guild?.id,
|
||||
`Invite created: ${invite.code}`,
|
||||
[
|
||||
`channelId=${invite.channelId ?? "unknown"}`,
|
||||
`inviter=${invite.inviter?.tag ?? "unknown"}`,
|
||||
],
|
||||
0x57f287,
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.InviteDelete, (invite) => {
|
||||
emitRuntimeLog(
|
||||
client,
|
||||
logEventService,
|
||||
"inviteDelete",
|
||||
invite.guild?.id,
|
||||
`Invite deleted: ${invite.code}`,
|
||||
[
|
||||
`channelId=${invite.channelId ?? "unknown"}`,
|
||||
],
|
||||
0xed4245,
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
LogEventCategoryKey,
|
||||
LogEventDefinition,
|
||||
} from "../../types/logs.js";
|
||||
|
||||
export const LOG_EVENT_DEFINITIONS: readonly LogEventDefinition[] = [
|
||||
{ key: "messageCreate", category: "message" },
|
||||
{ key: "messageDelete", category: "message" },
|
||||
{ key: "messageUpdate", category: "message" },
|
||||
{ key: "messageBulkDelete", category: "message" },
|
||||
{ key: "guildMemberAdd", category: "member" },
|
||||
{ key: "guildMemberRemove", category: "member" },
|
||||
{ key: "guildMemberUpdate", category: "member" },
|
||||
{ key: "interactionCreate", category: "interaction" },
|
||||
{ key: "channelCreate", category: "channel" },
|
||||
{ key: "channelDelete", category: "channel" },
|
||||
{ key: "channelUpdate", category: "channel" },
|
||||
{ key: "roleCreate", category: "role" },
|
||||
{ key: "roleDelete", category: "role" },
|
||||
{ key: "roleUpdate", category: "role" },
|
||||
{ key: "threadCreate", category: "thread" },
|
||||
{ key: "threadDelete", category: "thread" },
|
||||
{ key: "threadUpdate", category: "thread" },
|
||||
{ key: "emojiCreate", category: "emoji" },
|
||||
{ key: "emojiDelete", category: "emoji" },
|
||||
{ key: "emojiUpdate", category: "emoji" },
|
||||
{ key: "guildUpdate", category: "guild" },
|
||||
{ key: "guildUnavailable", category: "guild" },
|
||||
{ key: "guildBanAdd", category: "moderation" },
|
||||
{ key: "guildBanRemove", category: "moderation" },
|
||||
{ key: "inviteCreate", category: "invite" },
|
||||
{ key: "inviteDelete", category: "invite" },
|
||||
];
|
||||
|
||||
export const LOG_EVENT_KEYS = LOG_EVENT_DEFINITIONS.map((definition) => definition.key);
|
||||
|
||||
export const LOG_EVENT_KEYS_SET = new Set(LOG_EVENT_KEYS);
|
||||
|
||||
export const LOG_CHANNEL_NAME_BY_CATEGORY: Record<LogEventCategoryKey, string> = {
|
||||
message: "logs-message",
|
||||
member: "logs-member",
|
||||
interaction: "logs-interaction",
|
||||
channel: "logs-channel",
|
||||
role: "logs-role",
|
||||
thread: "logs-thread",
|
||||
emoji: "logs-emoji",
|
||||
guild: "logs-guild",
|
||||
moderation: "logs-moderation",
|
||||
invite: "logs-invite",
|
||||
};
|
||||
|
||||
export const LOG_EVENT_CATEGORY_KEYS = Object.keys(LOG_CHANNEL_NAME_BY_CATEGORY) as LogEventCategoryKey[];
|
||||
|
||||
export const LOGS_PANEL_EVENTS_PER_PAGE = 15;
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ContainerBuilder,
|
||||
MessageFlags,
|
||||
TextDisplayBuilder,
|
||||
} from "discord.js";
|
||||
|
||||
import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js";
|
||||
import { createScopedLogger } from "../../core/logging/logger.js";
|
||||
import type { CommandExecutionContext } from "../../types/command.js";
|
||||
import type {
|
||||
LogEventDefinition,
|
||||
LogEventKey,
|
||||
LogEventStateByKey,
|
||||
LogPanelCustomIds,
|
||||
} from "../../types/logs.js";
|
||||
import {
|
||||
LOG_EVENT_DEFINITIONS,
|
||||
LOG_EVENT_KEYS,
|
||||
LOGS_PANEL_EVENTS_PER_PAGE,
|
||||
} from "./catalog.js";
|
||||
import type { LogEventService } from "./service.js";
|
||||
|
||||
const logger = createScopedLogger("command:logs");
|
||||
|
||||
interface LogPanelUiState {
|
||||
pageIndex: number;
|
||||
feedback: string | null;
|
||||
}
|
||||
|
||||
const chunk = <T>(items: readonly T[], size: number): T[][] => {
|
||||
const output: T[][] = [];
|
||||
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
output.push(items.slice(index, index + size));
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
const createCustomIds = (): LogPanelCustomIds => {
|
||||
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const toggleButtonsByEvent = {} as Record<LogEventKey, string>;
|
||||
|
||||
for (const eventKey of LOG_EVENT_KEYS) {
|
||||
toggleButtonsByEvent[eventKey] = `logs:toggle:${eventKey}:${nonce}`;
|
||||
}
|
||||
|
||||
return {
|
||||
enableAllButton: `logs:enable-all:${nonce}`,
|
||||
disableAllButton: `logs:disable-all:${nonce}`,
|
||||
createChannelsButton: `logs:create-channels:${nonce}`,
|
||||
previousPageButton: `logs:page-prev:${nonce}`,
|
||||
nextPageButton: `logs:page-next:${nonce}`,
|
||||
toggleButtonsByEvent,
|
||||
};
|
||||
};
|
||||
|
||||
const clampPageIndex = (index: number, pageCount: number): number => {
|
||||
if (index < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (index >= pageCount) {
|
||||
return pageCount - 1;
|
||||
}
|
||||
|
||||
return index;
|
||||
};
|
||||
|
||||
const pageCount = (): number => Math.max(1, Math.ceil(LOG_EVENT_DEFINITIONS.length / LOGS_PANEL_EVENTS_PER_PAGE));
|
||||
|
||||
const pageEvents = (uiState: LogPanelUiState): LogEventDefinition[] => {
|
||||
const totalPages = pageCount();
|
||||
uiState.pageIndex = clampPageIndex(uiState.pageIndex, totalPages);
|
||||
const start = uiState.pageIndex * LOGS_PANEL_EVENTS_PER_PAGE;
|
||||
return LOG_EVENT_DEFINITIONS.slice(start, start + LOGS_PANEL_EVENTS_PER_PAGE);
|
||||
};
|
||||
|
||||
const eventStatusLabel = (ctx: CommandExecutionContext, enabled: boolean): string => {
|
||||
return enabled ? ctx.ct("ui.status.enabled") : ctx.ct("ui.status.disabled");
|
||||
};
|
||||
|
||||
const panelContent = (
|
||||
ctx: CommandExecutionContext,
|
||||
state: LogEventStateByKey,
|
||||
uiState: LogPanelUiState,
|
||||
): string => {
|
||||
const totalPages = pageCount();
|
||||
const currentEvents = pageEvents(uiState);
|
||||
const enabledCount = LOG_EVENT_KEYS.filter((eventKey) => state[eventKey].enabled).length;
|
||||
|
||||
const lines = [
|
||||
`## ${ctx.ct("ui.embed.title")}`,
|
||||
ctx.ct("ui.embed.description"),
|
||||
"",
|
||||
ctx.ct("ui.pageLabel", { page: uiState.pageIndex + 1, pageCount: totalPages }),
|
||||
ctx.ct("ui.enabledCountLabel", { enabledCount, totalCount: LOG_EVENT_KEYS.length }),
|
||||
"",
|
||||
`${ctx.ct("ui.eventsHeader")}:`,
|
||||
];
|
||||
|
||||
for (const definition of currentEvents) {
|
||||
const eventConfig = state[definition.key];
|
||||
const channelDisplay = eventConfig.channelId ? `<#${eventConfig.channelId}>` : ctx.ct("ui.channelNotConfigured");
|
||||
lines.push(`- ${definition.key} | ${eventStatusLabel(ctx, eventConfig.enabled)} | ${ctx.ct("ui.channelLabel")}: ${channelDisplay}`);
|
||||
}
|
||||
|
||||
if (uiState.feedback) {
|
||||
lines.push("", `${ctx.ct("ui.feedbackLabel")}: ${uiState.feedback}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const buildContainer = (
|
||||
ctx: CommandExecutionContext,
|
||||
state: LogEventStateByKey,
|
||||
customIds: LogPanelCustomIds,
|
||||
uiState: LogPanelUiState,
|
||||
disabled = false,
|
||||
): ContainerBuilder => {
|
||||
const totalPages = pageCount();
|
||||
const currentEvents = pageEvents(uiState);
|
||||
|
||||
const enableAllButton = new ButtonBuilder()
|
||||
.setCustomId(customIds.enableAllButton)
|
||||
.setLabel(ctx.ct("ui.buttons.enableAll"))
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(disabled);
|
||||
|
||||
const disableAllButton = new ButtonBuilder()
|
||||
.setCustomId(customIds.disableAllButton)
|
||||
.setLabel(ctx.ct("ui.buttons.disableAll"))
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
.setDisabled(disabled);
|
||||
|
||||
const createChannelsButton = new ButtonBuilder()
|
||||
.setCustomId(customIds.createChannelsButton)
|
||||
.setLabel(ctx.ct("ui.buttons.createChannels"))
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setDisabled(disabled);
|
||||
|
||||
const previousPageButton = new ButtonBuilder()
|
||||
.setCustomId(customIds.previousPageButton)
|
||||
.setLabel(ctx.ct("ui.buttons.previousPage"))
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(disabled || uiState.pageIndex <= 0);
|
||||
|
||||
const nextPageButton = new ButtonBuilder()
|
||||
.setCustomId(customIds.nextPageButton)
|
||||
.setLabel(ctx.ct("ui.buttons.nextPage"))
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(disabled || uiState.pageIndex >= totalPages - 1);
|
||||
|
||||
const container = new ContainerBuilder();
|
||||
container.addTextDisplayComponents(new TextDisplayBuilder().setContent(panelContent(ctx, state, uiState)));
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(enableAllButton, disableAllButton, createChannelsButton),
|
||||
);
|
||||
|
||||
for (const definitionChunk of chunk(currentEvents, 5)) {
|
||||
const rowButtons = definitionChunk.map((definition) => {
|
||||
const eventConfig = state[definition.key];
|
||||
const label = eventConfig.enabled
|
||||
? ctx.ct("ui.buttons.disableEvent", { event: definition.key })
|
||||
: ctx.ct("ui.buttons.enableEvent", { event: definition.key });
|
||||
|
||||
return new ButtonBuilder()
|
||||
.setCustomId(customIds.toggleButtonsByEvent[definition.key])
|
||||
.setLabel(label)
|
||||
.setStyle(eventConfig.enabled ? ButtonStyle.Danger : ButtonStyle.Success)
|
||||
.setDisabled(disabled);
|
||||
});
|
||||
|
||||
container.addActionRowComponents(new ActionRowBuilder<ButtonBuilder>().addComponents(...rowButtons));
|
||||
}
|
||||
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(previousPageButton, nextPageButton),
|
||||
);
|
||||
|
||||
return container;
|
||||
};
|
||||
|
||||
const applyAllEnabled = (state: LogEventStateByKey, enabled: boolean): void => {
|
||||
for (const eventKey of LOG_EVENT_KEYS) {
|
||||
state[eventKey].enabled = enabled;
|
||||
}
|
||||
};
|
||||
|
||||
const toToggleLookup = (customIds: LogPanelCustomIds): Map<string, LogEventKey> => {
|
||||
return new Map(
|
||||
LOG_EVENT_KEYS.map((eventKey) => [customIds.toggleButtonsByEvent[eventKey], eventKey]),
|
||||
);
|
||||
};
|
||||
|
||||
export const createLogsCommandExecute = (logEventService: LogEventService) => {
|
||||
return async (ctx: CommandExecutionContext): Promise<void> => {
|
||||
|
||||
if (!ctx.guild) {
|
||||
await ctx.reply(ctx.ct("responses.guildOnly"));
|
||||
return;
|
||||
}
|
||||
|
||||
const guild = ctx.guild;
|
||||
const state = await logEventService.loadGuildState(ctx.client, guild.id);
|
||||
const customIds = createCustomIds();
|
||||
const toggleLookup = toToggleLookup(customIds);
|
||||
|
||||
const uiState: LogPanelUiState = {
|
||||
pageIndex: 0,
|
||||
feedback: null,
|
||||
};
|
||||
|
||||
const replyResult = await ctx.reply({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, state, customIds, uiState)],
|
||||
withResponse: true,
|
||||
});
|
||||
|
||||
const replyMessage = resolveReplyMessage(replyResult);
|
||||
if (!replyMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId = ctx.user.id;
|
||||
const sessionKey = logEventService.panelSessionKey(ctx.client, guild.id, ownerId);
|
||||
|
||||
const disablePanel = async (): Promise<void> => {
|
||||
await replyMessage
|
||||
.edit({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, state, customIds, uiState, true)],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
|
||||
await logEventService.replacePanelSession(sessionKey, { collector, disable: disablePanel });
|
||||
|
||||
collector.on("collect", async (interaction) => {
|
||||
if (interaction.user.id !== ownerId) {
|
||||
await interaction.reply({
|
||||
content: ctx.ct("responses.notOwner"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!interaction.isButton()) {
|
||||
await interaction.reply({
|
||||
content: ctx.ct("responses.invalidSelection"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === customIds.enableAllButton) {
|
||||
applyAllEnabled(state, true);
|
||||
await logEventService.persistGuildState(ctx.client, guild.id, state);
|
||||
uiState.feedback = ctx.ct("responses.allEnabled");
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, state, customIds, uiState)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === customIds.disableAllButton) {
|
||||
applyAllEnabled(state, false);
|
||||
await logEventService.persistGuildState(ctx.client, guild.id, state);
|
||||
uiState.feedback = ctx.ct("responses.allDisabled");
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, state, customIds, uiState)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === customIds.createChannelsButton) {
|
||||
await interaction.deferUpdate();
|
||||
|
||||
const result = await logEventService.createCategoryChannels(ctx.client, guild, state);
|
||||
if (result.failedCategories.length === 0) {
|
||||
uiState.feedback = ctx.ct("responses.channelsCreated", {
|
||||
created: result.createdCount,
|
||||
reused: result.reusedCount,
|
||||
});
|
||||
} else if (result.createdCount + result.reusedCount > 0) {
|
||||
uiState.feedback = ctx.ct("responses.channelsPartial", {
|
||||
created: result.createdCount,
|
||||
reused: result.reusedCount,
|
||||
failed: result.failedCategories.join(", "),
|
||||
});
|
||||
} else {
|
||||
uiState.feedback = ctx.ct("responses.channelsFailed", {
|
||||
failed: result.failedCategories.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
await replyMessage.edit({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, state, customIds, uiState)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === customIds.previousPageButton) {
|
||||
uiState.pageIndex = Math.max(0, uiState.pageIndex - 1);
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, state, customIds, uiState)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === customIds.nextPageButton) {
|
||||
uiState.pageIndex = Math.min(pageCount() - 1, uiState.pageIndex + 1);
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, state, customIds, uiState)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const eventKey = toggleLookup.get(interaction.customId);
|
||||
if (eventKey) {
|
||||
state[eventKey].enabled = !state[eventKey].enabled;
|
||||
await logEventService.persistGuildState(ctx.client, guild.id, state);
|
||||
uiState.feedback = state[eventKey].enabled
|
||||
? ctx.ct("responses.eventEnabled", { event: eventKey })
|
||||
: ctx.ct("responses.eventDisabled", { event: eventKey });
|
||||
|
||||
await interaction.update({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [buildContainer(ctx, state, customIds, uiState)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
content: ctx.ct("responses.invalidSelection"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "interaction failed");
|
||||
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({
|
||||
content: ctx.t("errors.execution"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
}).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.followUp({
|
||||
content: ctx.t("errors.execution"),
|
||||
flags: [MessageFlags.Ephemeral],
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
collector.on("end", async () => {
|
||||
logEventService.deletePanelSessionIfCollectorMatch(sessionKey, collector);
|
||||
await disablePanel();
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
LogEventConfig,
|
||||
LogEventKey,
|
||||
LogEventRepositoryEntry,
|
||||
LogEventRow,
|
||||
} from "../../types/logs.js";
|
||||
|
||||
export interface LogEventRepository {
|
||||
listByBotGuild(botId: string, guildId: string): Promise<LogEventRow[]>;
|
||||
upsertByBotGuildEvent(botId: string, guildId: string, eventKey: LogEventKey, config: LogEventConfig): Promise<void>;
|
||||
upsertManyByBotGuildEvents(botId: string, guildId: string, entries: readonly LogEventRepositoryEntry[]): Promise<void>;
|
||||
deleteByBotGuild(botId: string, guildId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
PermissionFlagsBits,
|
||||
type Client,
|
||||
type Guild,
|
||||
} from "discord.js";
|
||||
|
||||
import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js";
|
||||
import { createScopedLogger } from "../../core/logging/logger.js";
|
||||
import {
|
||||
LOG_CHANNEL_NAME_BY_CATEGORY,
|
||||
LOG_EVENT_CATEGORY_KEYS,
|
||||
LOG_EVENT_DEFINITIONS,
|
||||
LOG_EVENT_KEYS,
|
||||
} from "./catalog.js";
|
||||
import type { LogEventRepository } from "./repository.js";
|
||||
import type {
|
||||
LogEventCategoryKey,
|
||||
LogChannelProvisionResult,
|
||||
LogEventRepositoryEntry,
|
||||
LogEventStateByKey,
|
||||
LogPanelSession,
|
||||
LogRuntimeDispatchInput,
|
||||
} from "../../types/logs.js";
|
||||
import {
|
||||
cloneLogEventState,
|
||||
createDefaultLogEventState,
|
||||
mergeLogEventRowsIntoState,
|
||||
} from "../../validators/logs.js";
|
||||
|
||||
const logger = createScopedLogger("feature:logs");
|
||||
const LOG_CHANNEL_CATEGORY_NAME = "📁 ➜ Espace Logs";
|
||||
const LOG_CHANNEL_NAME_PREFIX = "📁・";
|
||||
|
||||
const hasSendMethod = (value: unknown): value is { send: (payload: unknown) => Promise<unknown> } => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return "send" in value && typeof (value as { send?: unknown }).send === "function";
|
||||
};
|
||||
|
||||
const hasPermissionsFor = (value: unknown): value is {
|
||||
permissionsFor: (member: unknown) => { has: (permission: unknown) => boolean } | null;
|
||||
} => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return "permissionsFor" in value && typeof (value as { permissionsFor?: unknown }).permissionsFor === "function";
|
||||
};
|
||||
|
||||
const clampField = (value: string, maxLength: number): string => {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, maxLength - 3)}...`;
|
||||
};
|
||||
|
||||
const buildChannelName = (category: LogEventCategoryKey): string => {
|
||||
return `${LOG_CHANNEL_NAME_PREFIX}${LOG_CHANNEL_NAME_BY_CATEGORY[category]}`;
|
||||
};
|
||||
|
||||
export class LogEventService {
|
||||
private readonly stateCache = new Map<string, LogEventStateByKey>();
|
||||
private readonly panelSessions = new ComponentSessionRegistry<LogPanelSession>();
|
||||
|
||||
public constructor(private readonly repository: LogEventRepository) {}
|
||||
|
||||
public resolveBotId(client: Client): string | null {
|
||||
return client.user?.id ?? null;
|
||||
}
|
||||
|
||||
public panelSessionKey(client: Client, guildId: string, userId: string): string {
|
||||
return `${this.resolveBotId(client) ?? "unbound"}:${guildId}:${userId}`;
|
||||
}
|
||||
|
||||
public async replacePanelSession(key: string, session: LogPanelSession): Promise<void> {
|
||||
await this.panelSessions.replace(key, session);
|
||||
}
|
||||
|
||||
public deletePanelSessionIfCollectorMatch(key: string, collector: LogPanelSession["collector"]): void {
|
||||
this.panelSessions.deleteIfCollectorMatch(key, collector);
|
||||
}
|
||||
|
||||
public async loadGuildState(client: Client, guildId: string): Promise<LogEventStateByKey> {
|
||||
const botId = this.resolveBotId(client);
|
||||
if (!botId) {
|
||||
return createDefaultLogEventState();
|
||||
}
|
||||
|
||||
const cacheKey = this.cacheKey(botId, guildId);
|
||||
const cached = this.stateCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cloneLogEventState(cached);
|
||||
}
|
||||
|
||||
const rows = await this.repository.listByBotGuild(botId, guildId);
|
||||
const state = mergeLogEventRowsIntoState(rows);
|
||||
this.stateCache.set(cacheKey, state);
|
||||
|
||||
return cloneLogEventState(state);
|
||||
}
|
||||
|
||||
public async persistGuildState(client: Client, guildId: string, state: LogEventStateByKey): Promise<void> {
|
||||
const botId = this.resolveBotId(client);
|
||||
if (!botId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries: LogEventRepositoryEntry[] = LOG_EVENT_KEYS.map((eventKey) => ({
|
||||
eventKey,
|
||||
config: {
|
||||
enabled: state[eventKey].enabled,
|
||||
channelId: state[eventKey].channelId,
|
||||
},
|
||||
}));
|
||||
|
||||
await this.repository.upsertManyByBotGuildEvents(botId, guildId, entries);
|
||||
this.stateCache.set(this.cacheKey(botId, guildId), cloneLogEventState(state));
|
||||
}
|
||||
|
||||
public async createCategoryChannels(
|
||||
client: Client,
|
||||
guild: Guild,
|
||||
state: LogEventStateByKey,
|
||||
): Promise<LogChannelProvisionResult> {
|
||||
const categoryToChannelId = new Map<LogEventCategoryKey, string>();
|
||||
let createdCount = 0;
|
||||
let reusedCount = 0;
|
||||
const failedCategories: LogEventCategoryKey[] = [];
|
||||
|
||||
const existingChannels = await guild.channels.fetch();
|
||||
let logCategory = existingChannels.find((channel) => {
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return channel.type === ChannelType.GuildCategory && channel.name === LOG_CHANNEL_CATEGORY_NAME;
|
||||
});
|
||||
|
||||
if (!logCategory) {
|
||||
try {
|
||||
logCategory = await guild.channels.create({
|
||||
name: LOG_CHANNEL_CATEGORY_NAME,
|
||||
type: ChannelType.GuildCategory,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ guildId: guild.id, err: error }, "failed to create logs category");
|
||||
return {
|
||||
createdCount,
|
||||
reusedCount,
|
||||
failedCategories: [...LOG_EVENT_CATEGORY_KEYS],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const category of LOG_EVENT_CATEGORY_KEYS) {
|
||||
const expectedName = buildChannelName(category);
|
||||
const legacyName = LOG_CHANNEL_NAME_BY_CATEGORY[category];
|
||||
|
||||
const existingByExpectedName = existingChannels.find((channel) => {
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return channel.type === ChannelType.GuildText && channel.name === expectedName;
|
||||
});
|
||||
|
||||
const existingByLegacyName = existingByExpectedName
|
||||
? null
|
||||
: existingChannels.find((channel) => {
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return channel.type === ChannelType.GuildText && channel.name === legacyName;
|
||||
});
|
||||
|
||||
const existing = existingByExpectedName ?? existingByLegacyName;
|
||||
|
||||
if (existing) {
|
||||
if (existing.name !== expectedName) {
|
||||
await existing.setName(expectedName).catch((error) => {
|
||||
logger.warn({ guildId: guild.id, category, err: error }, "failed to rename logs channel");
|
||||
});
|
||||
}
|
||||
|
||||
if (existing.parentId !== logCategory.id) {
|
||||
await existing.setParent(logCategory.id).catch((error) => {
|
||||
logger.warn({ guildId: guild.id, category, err: error }, "failed to move logs channel to category");
|
||||
});
|
||||
}
|
||||
|
||||
categoryToChannelId.set(category, existing.id);
|
||||
reusedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const permissionOverwrites = [
|
||||
{
|
||||
id: guild.roles.everyone.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel],
|
||||
deny: [PermissionFlagsBits.SendMessages],
|
||||
},
|
||||
];
|
||||
|
||||
if (client.user?.id) {
|
||||
permissionOverwrites.push({
|
||||
id: client.user.id,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.EmbedLinks,
|
||||
],
|
||||
deny: [],
|
||||
});
|
||||
}
|
||||
|
||||
const created = await guild.channels.create({
|
||||
name: expectedName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: logCategory.id,
|
||||
topic: `Logs for ${category} events`,
|
||||
permissionOverwrites,
|
||||
});
|
||||
|
||||
categoryToChannelId.set(category, created.id);
|
||||
createdCount += 1;
|
||||
} catch (error) {
|
||||
logger.warn({ guildId: guild.id, category, err: error }, "failed to create logs channel");
|
||||
failedCategories.push(category);
|
||||
}
|
||||
}
|
||||
|
||||
for (const definition of LOG_EVENT_DEFINITIONS) {
|
||||
const channelId = categoryToChannelId.get(definition.category);
|
||||
if (!channelId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
state[definition.key].channelId = channelId;
|
||||
}
|
||||
|
||||
await this.persistGuildState(client, guild.id, state);
|
||||
|
||||
return {
|
||||
createdCount,
|
||||
reusedCount,
|
||||
failedCategories,
|
||||
};
|
||||
}
|
||||
|
||||
public async dispatchEvent(client: Client, input: LogRuntimeDispatchInput): Promise<void> {
|
||||
const botId = this.resolveBotId(client);
|
||||
if (!botId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = this.cacheKey(botId, input.guildId);
|
||||
let state = this.stateCache.get(cacheKey);
|
||||
if (!state) {
|
||||
const rows = await this.repository.listByBotGuild(botId, input.guildId);
|
||||
state = mergeLogEventRowsIntoState(rows);
|
||||
this.stateCache.set(cacheKey, state);
|
||||
}
|
||||
|
||||
const eventConfig = state[input.eventKey];
|
||||
if (!eventConfig.enabled || !eventConfig.channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guild = client.guilds.cache.get(input.guildId) ?? await client.guilds.fetch(input.guildId).catch(() => null);
|
||||
if (!guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = guild.channels.cache.get(eventConfig.channelId)
|
||||
?? await guild.channels.fetch(eventConfig.channelId).catch(() => null);
|
||||
|
||||
if (!channel || !hasSendMethod(channel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const me = guild.members.me;
|
||||
if (me && hasPermissionsFor(channel)) {
|
||||
const permissions = channel.permissionsFor(me);
|
||||
if (!permissions || !permissions.has(PermissionFlagsBits.ViewChannel) || !permissions.has(PermissionFlagsBits.SendMessages)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(input.color ?? 0x5865f2)
|
||||
.setTitle(`Event: ${input.eventKey}`)
|
||||
.setDescription(clampField(input.summary, 4096))
|
||||
.setTimestamp(new Date());
|
||||
|
||||
if (input.details && input.details.length > 0) {
|
||||
const detailsValue = clampField(input.details.map((line) => `- ${line}`).join("\n"), 1024);
|
||||
if (detailsValue.length > 0) {
|
||||
embed.addFields({
|
||||
name: "Details",
|
||||
value: detailsValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await channel.send({
|
||||
embeds: [embed],
|
||||
allowedMentions: { parse: [] },
|
||||
});
|
||||
}
|
||||
|
||||
public async cleanupGuild(botId: string, guildId: string): Promise<void> {
|
||||
await this.repository.deleteByBotGuild(botId, guildId);
|
||||
this.stateCache.delete(this.cacheKey(botId, guildId));
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
this.stateCache.clear();
|
||||
await this.panelSessions.stopAll("shutdown");
|
||||
}
|
||||
|
||||
private cacheKey(botId: string, guildId: string): string {
|
||||
return `${botId}:${guildId}`;
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createLogsCommandExecute } from "../../features/logs/commandPanel.js";
|
||||
export type { LogEventRepository } from "../../features/logs/repository.js";
|
||||
export { LogEventService } from "../../features/logs/service.js";
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Message } from "discord.js";
|
||||
|
||||
export type LogEventCategoryKey =
|
||||
| "message"
|
||||
| "member"
|
||||
| "interaction"
|
||||
| "channel"
|
||||
| "role"
|
||||
| "thread"
|
||||
| "emoji"
|
||||
| "guild"
|
||||
| "moderation"
|
||||
| "invite";
|
||||
|
||||
export type LogEventKey =
|
||||
| "messageCreate"
|
||||
| "messageDelete"
|
||||
| "messageUpdate"
|
||||
| "messageBulkDelete"
|
||||
| "guildMemberAdd"
|
||||
| "guildMemberRemove"
|
||||
| "guildMemberUpdate"
|
||||
| "interactionCreate"
|
||||
| "channelCreate"
|
||||
| "channelDelete"
|
||||
| "channelUpdate"
|
||||
| "roleCreate"
|
||||
| "roleDelete"
|
||||
| "roleUpdate"
|
||||
| "threadCreate"
|
||||
| "threadDelete"
|
||||
| "threadUpdate"
|
||||
| "emojiCreate"
|
||||
| "emojiDelete"
|
||||
| "emojiUpdate"
|
||||
| "guildUpdate"
|
||||
| "guildUnavailable"
|
||||
| "guildBanAdd"
|
||||
| "guildBanRemove"
|
||||
| "inviteCreate"
|
||||
| "inviteDelete";
|
||||
|
||||
export interface LogEventDefinition {
|
||||
key: LogEventKey;
|
||||
category: LogEventCategoryKey;
|
||||
}
|
||||
|
||||
export interface LogEventConfig {
|
||||
enabled: boolean;
|
||||
channelId: string | null;
|
||||
}
|
||||
|
||||
export type LogEventStateByKey = Record<LogEventKey, LogEventConfig>;
|
||||
|
||||
export interface LogEventRow {
|
||||
event_key: string;
|
||||
enabled: boolean;
|
||||
channel_id: string | null;
|
||||
}
|
||||
|
||||
export interface LogEventRepositoryEntry {
|
||||
eventKey: LogEventKey;
|
||||
config: LogEventConfig;
|
||||
}
|
||||
|
||||
export interface LogRuntimeDispatchInput {
|
||||
eventKey: LogEventKey;
|
||||
guildId: string;
|
||||
summary: string;
|
||||
details?: string[];
|
||||
color?: number;
|
||||
}
|
||||
|
||||
export interface LogPanelCustomIds {
|
||||
enableAllButton: string;
|
||||
disableAllButton: string;
|
||||
createChannelsButton: string;
|
||||
previousPageButton: string;
|
||||
nextPageButton: string;
|
||||
toggleButtonsByEvent: Record<LogEventKey, string>;
|
||||
}
|
||||
|
||||
export interface LogPanelSession {
|
||||
collector: ReturnType<Message["createMessageComponentCollector"]>;
|
||||
disable: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LogChannelProvisionResult {
|
||||
createdCount: number;
|
||||
reusedCount: number;
|
||||
failedCategories: LogEventCategoryKey[];
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user