From 47d76d10a957a61c92ee729c4e2414dff0c31ce3 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 22 Jan 2026 16:58:40 -0500 Subject: [PATCH 1/5] Add delay/retry to audit log retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There’s a race condition in attributing bans, because the Audit Log Entry object isn’t created immediately as the Ban event fires. We’ll need to wait for a few seconds to make sure that the Audit Log Entry isn’t still pending before we post. We could perhaps change this to post immediately and edit the message once attribution details come back. --- app/commands/report/modActionLogger.ts | 86 +++++++++++++++++++------- 1 file changed, 62 insertions(+), 24 deletions(-) diff --git a/app/commands/report/modActionLogger.ts b/app/commands/report/modActionLogger.ts index 7319174..e97d666 100644 --- a/app/commands/report/modActionLogger.ts +++ b/app/commands/report/modActionLogger.ts @@ -13,6 +13,7 @@ import { } from "discord.js"; import { logAutomodLegacy } from "#~/commands/report/automodLog.ts"; +import { sleep } from "#~/helpers/misc.ts"; import { log } from "#~/helpers/observability"; import { logModActionLegacy, type ModActionReport } from "./modActionLog"; @@ -31,20 +32,33 @@ async function handleBanAdd(ban: GuildBan) { reason, }); - // Check audit log for who performed the ban - const auditLogs = await guild.fetchAuditLogs({ - type: AuditLogEvent.MemberBanAdd, - limit: 5, - }); + // Retry loop with delay - audit log has propagation delay + for (let attempt = 0; attempt < 3; attempt++) { + await sleep(0.5); // Wait for audit log to be written + + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.MemberBanAdd, + limit: 5, + }); - const banEntry = auditLogs.entries.find( - (entry) => - entry.target?.id === user.id && - Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS, - ); + const banEntry = auditLogs.entries.find( + (entry) => + entry.target?.id === user.id && + Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS, + ); - executor = banEntry?.executor ?? null; - reason = banEntry?.reason ?? reason; + if (banEntry?.executor) { + executor = banEntry.executor; + reason = banEntry.reason ?? reason; + break; + } + + log("debug", "ModActionLogger", "Audit log entry not found, retrying", { + userId: user.id, + guildId: guild.id, + attempt: attempt + 1, + }); + } // Skip if the bot performed this action (it's already logged elsewhere) if (executor?.id === guild.client.user?.id) { @@ -68,24 +82,48 @@ 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, - }); + // Retry loop with delay - audit log has propagation delay + let kickEntry; + for (let attempt = 0; attempt < 3; attempt++) { + await sleep(0.5); // Wait for audit log to be written + + // Check audit log to distinguish kick from voluntary leave + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.MemberKick, + limit: 5, + }); - const kickEntry = auditLogs.entries.find( - (entry) => - entry.target?.id === user.id && - Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS, - ); + kickEntry = auditLogs.entries.find( + (entry) => + entry.target?.id === user.id && + Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS, + ); + + if (kickEntry?.executor) { + break; + } + + // Only log retry if we're not on the last attempt + if (attempt < 2) { + log( + "debug", + "ModActionLogger", + "Kick audit log entry not found, retrying", + { + userId: user.id, + guildId: guild.id, + attempt: attempt + 1, + }, + ); + } + } - // If no kick entry found, user left voluntarily + // If no kick entry found after retries, user left voluntarily if (!kickEntry) { log( "debug", "ModActionLogger", - "No kick entry found, user left voluntarily", + "No kick entry found after retries, user left voluntarily", { userId: user.id, guildId: guild.id }, ); return { From 095cccb613077fcdfd4c153cc8bc398f91e871ec Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 22 Jan 2026 22:56:29 -0500 Subject: [PATCH 2/5] Add timeout and unban logging to mod action logger Extends the mod action logging system to capture timeout and unban events. Timeout logs include human-readable duration, and only manual timeout removals (before natural expiry) are logged. Refactors handlers to use Effect-TS with thin async wrappers that execute Effects. Extracts shared audit log fetching logic into a reusable helper. Co-Authored-By: Claude Opus 4.5 --- app/commands/report/automodLog.ts | 2 +- app/commands/report/modActionLog.ts | 43 ++- app/commands/report/modActionLogger.ts | 501 ++++++++++++++++--------- 3 files changed, 366 insertions(+), 180 deletions(-) diff --git a/app/commands/report/automodLog.ts b/app/commands/report/automodLog.ts index ebdc760..2508481 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 420fc45..c9aaf4d 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 e97d666..a078644 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,225 +12,391 @@ import { type PartialUser, type User, } from "discord.js"; +import { Effect } from "effect"; -import { logAutomodLegacy } from "#~/commands/report/automodLog.ts"; -import { sleep } from "#~/helpers/misc.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"); - // Retry loop with delay - audit log has propagation delay - for (let attempt = 0; attempt < 3; attempt++) { - await sleep(0.5); // Wait for audit log to be written + const auditLogs = yield* Effect.promise(() => + guild.fetchAuditLogs({ type: auditLogType, limit: 5 }), + ); - const auditLogs = await guild.fetchAuditLogs({ - type: AuditLogEvent.MemberBanAdd, - limit: 5, + 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 }, + }), + ); + +const banAddEffect = (ban: GuildBan) => + Effect.gen(function* () { + const { guild, user } = ban; + let { reason } = ban; + + yield* logEffect("info", "ModActionLogger", "Ban detected", { + userId: user.id, + guildId: guild.id, + reason, }); - const banEntry = auditLogs.entries.find( - (entry) => - entry.target?.id === user.id && - Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS, + 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, + ), ); - if (banEntry?.executor) { - executor = banEntry.executor; - reason = banEntry.reason ?? reason; - break; + const executor = entry?.executor ?? null; + reason = entry?.reason ?? 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-ban", { + userId: user.id, + guildId: guild.id, + }); + return; } - log("debug", "ModActionLogger", "Audit log entry not found, retrying", { - userId: user.id, - guildId: guild.id, - attempt: attempt + 1, + yield* logModAction({ + guild, + user, + actionType: "ban", + executor, + reason: reason ?? "", }); - } + }).pipe(Effect.withSpan("handleBanAdd")); + +const banRemoveEffect = (ban: GuildBan) => + Effect.gen(function* () { + const { guild, user } = 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", "Unban detected", { userId: user.id, guildId: guild.id, }); - return; - } - - await logModActionLegacy({ - guild, - user, - actionType: "ban", - executor, - reason: reason ?? "", - }); -} -async function fetchAuditLogs( - guild: Guild, - user: User, -): Promise { - // Retry loop with delay - audit log has propagation delay - let kickEntry; - for (let attempt = 0; attempt < 3; attempt++) { - await sleep(0.5); // Wait for audit log to be written - - // 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.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")); - kickEntry = auditLogs.entries.find( - (entry) => - entry.target?.id === user.id && - Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS, +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 (kickEntry?.executor) { - break; + // 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, + }; } - // Only log retry if we're not on the last attempt - if (attempt < 2) { - log( - "debug", + const { executor, reason } = entry; + + if (!executor) { + yield* logEffect( + "warn", "ModActionLogger", - "Kick audit log entry not found, retrying", - { - userId: user.id, - guildId: guild.id, - attempt: attempt + 1, - }, + `No executor found for audit log entry`, + { userId: user.id, guildId: guild.id }, ); } - } - // If no kick entry found after retries, user left voluntarily - if (!kickEntry) { - log( - "debug", - "ModActionLogger", - "No kick entry found after retries, user left voluntarily", - { 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); }; From 678674ce2d00854c2aea423e5b3936e053cc98fd Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 23 Jan 2026 01:11:26 -0500 Subject: [PATCH 3/5] Fix title --- app/root.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/root.tsx b/app/root.tsx index a49b431..b4f678e 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -15,7 +15,7 @@ import { getUser } from "./models/session.server"; export const meta: MetaFunction = () => [ { charset: "utf-8", - title: "Remix Notes", + title: "Euno – A Discord moderation bot", viewport: "width=device-width,initial-scale=1", }, ]; From 8e2fb0e3bc28c1cd2c4cec7eba9e0a680a5b8b7f Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 23 Jan 2026 01:11:55 -0500 Subject: [PATCH 4/5] Fix invite link --- app/components/DiscordLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/DiscordLayout.tsx b/app/components/DiscordLayout.tsx index 5863687..eb3edef 100644 --- a/app/components/DiscordLayout.tsx +++ b/app/components/DiscordLayout.tsx @@ -92,7 +92,7 @@ export function DiscordLayout({ Date: Fri, 23 Jan 2026 01:33:17 -0500 Subject: [PATCH 5/5] Fix invite for real --- app/components/DiscordLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/DiscordLayout.tsx b/app/components/DiscordLayout.tsx index eb3edef..f00319f 100644 --- a/app/components/DiscordLayout.tsx +++ b/app/components/DiscordLayout.tsx @@ -92,7 +92,7 @@ export function DiscordLayout({