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,
|
PostgresLeaderCoordinator,
|
||||||
} from "../core/runtime/leaderCoordinator.js";
|
} from "../core/runtime/leaderCoordinator.js";
|
||||||
import { PostgresMemberMessageStore } from "../database/stores/memberMessageStore.js";
|
import { PostgresMemberMessageStore } from "../database/stores/memberMessageStore.js";
|
||||||
|
import { PostgresLogEventStore } from "../database/stores/logEventStore.js";
|
||||||
import { PostgresPresenceStore } from "../database/stores/presenceStore.js";
|
import { PostgresPresenceStore } from "../database/stores/presenceStore.js";
|
||||||
import { DatabaseLifecycle } from "../database/dbLifecycle.js";
|
import { DatabaseLifecycle } from "../database/dbLifecycle.js";
|
||||||
import { registerEvents } from "../events/index.js";
|
import { registerEvents } from "../events/index.js";
|
||||||
import { createPrefixHandler } from "../handlers/prefixHandler.js";
|
import { createPrefixHandler } from "../handlers/prefixHandler.js";
|
||||||
import { createSlashHandler } from "../handlers/slashHandler.js";
|
import { createSlashHandler } from "../handlers/slashHandler.js";
|
||||||
import { I18nService } from "../i18n/index.js";
|
import { I18nService } from "../i18n/index.js";
|
||||||
|
import {
|
||||||
|
LogEventService,
|
||||||
|
} from "../modules/logs/index.js";
|
||||||
import {
|
import {
|
||||||
MemberMessageService,
|
MemberMessageService,
|
||||||
} from "../modules/memberMessages/index.js";
|
} from "../modules/memberMessages/index.js";
|
||||||
@@ -111,11 +115,13 @@ export const bootstrap = async (): Promise<void> => {
|
|||||||
|
|
||||||
const presenceStore = new PostgresPresenceStore(pool);
|
const presenceStore = new PostgresPresenceStore(pool);
|
||||||
const memberMessageStore = new PostgresMemberMessageStore(pool);
|
const memberMessageStore = new PostgresMemberMessageStore(pool);
|
||||||
|
const logEventStore = new PostgresLogEventStore(pool);
|
||||||
|
|
||||||
const dbLifecycle = new DatabaseLifecycle(
|
const dbLifecycle = new DatabaseLifecycle(
|
||||||
[
|
[
|
||||||
{ name: "presenceStore", init: () => presenceStore.init() },
|
{ name: "presenceStore", init: () => presenceStore.init() },
|
||||||
{ name: "memberMessageStore", init: () => memberMessageStore.init() },
|
{ name: "memberMessageStore", init: () => memberMessageStore.init() },
|
||||||
|
{ name: "logEventStore", init: () => logEventStore.init() },
|
||||||
],
|
],
|
||||||
async () => {
|
async () => {
|
||||||
await pool.end();
|
await pool.end();
|
||||||
@@ -125,6 +131,7 @@ export const bootstrap = async (): Promise<void> => {
|
|||||||
const services: AppFeatureServices = {
|
const services: AppFeatureServices = {
|
||||||
presenceService: new PresenceService(presenceStore, env.PRESENCE_STREAM_URL),
|
presenceService: new PresenceService(presenceStore, env.PRESENCE_STREAM_URL),
|
||||||
memberMessageService: new MemberMessageService(memberMessageStore),
|
memberMessageService: new MemberMessageService(memberMessageStore),
|
||||||
|
logEventService: new LogEventService(logEventStore),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cooldownStore = env.STATE_BACKEND === "redis" && redis
|
const cooldownStore = env.STATE_BACKEND === "redis" && redis
|
||||||
@@ -179,6 +186,10 @@ export const bootstrap = async (): Promise<void> => {
|
|||||||
client.destroy();
|
client.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await services.logEventService.shutdown().catch((error) => {
|
||||||
|
log.error({ err: error }, "logs service shutdown failed");
|
||||||
|
});
|
||||||
|
|
||||||
await services.presenceService.shutdown().catch((error) => {
|
await services.presenceService.shutdown().catch((error) => {
|
||||||
log.error({ err: error }, "presence service shutdown failed");
|
log.error({ err: error }, "presence service shutdown failed");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
MemberMessageService,
|
MemberMessageService,
|
||||||
} from "../modules/memberMessages/index.js";
|
} from "../modules/memberMessages/index.js";
|
||||||
|
import type {
|
||||||
|
LogEventService,
|
||||||
|
} from "../modules/logs/index.js";
|
||||||
import type {
|
import type {
|
||||||
PresenceService,
|
PresenceService,
|
||||||
} from "../modules/presence/index.js";
|
} from "../modules/presence/index.js";
|
||||||
@@ -8,4 +11,5 @@ import type {
|
|||||||
export interface AppFeatureServices {
|
export interface AppFeatureServices {
|
||||||
presenceService: PresenceService;
|
presenceService: PresenceService;
|
||||||
memberMessageService: MemberMessageService;
|
memberMessageService: MemberMessageService;
|
||||||
|
logEventService: LogEventService;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { helpCommand } from "./help.js";
|
import { helpCommand } from "./help.js";
|
||||||
import { kissCommand } from "./kiss.js";
|
import { kissCommand } from "./kiss.js";
|
||||||
import { createGoodbyeCommand } from "./goodbye.js";
|
import { createGoodbyeCommand } from "./goodbye.js";
|
||||||
|
import { createLogsCommand } from "./logs.js";
|
||||||
import { createPresenceCommand } from "./presence.js";
|
import { createPresenceCommand } from "./presence.js";
|
||||||
import { pingCommand } from "./ping.js";
|
import { pingCommand } from "./ping.js";
|
||||||
import { createWelcomeCommand } from "./welcome.js";
|
import { createWelcomeCommand } from "./welcome.js";
|
||||||
@@ -22,5 +23,6 @@ export const createCommandList = (services: AppFeatureServices, i18n: I18nServic
|
|||||||
createWelcomeCommand(services.memberMessageService, i18n),
|
createWelcomeCommand(services.memberMessageService, i18n),
|
||||||
createGoodbyeCommand(services.memberMessageService, i18n),
|
createGoodbyeCommand(services.memberMessageService, i18n),
|
||||||
createPresenceCommand(services.presenceService),
|
createPresenceCommand(services.presenceService),
|
||||||
|
createLogsCommand(services.logEventService),
|
||||||
helpCommand,
|
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 { Events, type Client } from "discord.js";
|
||||||
|
|
||||||
import { createScopedLogger } from "../core/logging/logger.js";
|
import { createScopedLogger } from "../core/logging/logger.js";
|
||||||
|
import type { LogEventService } from "../modules/logs/index.js";
|
||||||
import type { MemberMessageService } from "../modules/memberMessages/index.js";
|
import type { MemberMessageService } from "../modules/memberMessages/index.js";
|
||||||
|
|
||||||
const log = createScopedLogger("event:guildDelete");
|
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) => {
|
client.on(Events.GuildDelete, (guild) => {
|
||||||
log.info({ guildId: guild.id, guildName: guild.name }, "left guild");
|
log.info({ guildId: guild.id, guildName: guild.name }, "left guild");
|
||||||
|
|
||||||
const botId = memberMessageService.resolveBotId(client);
|
const botId = logEventService.resolveBotId(client);
|
||||||
if (!botId) {
|
if (!botId) {
|
||||||
return;
|
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");
|
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 { registerGuildMemberAdd } from "./guildMemberAdd.js";
|
||||||
import { registerGuildMemberRemove } from "./guildMemberRemove.js";
|
import { registerGuildMemberRemove } from "./guildMemberRemove.js";
|
||||||
import { registerInteractionCreate } from "./interactionCreate.js";
|
import { registerInteractionCreate } from "./interactionCreate.js";
|
||||||
|
import { registerLogRuntimeEvents } from "./logsRuntime.js";
|
||||||
import { registerMessageCreate } from "./messageCreate.js";
|
import { registerMessageCreate } from "./messageCreate.js";
|
||||||
import { registerClientReady } from "./ready.js";
|
import { registerClientReady } from "./ready.js";
|
||||||
|
|
||||||
@@ -25,12 +26,13 @@ export const registerEvents = (
|
|||||||
): void => {
|
): void => {
|
||||||
registerMessageCreate(client, handlers.onPrefixMessage);
|
registerMessageCreate(client, handlers.onPrefixMessage);
|
||||||
registerInteractionCreate(client, handlers.onSlashInteraction);
|
registerInteractionCreate(client, handlers.onSlashInteraction);
|
||||||
|
registerLogRuntimeEvents(client, services.logEventService);
|
||||||
|
|
||||||
registerGuildMemberAdd(client, i18n, services.memberMessageService);
|
registerGuildMemberAdd(client, i18n, services.memberMessageService);
|
||||||
registerGuildMemberRemove(client, i18n, services.memberMessageService);
|
registerGuildMemberRemove(client, i18n, services.memberMessageService);
|
||||||
|
|
||||||
registerGuildCreate(client);
|
registerGuildCreate(client);
|
||||||
registerGuildDelete(client, services.memberMessageService);
|
registerGuildDelete(client, services.memberMessageService, services.logEventService);
|
||||||
|
|
||||||
registerClientReady(client, registry, i18n, services.presenceService, leaderCoordinator);
|
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": {
|
"welcome": {
|
||||||
"name": "welcome",
|
"name": "welcome",
|
||||||
"description": "Configure welcome messages with an interactive panel.",
|
"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": {
|
"welcome": {
|
||||||
"name": "bienvenida",
|
"name": "bienvenida",
|
||||||
"description": "Configurar mensajes de bienvenida con un panel interactivo.",
|
"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": {
|
"welcome": {
|
||||||
"name": "bienvenue",
|
"name": "bienvenue",
|
||||||
"description": "Configurer les messages de bienvenue avec un panneau interactif.",
|
"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