diff --git a/app/commands/report/automodLog.ts b/app/commands/report/automodLog.ts index ebdc7600..25084811 100644 --- a/app/commands/report/automodLog.ts +++ b/app/commands/report/automodLog.ts @@ -27,7 +27,7 @@ const ActionTypeLabels: Record = { [AutoModerationActionType.BlockMemberInteraction]: "blocked interaction", }; -const logAutomod = ({ +export const logAutomod = ({ guild, user, channelId, diff --git a/app/commands/report/modActionLog.ts b/app/commands/report/modActionLog.ts index 420fc454..c9aaf4d0 100644 --- a/app/commands/report/modActionLog.ts +++ b/app/commands/report/modActionLog.ts @@ -13,7 +13,22 @@ export type ModActionReport = | { guild: Guild; user: User; - actionType: "kick" | "ban"; + actionType: "kick" | "ban" | "unban"; + executor: User | PartialUser | null; + reason: string; + } + | { + guild: Guild; + user: User; + actionType: "timeout"; + executor: User | PartialUser | null; + reason: string; + duration: string; + } + | { + guild: Guild; + user: User; + actionType: "timeout_removed"; executor: User | PartialUser | null; reason: string; } @@ -25,14 +40,9 @@ export type ModActionReport = reason: undefined; }; -export const logModAction = ({ - guild, - user, - actionType, - executor, - reason, -}: ModActionReport) => +export const logModAction = (report: ModActionReport) => Effect.gen(function* () { + const { guild, user, actionType, executor, reason } = report; yield* logEffect( "info", "logModAction", @@ -67,6 +77,9 @@ export const logModAction = ({ const actionLabels: Record = { ban: "was banned", kick: "was kicked", + unban: "was unbanned", + timeout: "was timed out", + timeout_removed: "had timeout removed", left: "left", }; const actionLabel = actionLabels[actionType]; @@ -75,9 +88,11 @@ export const logModAction = ({ : " by unknown"; const reasonText = reason ? ` ${reason}` : " for no reason"; + const durationText = + actionType === "timeout" ? ` for ${report.duration}` : ""; const logContent = truncateMessage( - `<@${user.id}> (${user.username}) ${actionLabel} + `<@${user.id}> (${user.username}) ${actionLabel}${durationText} -# ${executorMention}${reasonText} `, ).trim(); @@ -108,13 +123,17 @@ export const logModAction = ({ ); }).pipe( Effect.withSpan("logModAction", { - attributes: { userId: user.id, guildId: guild.id, actionType }, + attributes: { + userId: report.user.id, + guildId: report.guild.id, + actionType: report.actionType, + }, }), ); /** - * Logs a mod action (kick/ban) to the user's persistent thread. - * Used when Discord events indicate a kick or ban occurred. + * Logs a mod action (kick/ban/unban/timeout) to the user's persistent thread. + * Used when Discord events indicate a moderation action occurred. */ export const logModActionLegacy = (report: ModActionReport): Promise => runEffect(Effect.provide(logModAction(report), DatabaseLayer)); diff --git a/app/commands/report/modActionLogger.ts b/app/commands/report/modActionLogger.ts index 7319174e..a078644e 100644 --- a/app/commands/report/modActionLogger.ts +++ b/app/commands/report/modActionLogger.ts @@ -1,3 +1,4 @@ +import { formatDistanceToNowStrict } from "date-fns"; import { AuditLogEvent, AutoModerationActionType, @@ -11,187 +12,391 @@ import { type PartialUser, type User, } from "discord.js"; +import { Effect } from "effect"; -import { logAutomodLegacy } from "#~/commands/report/automodLog.ts"; -import { log } from "#~/helpers/observability"; +import { logAutomod } from "#~/commands/report/automodLog.ts"; +import { DatabaseLayer } from "#~/Database.ts"; +import { logEffect } from "#~/effects/observability.ts"; +import { runEffect } from "#~/effects/runtime.ts"; -import { logModActionLegacy, type ModActionReport } from "./modActionLog"; +import { logModAction } from "./modActionLog"; // Time window to check audit log for matching entries (5 seconds) const AUDIT_LOG_WINDOW_MS = 5000; -async function handleBanAdd(ban: GuildBan) { - const { guild, user } = ban; - let { reason } = ban; - let executor: User | PartialUser | null = null; +interface AuditLogEntryResult { + executor: User | PartialUser | null; + reason: string | null; +} - log("info", "ModActionLogger", "Ban detected", { - userId: user.id, - guildId: guild.id, - reason, - }); +/** + * Fetches audit log entries with retry logic to handle propagation delay. + * Returns the executor and reason if found. + */ +const fetchAuditLogEntry = ( + guild: Guild, + userId: string, + auditLogType: AuditLogEvent, + findEntry: ( + entries: Awaited>["entries"], + ) => AuditLogEntryResult | undefined, +) => + Effect.gen(function* () { + yield* Effect.sleep("100 millis"); + for (let attempt = 0; attempt < 3; attempt++) { + yield* Effect.sleep("500 millis"); - // Check audit log for who performed the ban - const auditLogs = await guild.fetchAuditLogs({ - type: AuditLogEvent.MemberBanAdd, - limit: 5, - }); + const auditLogs = yield* Effect.promise(() => + guild.fetchAuditLogs({ type: auditLogType, limit: 5 }), + ); - const banEntry = auditLogs.entries.find( - (entry) => - entry.target?.id === user.id && - Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS, + const entry = findEntry(auditLogs.entries); + if (entry?.executor) { + yield* logEffect("debug", "ModActionLogger", `record found`, { + attempt: attempt + 1, + }); + return entry; + } + } + return undefined; + }).pipe( + Effect.withSpan("fetchAuditLogEntry", { + attributes: { userId, guildId: guild.id }, + }), ); - executor = banEntry?.executor ?? null; - reason = banEntry?.reason ?? reason; +const banAddEffect = (ban: GuildBan) => + Effect.gen(function* () { + const { guild, user } = ban; + let { reason } = ban; - // Skip if the bot performed this action (it's already logged elsewhere) - if (executor?.id === guild.client.user?.id) { - log("debug", "ModActionLogger", "Skipping self-ban", { + yield* logEffect("info", "ModActionLogger", "Ban detected", { userId: user.id, guildId: guild.id, + reason, }); - return; - } - - await logModActionLegacy({ - guild, - user, - actionType: "ban", - executor, - reason: reason ?? "", - }); -} -async function fetchAuditLogs( - guild: Guild, - user: User, -): Promise { - // Check audit log to distinguish kick from voluntary leave - const auditLogs = await guild.fetchAuditLogs({ - type: AuditLogEvent.MemberKick, - limit: 5, - }); + const entry = yield* fetchAuditLogEntry( + guild, + user.id, + AuditLogEvent.MemberBanAdd, + (entries) => + entries.find( + (e) => + e.targetId === user.id && + Date.now() - e.createdTimestamp < AUDIT_LOG_WINDOW_MS, + ), + ); - const kickEntry = auditLogs.entries.find( - (entry) => - entry.target?.id === user.id && - Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS, - ); + const executor = entry?.executor ?? null; + reason = entry?.reason ?? reason; - // If no kick entry found, user left voluntarily - if (!kickEntry) { - log( - "debug", - "ModActionLogger", - "No kick entry found, user left voluntarily", - { userId: user.id, guildId: guild.id }, + // Skip if the bot performed this action (it's already logged elsewhere) + if (executor?.id === guild.client.user?.id) { + yield* logEffect("debug", "ModActionLogger", "Skipping self-ban", { + userId: user.id, + guildId: guild.id, + }); + return; + } + + yield* logModAction({ + guild, + user, + actionType: "ban", + executor, + reason: reason ?? "", + }); + }).pipe(Effect.withSpan("handleBanAdd")); + +const banRemoveEffect = (ban: GuildBan) => + Effect.gen(function* () { + const { guild, user } = ban; + + yield* logEffect("info", "ModActionLogger", "Unban detected", { + userId: user.id, + guildId: guild.id, + }); + + const entry = yield* fetchAuditLogEntry( + guild, + user.id, + AuditLogEvent.MemberBanRemove, + (entries) => + entries.find( + (e) => + e.targetId === user.id && + Date.now() - e.createdTimestamp < AUDIT_LOG_WINDOW_MS, + ), + ); + + const executor = entry?.executor ?? null; + const reason = entry?.reason ?? ""; + + // Skip if the bot performed this action (it's already logged elsewhere) + if (executor?.id === guild.client.user?.id) { + yield* logEffect("debug", "ModActionLogger", "Skipping self-unban", { + userId: user.id, + guildId: guild.id, + }); + return; + } + + yield* logModAction({ + guild, + user, + actionType: "unban", + executor, + reason, + }); + }).pipe(Effect.withSpan("handleBanRemove")); + +const fetchKickAuditLog = (guild: Guild, user: User) => + Effect.gen(function* () { + const entry = yield* fetchAuditLogEntry( + guild, + user.id, + AuditLogEvent.MemberKick, + (entries) => + entries.find( + (e) => + e.targetId === user.id && + Date.now() - e.createdTimestamp < AUDIT_LOG_WINDOW_MS, + ), ); + + // If no kick entry found after retries, user left voluntarily + if (!entry) { + yield* logEffect( + "debug", + "ModActionLogger", + "No kick entry found after retries, user left voluntarily", + { userId: user.id, guildId: guild.id }, + ); + return { + actionType: "left" as const, + user, + guild, + executor: undefined, + reason: undefined, + }; + } + + const { executor, reason } = entry; + + if (!executor) { + yield* logEffect( + "warn", + "ModActionLogger", + `No executor found for audit log entry`, + { userId: user.id, guildId: guild.id }, + ); + } + + // Skip if the bot performed this action + if (executor?.id === guild.client.user?.id) { + yield* logEffect("debug", "ModActionLogger", "Skipping self-kick", { + userId: user.id, + guildId: guild.id, + }); + return undefined; + } + return { - actionType: "left", + actionType: "kick" as const, user, guild, - executor: undefined, - reason: undefined, + executor, + reason: reason ?? "", }; - } - const { executor, reason } = kickEntry; + }); - if (!executor) { - log( - "warn", - "ModActionLogger", - `No executor found for audit log entry ${kickEntry.id}`, - ); - } +const memberRemoveEffect = (member: GuildMember | PartialGuildMember) => + Effect.gen(function* () { + const { guild, user } = member; - // Skip if the bot performed this action - // TODO: maybe best to invert — remove manual kick logs in favor of this - if (kickEntry.executor?.id === guild.client.user?.id) { - log("debug", "ModActionLogger", "Skipping self-kick", { + yield* logEffect("info", "ModActionLogger", "Member removal detected", { userId: user.id, guildId: guild.id, }); - return; - } - return { actionType: "kick", user, guild, executor, reason: reason ?? "" }; -} + const auditLogs = yield* fetchKickAuditLog(guild, user); + if (!auditLogs || auditLogs.actionType === "left") { + return; + } -async function handleMemberRemove(member: GuildMember | PartialGuildMember) { - const { guild, user } = member; + const { executor = null, reason = "" } = auditLogs; + yield* logModAction({ + guild, + user, + actionType: "kick", + executor, + reason, + }); + }).pipe(Effect.withSpan("handleMemberRemove")); - log("info", "ModActionLogger", "Member removal detected", { - userId: user.id, - guildId: guild.id, - }); +const automodActionEffect = (execution: AutoModerationActionExecution) => + Effect.gen(function* () { + const { + guild, + userId, + channelId, + messageId, + content, + action, + matchedContent, + matchedKeyword, + autoModerationRule, + } = execution; - const auditLogs = await fetchAuditLogs(guild, user); - if (!auditLogs || auditLogs?.actionType === "left") { - return; - } - - const { executor = null, reason = "" } = auditLogs; - await logModActionLegacy({ - guild, - user, - actionType: "kick", - executor, - reason, - }); -} + // Only log actions that actually affected a message + if (action.type === AutoModerationActionType.Timeout) { + yield* logEffect( + "info", + "Automod", + "Skipping timeout action (no message to log)", + { + userId, + guildId: guild.id, + ruleId: autoModerationRule?.name, + }, + ); + return; + } -async function handleAutomodAction(execution: AutoModerationActionExecution) { - const { - guild, - userId, - channelId, - messageId, - content, - action, - matchedContent, - matchedKeyword, - autoModerationRule, - } = execution; - - // Only log actions that actually affected a message - if (action.type === AutoModerationActionType.Timeout) { - log("info", "Automod", "Skipping timeout action (no message to log)", { + yield* logEffect("info", "Automod", "Automod action executed", { userId, guildId: guild.id, - ruleId: autoModerationRule?.name, + channelId, + messageId, + actionType: action.type, + ruleName: autoModerationRule?.name, + matchedKeyword, }); - return; - } - - log("info", "Automod", "Automod action executed", { - userId, - guildId: guild.id, - channelId, - messageId, - actionType: action.type, - ruleName: autoModerationRule?.name, - matchedKeyword, - }); - // Fallback: message was blocked/deleted or we couldn't fetch it - // Use reportAutomod which doesn't require a Message object - const user = await guild.client.users.fetch(userId); - await logAutomodLegacy({ - guild, - user, - content: content ?? matchedContent ?? "[Content not available]", - channelId: channelId ?? undefined, - messageId: messageId ?? undefined, - ruleName: autoModerationRule?.name ?? "Unknown rule", - matchedKeyword: matchedKeyword ?? matchedContent ?? undefined, - actionType: action.type, - }); -} + const user = yield* Effect.tryPromise({ + try: () => guild.client.users.fetch(userId), + catch: (error) => error, + }); + + yield* logAutomod({ + guild, + user, + content: content ?? matchedContent ?? "[Content not available]", + channelId: channelId ?? undefined, + messageId: messageId ?? undefined, + ruleName: autoModerationRule?.name ?? "Unknown rule", + matchedKeyword: matchedKeyword ?? matchedContent ?? undefined, + actionType: action.type, + }); + }).pipe(Effect.withSpan("handleAutomodAction")); + +const memberUpdateEffect = ( + oldMember: GuildMember | PartialGuildMember, + newMember: GuildMember | PartialGuildMember, +) => + Effect.gen(function* () { + const { guild, user } = newMember; + const oldTimeout = oldMember.communicationDisabledUntilTimestamp; + const newTimeout = newMember.communicationDisabledUntilTimestamp; + + // Determine if this is a timeout applied or removed + const isTimeoutApplied = newTimeout !== null && newTimeout > Date.now(); + const isTimeoutRemoved = + oldTimeout !== null && oldTimeout > Date.now() && newTimeout === null; + + // No timeout change relevant to us + if (!isTimeoutApplied && !isTimeoutRemoved) { + return; + } + + // Capture duration immediately before audit log lookup + const duration = isTimeoutApplied + ? formatDistanceToNowStrict(new Date(newTimeout)) + : undefined; + + yield* logEffect( + "info", + "ModActionLogger", + isTimeoutApplied ? "Timeout detected" : "Timeout removal detected", + { + userId: user.id, + guildId: guild.id, + duration, + }, + ); + + const entry = yield* fetchAuditLogEntry( + guild, + user.id, + AuditLogEvent.MemberUpdate, + (entries) => + entries.find((e) => { + if (e.targetId !== user.id) return false; + if (Date.now() - e.createdTimestamp >= AUDIT_LOG_WINDOW_MS) + return false; + const timeoutChange = e.changes?.find( + (change) => change.key === "communication_disabled_until", + ); + return timeoutChange !== undefined; + }), + ); + + const executor = entry?.executor ?? null; + const reason = entry?.reason ?? ""; + + // Skip if the bot performed this action (it's already logged elsewhere) + if (executor?.id === guild.client.user?.id) { + yield* logEffect("debug", "ModActionLogger", "Skipping self-timeout", { + userId: user.id, + guildId: guild.id, + }); + return; + } + + if (isTimeoutApplied) { + yield* logModAction({ + guild, + user, + actionType: "timeout", + executor, + reason, + duration: duration!, + }); + } else { + yield* logModAction({ + guild, + user, + actionType: "timeout_removed", + executor, + reason, + }); + } + }).pipe(Effect.withSpan("handleMemberUpdate")); + +// Thin async wrappers that execute the Effects +const handleBanAdd = (ban: GuildBan) => + runEffect(banAddEffect(ban).pipe(Effect.provide(DatabaseLayer))); +const handleBanRemove = (ban: GuildBan) => + runEffect(banRemoveEffect(ban).pipe(Effect.provide(DatabaseLayer))); +const handleMemberRemove = (member: GuildMember | PartialGuildMember) => + runEffect(memberRemoveEffect(member).pipe(Effect.provide(DatabaseLayer))); +const handleAutomodAction = (execution: AutoModerationActionExecution) => + runEffect(automodActionEffect(execution).pipe(Effect.provide(DatabaseLayer))); +const handleMemberUpdate = ( + oldMember: GuildMember | PartialGuildMember, + newMember: GuildMember | PartialGuildMember, +) => + runEffect( + memberUpdateEffect(oldMember, newMember).pipe( + Effect.provide(DatabaseLayer), + ), + ); export default async (bot: Client) => { bot.on(Events.GuildBanAdd, handleBanAdd); + bot.on(Events.GuildBanRemove, handleBanRemove); bot.on(Events.GuildMemberRemove, handleMemberRemove); + bot.on(Events.GuildMemberUpdate, handleMemberUpdate); bot.on(Events.AutoModerationActionExecution, handleAutomodAction); }; diff --git a/app/components/DiscordLayout.tsx b/app/components/DiscordLayout.tsx index 58636877..f00319f0 100644 --- a/app/components/DiscordLayout.tsx +++ b/app/components/DiscordLayout.tsx @@ -92,7 +92,7 @@ export function DiscordLayout({ [ { charset: "utf-8", - title: "Remix Notes", + title: "Euno – A Discord moderation bot", viewport: "width=device-width,initial-scale=1", }, ];