From e5c4de7ef44004255fa52568716cb3c73f6557ec Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 23 Jan 2026 12:54:29 -0500 Subject: [PATCH 01/10] Add Discord SDK Effect helpers, consolidate error handling - Create app/effects/discordSdk.ts with Effect-wrapped Discord.js helpers: fetchGuild, fetchChannel, fetchMember, fetchMessage, sendMessage, etc. - Add fetchSettingsEffect to guilds.server.ts for Effect-based settings fetch - Update escalate, report, and resolver code to use new SDK helpers - Consolidate error types in effects/errors.ts (rename discordError -> cause) - Fix missing Layer imports and service layer provision in handlers Co-Authored-By: Claude Opus 4.5 --- app/commands/escalate/directActions.ts | 110 +++++--------- app/commands/escalate/escalate.ts | 150 ++++++++------------ app/commands/escalate/escalationResolver.ts | 143 ++++++------------- app/commands/escalate/expedite.ts | 15 +- app/commands/escalate/handlers.ts | 51 +++++-- app/commands/escalate/index.ts | 15 +- app/commands/escalate/service.ts | 13 +- app/commands/escalate/vote.ts | 21 +-- app/commands/report/automodLog.ts | 41 ++---- app/commands/report/constructLog.ts | 19 +-- app/commands/report/modActionLog.ts | 41 ++---- app/commands/report/userLog.ts | 12 +- app/discord/escalationResolver.ts | 12 +- app/effects/discordSdk.ts | 146 +++++++++++++++++++ app/effects/errors.ts | 26 ++-- app/helpers/discord.ts | 5 +- app/helpers/errors.ts | 5 - app/models/guilds.server.ts | 25 ++++ app/models/stripe.server.ts | 19 +-- app/models/userThreads.ts | 19 +-- 20 files changed, 425 insertions(+), 463 deletions(-) create mode 100644 app/effects/discordSdk.ts delete mode 100644 app/helpers/errors.ts diff --git a/app/commands/escalate/directActions.ts b/app/commands/escalate/directActions.ts index 97285e40..c3e4e22c 100644 --- a/app/commands/escalate/directActions.ts +++ b/app/commands/escalate/directActions.ts @@ -4,11 +4,12 @@ import { } from "discord.js"; import { Effect } from "effect"; +import { fetchMember } from "#~/effects/discordSdk"; import { DiscordApiError, NotAuthorizedError } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; import { hasModRole } from "#~/helpers/discord"; import { applyRestriction, ban, kick, timeout } from "#~/models/discord.server"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { deleteAllReportedForUserEffect } from "#~/models/reportedMessages"; export interface DeleteMessagesResult { @@ -29,11 +30,7 @@ export const deleteMessagesEffect = ( const guildId = interaction.guildId!; // Check permissions - const member = yield* Effect.tryPromise({ - try: () => interaction.guild!.members.fetch(interaction.user.id), - catch: (error) => - new DiscordApiError({ operation: "fetchMember", discordError: error }), - }); + const member = yield* fetchMember(interaction.guild!, interaction.user.id); if (!member.permissions.has(PermissionsBitField.Flags.ManageMessages)) { return yield* Effect.fail( @@ -85,14 +82,9 @@ export const kickUserEffect = (interaction: MessageComponentInteraction) => const guildId = interaction.guildId!; // Get settings and check permissions - const { moderator: modRoleId } = yield* Effect.tryPromise({ - try: () => fetchSettings(guildId, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId } = yield* fetchSettingsEffect(guildId, [ + SETTINGS.moderator, + ]); if (!hasModRole(interaction, modRoleId)) { return yield* Effect.fail( @@ -105,20 +97,16 @@ export const kickUserEffect = (interaction: MessageComponentInteraction) => } // Fetch the reported member - const reportedMember = yield* Effect.tryPromise({ - try: () => interaction.guild!.members.fetch(reportedUserId), - catch: (error) => - new DiscordApiError({ - operation: "fetchReportedMember", - discordError: error, - }), - }); + const reportedMember = yield* fetchMember( + interaction.guild!, + reportedUserId, + ); // Execute kick yield* Effect.tryPromise({ try: () => kick(reportedMember, "single moderator decision"), catch: (error) => - new DiscordApiError({ operation: "kick", discordError: error }), + new DiscordApiError({ operation: "kick", cause: error }), }); yield* logEffect("info", "DirectActions", "Kicked user", { @@ -147,14 +135,9 @@ export const banUserEffect = (interaction: MessageComponentInteraction) => const guildId = interaction.guildId!; // Get settings and check permissions - const { moderator: modRoleId } = yield* Effect.tryPromise({ - try: () => fetchSettings(guildId, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId } = yield* fetchSettingsEffect(guildId, [ + SETTINGS.moderator, + ]); if (!hasModRole(interaction, modRoleId)) { return yield* Effect.fail( @@ -167,20 +150,15 @@ export const banUserEffect = (interaction: MessageComponentInteraction) => } // Fetch the reported member - const reportedMember = yield* Effect.tryPromise({ - try: () => interaction.guild!.members.fetch(reportedUserId), - catch: (error) => - new DiscordApiError({ - operation: "fetchReportedMember", - discordError: error, - }), - }); + const reportedMember = yield* fetchMember( + interaction.guild!, + reportedUserId, + ); // Execute ban yield* Effect.tryPromise({ try: () => ban(reportedMember, "single moderator decision"), - catch: (error) => - new DiscordApiError({ operation: "ban", discordError: error }), + catch: (error) => new DiscordApiError({ operation: "ban", cause: error }), }); yield* logEffect("info", "DirectActions", "Banned user", { @@ -209,14 +187,9 @@ export const restrictUserEffect = (interaction: MessageComponentInteraction) => const guildId = interaction.guildId!; // Get settings and check permissions - const { moderator: modRoleId } = yield* Effect.tryPromise({ - try: () => fetchSettings(guildId, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId } = yield* fetchSettingsEffect(guildId, [ + SETTINGS.moderator, + ]); if (!hasModRole(interaction, modRoleId)) { return yield* Effect.fail( @@ -229,14 +202,10 @@ export const restrictUserEffect = (interaction: MessageComponentInteraction) => } // Fetch the reported member - const reportedMember = yield* Effect.tryPromise({ - try: () => interaction.guild!.members.fetch(reportedUserId), - catch: (error) => - new DiscordApiError({ - operation: "fetchReportedMember", - discordError: error, - }), - }); + const reportedMember = yield* fetchMember( + interaction.guild!, + reportedUserId, + ); // Execute restriction yield* Effect.tryPromise({ @@ -244,7 +213,7 @@ export const restrictUserEffect = (interaction: MessageComponentInteraction) => catch: (error) => new DiscordApiError({ operation: "applyRestriction", - discordError: error, + cause: error, }), }); @@ -274,14 +243,9 @@ export const timeoutUserEffect = (interaction: MessageComponentInteraction) => const guildId = interaction.guildId!; // Get settings and check permissions - const { moderator: modRoleId } = yield* Effect.tryPromise({ - try: () => fetchSettings(guildId, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId } = yield* fetchSettingsEffect(guildId, [ + SETTINGS.moderator, + ]); if (!hasModRole(interaction, modRoleId)) { return yield* Effect.fail( @@ -294,20 +258,16 @@ export const timeoutUserEffect = (interaction: MessageComponentInteraction) => } // Fetch the reported member - const reportedMember = yield* Effect.tryPromise({ - try: () => interaction.guild!.members.fetch(reportedUserId), - catch: (error) => - new DiscordApiError({ - operation: "fetchReportedMember", - discordError: error, - }), - }); + const reportedMember = yield* fetchMember( + interaction.guild!, + reportedUserId, + ); // Execute timeout yield* Effect.tryPromise({ try: () => timeout(reportedMember, "single moderator decision"), catch: (error) => - new DiscordApiError({ operation: "timeout", discordError: error }), + new DiscordApiError({ operation: "timeout", cause: error }), }); yield* logEffect("info", "DirectActions", "Timed out user", { diff --git a/app/commands/escalate/escalate.ts b/app/commands/escalate/escalate.ts index 4989022d..22dc8688 100644 --- a/app/commands/escalate/escalate.ts +++ b/app/commands/escalate/escalate.ts @@ -2,6 +2,13 @@ import type { MessageComponentInteraction, ThreadChannel } from "discord.js"; import { Effect } from "effect"; import { client } from "#~/discord/client.server"; +import { + editMessage, + fetchChannel, + fetchGuild, + fetchMessage, + sendMessage, +} from "#~/effects/discordSdk"; import { DiscordApiError } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; import { calculateScheduledFor } from "#~/helpers/escalationVotes"; @@ -9,7 +16,7 @@ import type { Features } from "#~/helpers/featuresFlags"; import { votingStrategies, type Resolution } from "#~/helpers/modResponse"; import { DEFAULT_QUORUM, - fetchSettings, + fetchSettingsEffect, SETTINGS, } from "#~/models/guilds.server"; @@ -38,38 +45,24 @@ export const createEscalationEffect = ( const features: Features[] = []; // Get settings - const { moderator: modRoleId, restricted } = yield* Effect.tryPromise({ - try: () => - fetchSettings(guildId, [SETTINGS.moderator, SETTINGS.restricted]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId, restricted } = yield* fetchSettingsEffect( + guildId, + [SETTINGS.moderator, SETTINGS.restricted], + ); if (restricted) { features.push("restrict"); } // Fetch guild and channel - const guild = yield* Effect.tryPromise({ - try: () => client.guilds.fetch(guildId), - catch: (error) => - new DiscordApiError({ operation: "fetchGuild", discordError: error }), - }); - - const channel = yield* Effect.tryPromise({ - try: () => guild.channels.fetch(interaction.channelId), - catch: (error) => - new DiscordApiError({ operation: "fetchChannel", discordError: error }), - }); + const guild = yield* fetchGuild(client, guildId); + const channel = yield* fetchChannel(guild, interaction.channelId); if (!channel || !("send" in channel)) { return yield* Effect.fail( new DiscordApiError({ operation: "validateChannel", - discordError: new Error("Invalid channel - cannot send messages"), + cause: new Error("Invalid channel - cannot send messages"), }), ); } @@ -104,28 +97,20 @@ export const createEscalationEffect = ( }; // Send vote message to get its ID - const voteMessage = yield* Effect.tryPromise({ - try: () => - (channel as ThreadChannel).send({ - content: buildVoteMessageContent( - modRoleId, - votingStrategy, - tempEscalation, - emptyTally, - ), - components: buildVoteButtons( - features, - votingStrategy, - tempEscalation, - emptyTally, - false, - ), - }), - catch: (error) => - new DiscordApiError({ - operation: "sendVoteMessage", - discordError: error, - }), + const voteMessage = yield* sendMessage(channel as ThreadChannel, { + content: buildVoteMessageContent( + modRoleId, + votingStrategy, + tempEscalation, + emptyTally, + ), + components: buildVoteButtons( + features, + votingStrategy, + tempEscalation, + emptyTally, + false, + ), }); // Create escalation record with the correct message ID @@ -178,32 +163,21 @@ export const upgradeToMajorityEffect = ( const features: Features[] = []; // Get settings - const { moderator: modRoleId, restricted } = yield* Effect.tryPromise({ - try: () => - fetchSettings(guildId, [SETTINGS.moderator, SETTINGS.restricted]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId, restricted } = yield* fetchSettingsEffect( + guildId, + [SETTINGS.moderator, SETTINGS.restricted], + ); if (restricted) { features.push("restrict"); } // Fetch guild and channel - const guild = yield* Effect.tryPromise({ - try: () => client.guilds.fetch(guildId), - catch: (error) => - new DiscordApiError({ operation: "fetchGuild", discordError: error }), - }); - - const channel = yield* Effect.tryPromise({ - try: () => guild.channels.fetch(interaction.channelId), - catch: (error) => - new DiscordApiError({ operation: "fetchChannel", discordError: error }), - }) as Effect.Effect; + const guild = yield* fetchGuild(client, guildId); + const channel = (yield* fetchChannel( + guild, + interaction.channelId, + )) as ThreadChannel; const votingStrategy = votingStrategies.majority; @@ -211,14 +185,10 @@ export const upgradeToMajorityEffect = ( const escalation = yield* escalationService.getEscalation(escalationId); // Fetch the vote message - const voteMessage = yield* Effect.tryPromise({ - try: () => channel.messages.fetch(escalation.vote_message_id), - catch: (error) => - new DiscordApiError({ - operation: "fetchVoteMessage", - discordError: error, - }), - }); + const voteMessage = yield* fetchMessage( + channel, + escalation.vote_message_id, + ); // Get current votes to display const votes = yield* escalationService.getVotesForEscalation(escalationId); @@ -240,28 +210,20 @@ export const upgradeToMajorityEffect = ( }; // Update the vote message - yield* Effect.tryPromise({ - try: () => - voteMessage.edit({ - content: buildVoteMessageContent( - modRoleId, - votingStrategy, - updatedEscalation, - tally, - ), - components: buildVoteButtons( - features, - votingStrategy, - escalation, - tally, - false, // Never in early resolution state when upgrading to majority - ), - }), - catch: (error) => - new DiscordApiError({ - operation: "editVoteMessage", - discordError: error, - }), + yield* editMessage(voteMessage, { + content: buildVoteMessageContent( + modRoleId, + votingStrategy, + updatedEscalation, + tally, + ), + components: buildVoteButtons( + features, + votingStrategy, + escalation, + tally, + false, // Never in early resolution state when upgrading to majority + ), }); // Update the escalation's voting strategy and scheduled_for diff --git a/app/commands/escalate/escalationResolver.ts b/app/commands/escalate/escalationResolver.ts index fb0876ed..cfc44bf8 100644 --- a/app/commands/escalate/escalationResolver.ts +++ b/app/commands/escalate/escalationResolver.ts @@ -8,14 +8,22 @@ import { } from "discord.js"; import { Effect } from "effect"; -import { DiscordApiError } from "#~/effects/errors"; +import { + editMessage, + fetchChannelFromClient, + fetchGuild, + fetchMemberOrNull, + fetchMessage, + fetchUserOrNull, + replyAndForwardSafe, +} from "#~/effects/discordSdk"; import { logEffect } from "#~/effects/observability"; import { humanReadableResolutions, resolutions, type Resolution, } from "#~/helpers/modResponse"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { EscalationService, type Escalation } from "./service"; import { tallyVotes } from "./voting"; @@ -111,46 +119,27 @@ export const processEscalationEffect = ( ); // Fetch Discord resources - const { modLog } = yield* Effect.tryPromise({ - try: () => fetchSettings(escalation.guild_id, [SETTINGS.modLog]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); - - const guild = yield* Effect.tryPromise({ - try: () => client.guilds.fetch(escalation.guild_id), - catch: (error) => - new DiscordApiError({ operation: "fetchGuild", discordError: error }), - }); - - const channel = yield* Effect.tryPromise({ - try: () => - client.channels.fetch(escalation.thread_id) as Promise, - catch: (error) => - new DiscordApiError({ operation: "fetchChannel", discordError: error }), - }); - - const reportedUser = yield* Effect.tryPromise({ - try: () => client.users.fetch(escalation.reported_user_id), - catch: () => null, - }).pipe(Effect.catchAll(() => Effect.succeed(null))); - - const voteMessage = yield* Effect.tryPromise({ - try: () => channel.messages.fetch(escalation.vote_message_id), - catch: (error) => - new DiscordApiError({ - operation: "fetchVoteMessage", - discordError: error, - }), - }); - - const reportedMember = yield* Effect.tryPromise({ - try: () => guild.members.fetch(escalation.reported_user_id), - catch: () => null, - }).pipe(Effect.catchAll(() => Effect.succeed(null))); + const { modLog } = yield* fetchSettingsEffect(escalation.guild_id, [ + SETTINGS.modLog, + ]); + + const guild = yield* fetchGuild(client, escalation.guild_id); + const channel = yield* fetchChannelFromClient( + client, + escalation.thread_id, + ); + const reportedUser = yield* fetchUserOrNull( + client, + escalation.reported_user_id, + ); + const voteMessage = yield* fetchMessage( + channel, + escalation.vote_message_id, + ); + const reportedMember = yield* fetchMemberOrNull( + guild, + escalation.reported_user_id, + ); // Calculate timing info const now = Math.floor(Date.now() / 1000); @@ -185,37 +174,16 @@ export const processEscalationEffect = ( resolutions.track, ); - yield* Effect.tryPromise({ - try: () => - voteMessage.edit({ components: getDisabledButtons(voteMessage) }), - catch: (error) => - new DiscordApiError({ - operation: "editVoteMessage", - discordError: error, - }), + yield* editMessage(voteMessage, { + components: getDisabledButtons(voteMessage), }); // Try to reply and forward - but don't fail if it doesn't work - yield* Effect.tryPromise({ - try: async () => { - const notice = await voteMessage.reply({ - content: `${noticeText}\n${timing} (${reason})`, - }); - await notice.forward(modLog); - }, - catch: () => null, - }).pipe( - Effect.catchAll((error) => - logEffect( - "warn", - "EscalationResolver", - "Could not update vote message", - { - ...logBag, - error, - }, - ), - ), + yield* replyAndForwardSafe( + voteMessage, + `${noticeText}\n${timing} (${reason})`, + modLog, + "EscalationResolver", ); return { resolution: resolutions.track, userGone: true }; @@ -228,37 +196,16 @@ export const processEscalationEffect = ( yield* escalationService.resolveEscalation(escalation.id, resolution); // Update Discord message - yield* Effect.tryPromise({ - try: () => - voteMessage.edit({ components: getDisabledButtons(voteMessage) }), - catch: (error) => - new DiscordApiError({ - operation: "editVoteMessage", - discordError: error, - }), + yield* editMessage(voteMessage, { + components: getDisabledButtons(voteMessage), }); // Try to reply and forward - but don't fail if it doesn't work - yield* Effect.tryPromise({ - try: async () => { - const notice = await voteMessage.reply({ - content: `${noticeText}\n${timing}`, - }); - await notice.forward(modLog); - }, - catch: () => null, - }).pipe( - Effect.catchAll((error) => - logEffect( - "warn", - "EscalationResolver", - "Could not update vote message", - { - ...logBag, - error, - }, - ), - ), + yield* replyAndForwardSafe( + voteMessage, + `${noticeText}\n${timing}`, + modLog, + "EscalationResolver", ); yield* logEffect( diff --git a/app/commands/escalate/expedite.ts b/app/commands/escalate/expedite.ts index 602e28eb..b279984b 100644 --- a/app/commands/escalate/expedite.ts +++ b/app/commands/escalate/expedite.ts @@ -10,7 +10,7 @@ import { import { logEffect } from "#~/effects/observability"; import { hasModRole } from "#~/helpers/discord"; import type { Resolution } from "#~/helpers/modResponse"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { EscalationService, type Escalation } from "./service"; import { tallyVotes, type VoteTally } from "./voting"; @@ -34,14 +34,9 @@ export const expediteEffect = (interaction: MessageComponentInteraction) => const expeditedBy = interaction.user.id; // Get settings and check mod role - const { moderator: modRoleId } = yield* Effect.tryPromise({ - try: () => fetchSettings(guildId, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId } = yield* fetchSettingsEffect(guildId, [ + SETTINGS.moderator, + ]); if (!hasModRole(interaction, modRoleId)) { return yield* Effect.fail( @@ -86,7 +81,7 @@ export const expediteEffect = (interaction: MessageComponentInteraction) => const guild = yield* Effect.tryPromise({ try: () => interaction.guild!.fetch(), catch: (error) => - new DiscordApiError({ operation: "fetchGuild", discordError: error }), + new DiscordApiError({ operation: "fetchGuild", cause: error }), }); // Execute the resolution diff --git a/app/commands/escalate/handlers.ts b/app/commands/escalate/handlers.ts index a56bf6e1..a3b38358 100644 --- a/app/commands/escalate/handlers.ts +++ b/app/commands/escalate/handlers.ts @@ -5,7 +5,7 @@ import { MessageFlags, type MessageComponentInteraction, } from "discord.js"; -import { Effect } from "effect"; +import { Effect, Layer } from "effect"; import { DatabaseLayer } from "#~/Database.ts"; import { runEffectExit } from "#~/effects/runtime.ts"; @@ -15,7 +15,7 @@ import { } from "#~/helpers/modResponse"; import { log } from "#~/helpers/observability"; -import { getFailure, runEscalationEffect } from "."; +import { getFailure } from "."; import { banUserEffect, deleteMessagesEffect, @@ -25,6 +25,7 @@ import { } from "./directActions"; import { createEscalationEffect, upgradeToMajorityEffect } from "./escalate"; import { expediteEffect } from "./expedite"; +import { EscalationServiceLive } from "./service"; import { buildConfirmedMessageContent, buildVoteButtons, @@ -61,7 +62,9 @@ const deleteMessages = async (interaction: MessageComponentInteraction) => { const kickUser = async (interaction: MessageComponentInteraction) => { const reportedUserId = interaction.customId.split("|")[1]; - const exit = await runEffectExit(kickUserEffect(interaction)); + const exit = await runEffectExit( + kickUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Error kicking user", { error }); @@ -88,7 +91,9 @@ const kickUser = async (interaction: MessageComponentInteraction) => { const banUser = async (interaction: MessageComponentInteraction) => { const reportedUserId = interaction.customId.split("|")[1]; - const exit = await runEffectExit(banUserEffect(interaction)); + const exit = await runEffectExit( + banUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Error banning user", { error }); @@ -115,7 +120,9 @@ const banUser = async (interaction: MessageComponentInteraction) => { const restrictUser = async (interaction: MessageComponentInteraction) => { const reportedUserId = interaction.customId.split("|")[1]; - const exit = await runEffectExit(restrictUserEffect(interaction)); + const exit = await runEffectExit( + restrictUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Error restricting user", { error }); @@ -144,7 +151,9 @@ const restrictUser = async (interaction: MessageComponentInteraction) => { const timeoutUser = async (interaction: MessageComponentInteraction) => { const reportedUserId = interaction.customId.split("|")[1]; - const exit = await runEffectExit(timeoutUserEffect(interaction)); + const exit = await runEffectExit( + timeoutUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Error timing out user", { error }); @@ -174,7 +183,11 @@ const vote = (resolution: Resolution) => async function handleVote( interaction: MessageComponentInteraction, ): Promise { - const exit = await runEscalationEffect(voteEffect(resolution)(interaction)); + const exit = await runEffectExit( + voteEffect(resolution)(interaction).pipe( + Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + ), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Error voting", { error, resolution }); @@ -186,7 +199,7 @@ const vote = (resolution: Resolution) => }); return; } - if (error?._tag === "EscalationNotFoundError") { + if (error?._tag === "NotFoundError") { await interaction.reply({ content: "Escalation not found.", flags: [MessageFlags.Ephemeral], @@ -257,7 +270,11 @@ const expedite = async ( ): Promise => { await interaction.deferUpdate(); - const exit = await runEscalationEffect(expediteEffect(interaction)); + const exit = await runEffectExit( + expediteEffect(interaction).pipe( + Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + ), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Expedite failed", { error }); @@ -269,7 +286,7 @@ const expedite = async ( }); return; } - if (error?._tag === "EscalationNotFoundError") { + if (error?._tag === "NotFoundError") { await interaction.followUp({ content: "Escalation not found.", flags: [MessageFlags.Ephemeral], @@ -325,8 +342,10 @@ const escalate = async (interaction: MessageComponentInteraction) => { if (Number(level) === 0) { // Create new escalation - const exit = await runEscalationEffect( - createEscalationEffect(interaction, reportedUserId, escalationId), + const exit = await runEffectExit( + createEscalationEffect(interaction, reportedUserId, escalationId).pipe( + Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + ), ); if (exit._tag === "Failure") { @@ -343,8 +362,10 @@ const escalate = async (interaction: MessageComponentInteraction) => { await interaction.editReply("Escalation started"); } else { // Upgrade to majority voting - const exit = await runEscalationEffect( - upgradeToMajorityEffect(interaction, escalationId), + const exit = await runEffectExit( + upgradeToMajorityEffect(interaction, escalationId).pipe( + Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + ), ); if (exit._tag === "Failure") { @@ -353,7 +374,7 @@ const escalate = async (interaction: MessageComponentInteraction) => { error, }); - if (error?._tag === "EscalationNotFoundError") { + if (error?._tag === "NotFoundError") { await interaction.editReply({ content: "Failed to re-escalate, couldn't find escalation", }); diff --git a/app/commands/escalate/index.ts b/app/commands/escalate/index.ts index 2873056b..332b8d08 100644 --- a/app/commands/escalate/index.ts +++ b/app/commands/escalate/index.ts @@ -1,17 +1,4 @@ -import { Cause, Effect } from "effect"; - -import { runEffectExit } from "#~/effects/runtime"; - -import { EscalationServiceLive, type EscalationService } from "./service"; - -/** - * Run an Effect that requires EscalationService. - * Provides the service and all its dependencies automatically. - * Returns an Exit for error handling in handlers. - */ -export const runEscalationEffect = ( - effect: Effect.Effect, -) => runEffectExit(Effect.provide(effect, EscalationServiceLive)); +import { Cause } from "effect"; /** * Extract the first failure from a Cause for type-safe error matching. diff --git a/app/commands/escalate/service.ts b/app/commands/escalate/service.ts index 771bd1e5..c2448ad1 100644 --- a/app/commands/escalate/service.ts +++ b/app/commands/escalate/service.ts @@ -6,7 +6,7 @@ import { DatabaseLayer, DatabaseService, type SqlError } from "#~/Database"; import type { DB } from "#~/db"; import { AlreadyResolvedError, - EscalationNotFoundError, + NotFoundError, ResolutionExecutionError, } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; @@ -47,7 +47,7 @@ export interface IEscalationService { */ readonly getEscalation: ( id: string, - ) => Effect.Effect; + ) => Effect.Effect; /** * Record a vote for an escalation. @@ -70,10 +70,7 @@ export interface IEscalationService { readonly resolveEscalation: ( id: string, resolution: Resolution, - ) => Effect.Effect< - void, - EscalationNotFoundError | AlreadyResolvedError | SqlError - >; + ) => Effect.Effect; /** * Update the voting strategy for an escalation. @@ -168,7 +165,7 @@ export const EscalationServiceLive = Layer.effect( if (!escalation) { return yield* Effect.fail( - new EscalationNotFoundError({ escalationId: id }), + new NotFoundError({ id, resource: "escalation" }), ); } @@ -258,7 +255,7 @@ export const EscalationServiceLive = Layer.effect( if (!escalation) { return yield* Effect.fail( - new EscalationNotFoundError({ escalationId: id }), + new NotFoundError({ id, resource: "escalation" }), ); } diff --git a/app/commands/escalate/vote.ts b/app/commands/escalate/vote.ts index 57830672..897da526 100644 --- a/app/commands/escalate/vote.ts +++ b/app/commands/escalate/vote.ts @@ -1,17 +1,13 @@ import type { MessageComponentInteraction } from "discord.js"; import { Effect } from "effect"; -import { - AlreadyResolvedError, - DiscordApiError, - NotAuthorizedError, -} from "#~/effects/errors"; +import { AlreadyResolvedError, NotAuthorizedError } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; import { hasModRole } from "#~/helpers/discord"; import { calculateScheduledFor, parseFlags } from "#~/helpers/escalationVotes"; import type { Features } from "#~/helpers/featuresFlags"; import type { Resolution, VotingStrategy } from "#~/helpers/modResponse"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { EscalationService, type Escalation } from "./service"; import { @@ -43,15 +39,10 @@ export const voteEffect = const features: Features[] = []; // Get settings - const { moderator: modRoleId, restricted } = yield* Effect.tryPromise({ - try: () => - fetchSettings(guildId, [SETTINGS.moderator, SETTINGS.restricted]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId, restricted } = yield* fetchSettingsEffect( + guildId, + [SETTINGS.moderator, SETTINGS.restricted], + ); if (restricted) { features.push("restrict"); diff --git a/app/commands/report/automodLog.ts b/app/commands/report/automodLog.ts index 25084811..25e9efd4 100644 --- a/app/commands/report/automodLog.ts +++ b/app/commands/report/automodLog.ts @@ -2,11 +2,11 @@ import { AutoModerationActionType, type Guild, type User } from "discord.js"; import { Effect } from "effect"; import { DatabaseLayer } from "#~/Database"; -import { DiscordApiError } from "#~/effects/errors"; +import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk"; import { logEffect } from "#~/effects/observability"; import { runEffect } from "#~/effects/runtime"; import { truncateMessage } from "#~/helpers/string"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { getOrCreateUserThread } from "#~/models/userThreads.ts"; export interface AutomodReport { @@ -52,14 +52,10 @@ export const logAutomod = ({ const thread = yield* getOrCreateUserThread(guild, user); // Get mod log for forwarding - const { modLog, moderator } = yield* Effect.tryPromise({ - try: () => fetchSettings(guild.id, [SETTINGS.modLog, SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { modLog, moderator } = yield* fetchSettingsEffect(guild.id, [ + SETTINGS.modLog, + SETTINGS.moderator, + ]); // Construct the log message const channelMention = channelId ? `<#${channelId}>` : "Unknown channel"; @@ -71,30 +67,13 @@ export const logAutomod = ({ ).trim(); // Send log to thread - const logMessage = yield* Effect.tryPromise({ - try: () => - thread.send({ - content: logContent, - allowedMentions: { roles: [moderator] }, - }), - catch: (error) => - new DiscordApiError({ - operation: "sendLogMessage", - discordError: error, - }), + const logMessage = yield* sendMessage(thread, { + content: logContent, + allowedMentions: { roles: [moderator] }, }); // Forward to mod log (non-critical) - yield* Effect.tryPromise({ - try: () => logMessage.forward(modLog), - catch: (error) => error, - }).pipe( - Effect.catchAll((error) => - logEffect("error", "logAutomod", "failed to forward to modLog", { - error: String(error), - }), - ), - ); + yield* forwardMessageSafe(logMessage, modLog, "logAutomod"); }).pipe( Effect.withSpan("logAutomod", { attributes: { userId: user.id, guildId: guild.id, ruleName }, diff --git a/app/commands/report/constructLog.ts b/app/commands/report/constructLog.ts index 8d39d7fa..72b9c00a 100644 --- a/app/commands/report/constructLog.ts +++ b/app/commands/report/constructLog.ts @@ -9,7 +9,7 @@ import { Effect } from "effect"; import { DiscordApiError } from "#~/effects/errors"; import { constructDiscordLink } from "#~/helpers/discord"; import { truncateMessage } from "#~/helpers/string"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { ReportReasons, type Report } from "#~/models/reportedMessages"; const ReadableReasons: Record = { @@ -38,7 +38,7 @@ export const constructLog = ({ return yield* Effect.fail( new DiscordApiError({ operation: "constructLog", - discordError: new Error( + cause: new Error( "Something went wrong when trying to retrieve last report", ), }), @@ -46,22 +46,17 @@ export const constructLog = ({ } const { message } = lastReport; const { author } = message; - const { moderator } = yield* Effect.tryPromise({ - try: () => - fetchSettings(lastReport.message.guild!.id, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator } = yield* fetchSettingsEffect( + lastReport.message.guild.id, + [SETTINGS.moderator], + ); // This should never be possible but we gotta satisfy types if (!moderator) { return yield* Effect.fail( new DiscordApiError({ operation: "constructLog", - discordError: new Error("No role configured to be used as moderator"), + cause: new Error("No role configured to be used as moderator"), }), ); } diff --git a/app/commands/report/modActionLog.ts b/app/commands/report/modActionLog.ts index c9aaf4d0..50d4b6b0 100644 --- a/app/commands/report/modActionLog.ts +++ b/app/commands/report/modActionLog.ts @@ -2,11 +2,11 @@ import { type Guild, type PartialUser, type User } from "discord.js"; import { Effect } from "effect"; import { DatabaseLayer } from "#~/Database"; -import { DiscordApiError } from "#~/effects/errors"; +import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk"; import { logEffect } from "#~/effects/observability"; import { runEffect } from "#~/effects/runtime"; import { truncateMessage } from "#~/helpers/string"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { getOrCreateUserThread } from "#~/models/userThreads.ts"; export type ModActionReport = @@ -64,14 +64,10 @@ export const logModAction = (report: ModActionReport) => const thread = yield* getOrCreateUserThread(guild, user); // Get mod log for forwarding - const { modLog, moderator } = yield* Effect.tryPromise({ - try: () => fetchSettings(guild.id, [SETTINGS.modLog, SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { modLog, moderator } = yield* fetchSettingsEffect(guild.id, [ + SETTINGS.modLog, + SETTINGS.moderator, + ]); // Construct the log message const actionLabels: Record = { @@ -97,30 +93,13 @@ export const logModAction = (report: ModActionReport) => ).trim(); // Send log to thread - const logMessage = yield* Effect.tryPromise({ - try: () => - thread.send({ - content: logContent, - allowedMentions: { roles: [moderator] }, - }), - catch: (error) => - new DiscordApiError({ - operation: "sendLogMessage", - discordError: error, - }), + const logMessage = yield* sendMessage(thread, { + content: logContent, + allowedMentions: { roles: [moderator] }, }); // Forward to mod log (non-critical) - yield* Effect.tryPromise({ - try: () => logMessage.forward(modLog), - catch: (error) => error, - }).pipe( - Effect.catchAll((error) => - logEffect("error", "logModAction", "failed to forward to modLog", { - error: String(error), - }), - ), - ); + yield* forwardMessageSafe(logMessage, modLog, "logModAction"); }).pipe( Effect.withSpan("logModAction", { attributes: { diff --git a/app/commands/report/userLog.ts b/app/commands/report/userLog.ts index 88b54d0a..8f37fd60 100644 --- a/app/commands/report/userLog.ts +++ b/app/commands/report/userLog.ts @@ -80,7 +80,7 @@ export function logUserMessage({ return yield* Effect.fail( new DiscordApiError({ operation: "logUserMessage", - discordError: new Error("Tried to log a message without a guild"), + cause: new Error("Tried to log a message without a guild"), }), ); } @@ -93,7 +93,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "fetchSettings", - discordError: error, + cause: error, }), }), constructLog({ @@ -138,7 +138,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "logUserMessage existing", - discordError: error, + cause: error, }), }); @@ -200,7 +200,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "sendLogMessages", - discordError: error, + cause: error, }), }); @@ -231,7 +231,7 @@ export function logUserMessage({ yield* Effect.tryPromise({ try: () => logMessage.forward(modLog), catch: (error) => - new DiscordApiError({ operation: "forwardLog", discordError: error }), + new DiscordApiError({ operation: "forwardLog", cause: error }), }).pipe( Effect.catchAll((error) => logEffect("error", "logUserMessage", "failed to forward to modLog", { @@ -261,7 +261,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "logUserMessage", - discordError: error, + cause: error, }), }).pipe( Effect.catchAll((error) => diff --git a/app/discord/escalationResolver.ts b/app/discord/escalationResolver.ts index d94dca2a..797b4d3b 100644 --- a/app/discord/escalationResolver.ts +++ b/app/discord/escalationResolver.ts @@ -1,7 +1,11 @@ import type { Client } from "discord.js"; +import { Effect, Layer } from "effect"; import { checkPendingEscalationsEffect } from "#~/commands/escalate/escalationResolver"; -import { getFailure, runEscalationEffect } from "#~/commands/escalate/index"; +import { getFailure } from "#~/commands/escalate/index"; +import { EscalationServiceLive } from "#~/commands/escalate/service.ts"; +import { DatabaseLayer } from "#~/Database.ts"; +import { runEffectExit } from "#~/effects/runtime.ts"; import { log } from "#~/helpers/observability"; import { scheduleTask } from "#~/helpers/schedule"; @@ -11,7 +15,11 @@ const ONE_MINUTE = 60 * 1000; * Check pending escalations using Effect-based resolver. */ async function checkPendingEscalations(client: Client): Promise { - const exit = await runEscalationEffect(checkPendingEscalationsEffect(client)); + const exit = await runEffectExit( + checkPendingEscalationsEffect(client).pipe( + Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + ), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); diff --git a/app/effects/discordSdk.ts b/app/effects/discordSdk.ts new file mode 100644 index 00000000..caa1396b --- /dev/null +++ b/app/effects/discordSdk.ts @@ -0,0 +1,146 @@ +/** + * Discord SDK - Effect-TS wrappers for common Discord.js operations. + * + * These helpers provide consistent error handling and reduce boilerplate + * when calling Discord.js APIs from Effect-based code. + */ +import type { + Client, + Guild, + GuildMember, + GuildTextBasedChannel, + Message, + ThreadChannel, + User, +} from "discord.js"; +import { Effect } from "effect"; + +import { DiscordApiError } from "#~/effects/errors"; +import { logEffect } from "#~/effects/observability"; + +export const fetchGuild = (client: Client, guildId: string) => + Effect.tryPromise({ + try: () => client.guilds.fetch(guildId), + catch: (error) => + new DiscordApiError({ operation: "fetchGuild", cause: error }), + }); + +export const fetchChannel = (guild: Guild, channelId: string) => + Effect.tryPromise({ + try: () => guild.channels.fetch(channelId), + catch: (error) => + new DiscordApiError({ operation: "fetchChannel", cause: error }), + }); + +export const fetchChannelFromClient = ( + client: Client, + channelId: string, +) => + Effect.tryPromise({ + try: () => client.channels.fetch(channelId) as Promise, + catch: (error) => + new DiscordApiError({ operation: "fetchChannel", cause: error }), + }); + +export const fetchMember = (guild: Guild, userId: string) => + Effect.tryPromise({ + try: () => guild.members.fetch(userId), + catch: (error) => + new DiscordApiError({ operation: "fetchMember", cause: error }), + }); + +export const fetchMemberOrNull = ( + guild: Guild, + userId: string, +): Effect.Effect => + Effect.tryPromise({ + try: () => guild.members.fetch(userId), + catch: () => null, + }).pipe(Effect.catchAll(() => Effect.succeed(null))); + +export const fetchUser = (client: Client, userId: string) => + Effect.tryPromise({ + try: () => client.users.fetch(userId), + catch: (error) => + new DiscordApiError({ operation: "fetchUser", cause: error }), + }); + +export const fetchUserOrNull = ( + client: Client, + userId: string, +): Effect.Effect => + Effect.tryPromise({ + try: () => client.users.fetch(userId), + catch: () => null, + }).pipe(Effect.catchAll(() => Effect.succeed(null))); + +export const fetchMessage = ( + channel: GuildTextBasedChannel | ThreadChannel, + messageId: string, +) => + Effect.tryPromise({ + try: () => channel.messages.fetch(messageId), + catch: (error) => + new DiscordApiError({ operation: "fetchMessage", cause: error }), + }); + +export const sendMessage = ( + channel: GuildTextBasedChannel | ThreadChannel, + options: Parameters[0], +) => + Effect.tryPromise({ + try: () => channel.send(options), + catch: (error) => + new DiscordApiError({ operation: "sendMessage", cause: error }), + }); + +export const editMessage = ( + message: Message, + options: Parameters[0], +) => + Effect.tryPromise({ + try: () => message.edit(options), + catch: (error) => + new DiscordApiError({ operation: "editMessage", cause: error }), + }); + +export const forwardMessageSafe = ( + message: Message, + targetChannelId: string, + serviceName: string, +) => + Effect.tryPromise({ + try: () => message.forward(targetChannelId), + catch: (error) => error, + }).pipe( + Effect.catchAll((error) => + logEffect("error", serviceName, "failed to forward to modLog", { + error: String(error), + messageId: message.id, + targetChannelId, + }), + ), + ); + +export const replyAndForwardSafe = ( + message: Message, + content: string, + forwardToChannelId: string, + serviceName: string, +) => + Effect.tryPromise({ + try: async () => { + const reply = await message.reply({ content }); + await reply.forward(forwardToChannelId); + return reply; + }, + catch: () => null, + }).pipe( + Effect.catchAll((error) => + logEffect("warn", serviceName, "Could not reply and forward message", { + error, + messageId: message.id, + forwardToChannelId, + }), + ), + ); diff --git a/app/effects/errors.ts b/app/effects/errors.ts index 4efd0a99..db80d780 100644 --- a/app/effects/errors.ts +++ b/app/effects/errors.ts @@ -3,17 +3,22 @@ import { Data } from "effect"; // Re-export SQL errors from @effect/sql for convenience export { SqlError, ResultLengthMismatch } from "@effect/sql/SqlError"; -// Tagged error types for discriminated unions -// Each error has a _tag property for pattern matching with Effect.catchTag +export class NotAuthorizedError extends Data.TaggedError("NotAuthorizedError")<{ + operation: string; + userId: string; + requiredRole?: string; +}> {} +// TODO: refine export class DiscordApiError extends Data.TaggedError("DiscordApiError")<{ operation: string; - discordError: unknown; + cause: unknown; }> {} +// TODO: refine export class StripeApiError extends Data.TaggedError("StripeApiError")<{ operation: string; - stripeError: unknown; + cause: unknown; }> {} export class NotFoundError extends Data.TaggedError("NotFoundError")<{ @@ -32,13 +37,6 @@ export class ConfigError extends Data.TaggedError("ConfigError")<{ }> {} // Escalation-specific errors - -export class EscalationNotFoundError extends Data.TaggedError( - "EscalationNotFoundError", -)<{ - escalationId: string; -}> {} - export class AlreadyResolvedError extends Data.TaggedError( "AlreadyResolvedError", )<{ @@ -46,12 +44,6 @@ export class AlreadyResolvedError extends Data.TaggedError( resolvedAt: string; }> {} -export class NotAuthorizedError extends Data.TaggedError("NotAuthorizedError")<{ - operation: string; - userId: string; - requiredRole?: string; -}> {} - export class NoLeaderError extends Data.TaggedError("NoLeaderError")<{ escalationId: string; reason: "no_votes" | "tied"; diff --git a/app/helpers/discord.ts b/app/helpers/discord.ts index ff03109c..efefaefa 100644 --- a/app/helpers/discord.ts +++ b/app/helpers/discord.ts @@ -22,6 +22,7 @@ import { type Effect } from "effect"; import { partition } from "lodash-es"; import prettyBytes from "pretty-bytes"; +import { NotFoundError } from "#~/effects/errors.ts"; import { getChars, getWords, @@ -29,8 +30,6 @@ import { } from "#~/helpers/messageParsing"; import { trackPerformance } from "#~/helpers/observability"; -import { NotFoundError } from "./errors"; - const staffRoles = ["mvp", "moderator", "admin", "admins"]; const helpfulRoles = ["mvp", "star helper"]; @@ -270,7 +269,7 @@ export async function getMessageStats(msg: Message | PartialMessage) { .fetch() .catch((_) => ({ content: undefined })); if (!content) { - throw new NotFoundError("message", "getMessageStats"); + throw new NotFoundError({ resource: "message", id: msg.id }); } const blocks = parseMarkdownBlocks(content); diff --git a/app/helpers/errors.ts b/app/helpers/errors.ts deleted file mode 100644 index 688b4f7e..00000000 --- a/app/helpers/errors.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class NotFoundError extends Error { - constructor(resource: string, message?: string) { - super(message, { cause: `'${resource}' not found` }); - } -} diff --git a/app/models/guilds.server.ts b/app/models/guilds.server.ts index 26403351..4ea9dd02 100644 --- a/app/models/guilds.server.ts +++ b/app/models/guilds.server.ts @@ -1,3 +1,6 @@ +import { Effect } from "effect"; + +import { DatabaseService } from "#~/Database.ts"; import db, { SqliteError, type DB } from "#~/db.server"; import { log, trackPerformance } from "#~/helpers/observability"; @@ -114,3 +117,25 @@ export const fetchSettings = async ( ) as [T, string][]; return Object.fromEntries(result) as Pick; }; + +export const fetchSettingsEffect = ( + guildId: string, + keys: T[], +) => + Effect.gen(function* () { + const db = yield* DatabaseService; + const result = Object.entries( + yield* db + .selectFrom("guilds") + // @ts-expect-error This is broken because of a migration from knex and + // old/bad use of jsonb for storing settings. The type is guaranteed here + // not by the codegen + .select((eb) => + keys.map((k) => eb.ref("settings", "->").key(k).as(k)), + ) + .where("id", "=", guildId), + ) as [T, string][]; + return Object.fromEntries( + result.map(([k, v]) => [k, JSON.parse(v)]), + ) as Pick; + }); diff --git a/app/models/stripe.server.ts b/app/models/stripe.server.ts index a1db74b7..0df4874c 100644 --- a/app/models/stripe.server.ts +++ b/app/models/stripe.server.ts @@ -1,7 +1,7 @@ import Stripe from "stripe"; +import { NotFoundError } from "#~/effects/errors.ts"; import { stripeSecretKey, stripeWebhookSecret } from "#~/helpers/env.server.js"; -import { NotFoundError } from "#~/helpers/errors.js"; import { log, trackPerformance } from "#~/helpers/observability"; import Sentry from "#~/helpers/sentry.server"; @@ -32,20 +32,13 @@ export const StripeService = { const successUrl = `${baseUrl}/payment/success?session_id={CHECKOUT_SESSION_ID}&guild_id=${guildId}`; const settingsUrl = `${baseUrl}/app/${guildId}/settings`; let priceId = ""; - try { - const prices = await stripe.prices.list({ lookup_keys: [variant] }); - const price = prices.data.at(0); - if (!price) { - throw new NotFoundError( - "price", - "failed to find a price while upgrading", - ); - } - priceId = price.id; - } catch (e) { + const prices = await stripe.prices.list({ lookup_keys: [variant] }); + const price = prices.data.at(0); + if (!price) { log("error", "Stripe", "Failed to load pricing data"); - throw e; + throw new NotFoundError({ resource: "Price", id: priceId }); } + priceId = price.id; try { const session = await stripe.checkout.sessions.create({ diff --git a/app/models/userThreads.ts b/app/models/userThreads.ts index 7e919262..6f1c67c9 100644 --- a/app/models/userThreads.ts +++ b/app/models/userThreads.ts @@ -103,7 +103,7 @@ const makeUserThread = (channel: TextChannel, user: User) => Effect.tryPromise({ try: () => channel.threads.create({ name: `${user.username} logs` }), catch: (error) => - new DiscordApiError({ operation: "createThread", discordError: error }), + new DiscordApiError({ operation: "createThread", cause: error }), }); export const getOrCreateUserThread = (guild: Guild, user: User) => @@ -140,26 +140,20 @@ export const getOrCreateUserThread = (guild: Guild, user: User) => const { modLog: modLogId } = yield* Effect.tryPromise({ try: () => fetchSettings(guild.id, [SETTINGS.modLog]), catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), + new DiscordApiError({ operation: "fetchSettings", cause: error }), }); const modLog = yield* Effect.tryPromise({ try: () => guild.channels.fetch(modLogId), catch: (error) => - new DiscordApiError({ - operation: "fetchModLogChannel", - discordError: error, - }), + new DiscordApiError({ operation: "fetchModLogChannel", cause: error }), }); if (!modLog || modLog.type !== ChannelType.GuildText) { return yield* Effect.fail( new DiscordApiError({ operation: "getOrCreateUserThread", - discordError: new Error("Invalid mod log channel"), + cause: new Error("Invalid mod log channel"), }), ); } @@ -170,10 +164,7 @@ export const getOrCreateUserThread = (guild: Guild, user: User) => yield* Effect.tryPromise({ try: () => escalationControls(user.id, thread), catch: (error) => - new DiscordApiError({ - operation: "escalationControls", - discordError: error, - }), + new DiscordApiError({ operation: "escalationControls", cause: error }), }); // Store or update the thread reference From 82e309443a088ac7b80fd966f9a97552a056ed3e Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 23 Jan 2026 15:44:14 -0500 Subject: [PATCH 02/10] Revert "Revert "Implement listener registry pattern for HMR support (#250)"" This reverts commit 706effa6ee33382d662029e9893ccedbf9db02fc. --- app/discord/client.server.ts | 10 +++ app/discord/escalationResolver.ts | 11 ++- app/discord/gateway.ts | 139 +++++++++++++++++------------- app/discord/hmrRegistry.ts | 61 +++++++++++++ app/helpers/schedule.ts | 20 +++-- 5 files changed, 175 insertions(+), 66 deletions(-) create mode 100644 app/discord/hmrRegistry.ts diff --git a/app/discord/client.server.ts b/app/discord/client.server.ts index 63b13978..0b4f481f 100644 --- a/app/discord/client.server.ts +++ b/app/discord/client.server.ts @@ -3,6 +3,16 @@ import { ActivityType, Client, GatewayIntentBits, Partials } from "discord.js"; import { discordToken } from "#~/helpers/env.server"; import { log, trackPerformance } from "#~/helpers/observability"; +// All HMR-related global state declarations +declare global { + var __discordListenerRegistry: + | { event: string; listener: (...args: unknown[]) => void }[] + | undefined; + var __discordScheduledTasks: ReturnType[] | undefined; + var __discordClientReady: boolean | undefined; + var __discordLoginStarted: boolean | undefined; +} + export const client = new Client({ intents: [ GatewayIntentBits.Guilds, diff --git a/app/discord/escalationResolver.ts b/app/discord/escalationResolver.ts index 797b4d3b..6324d926 100644 --- a/app/discord/escalationResolver.ts +++ b/app/discord/escalationResolver.ts @@ -5,6 +5,7 @@ import { checkPendingEscalationsEffect } from "#~/commands/escalate/escalationRe import { getFailure } from "#~/commands/escalate/index"; import { EscalationServiceLive } from "#~/commands/escalate/service.ts"; import { DatabaseLayer } from "#~/Database.ts"; +import { registerScheduledTask } from "#~/discord/hmrRegistry"; import { runEffectExit } from "#~/effects/runtime.ts"; import { log } from "#~/helpers/observability"; import { scheduleTask } from "#~/helpers/schedule"; @@ -41,7 +42,15 @@ export function startEscalationResolver(client: Client): void { {}, ); - scheduleTask("EscalationResolver", ONE_MINUTE * 15, () => { + const handle = scheduleTask("EscalationResolver", ONE_MINUTE * 15, () => { void checkPendingEscalations(client); }); + + // Register timers for HMR cleanup + if (handle) { + registerScheduledTask(handle.initialTimer); + // The interval timer is created inside the setTimeout, so we need to + // register it when it's available. Since clearScheduledTasks clears both + // timeouts and intervals, the initial timer registration will handle cleanup. + } } diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index 4d602904..21baf809 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -1,11 +1,19 @@ import { Events, InteractionType } from "discord.js"; -import modActionLogger from "#~/commands/report/modActionLogger.ts"; +import modActionLogger from "#~/commands/report/modActionLogger"; import { startActivityTracking } from "#~/discord/activityTracker"; import automod from "#~/discord/automod"; import { client, login } from "#~/discord/client.server"; import { deployCommands, matchCommand } from "#~/discord/deployCommands.server"; import { startEscalationResolver } from "#~/discord/escalationResolver"; +import { + clearScheduledTasks, + isClientReady, + isLoginStarted, + markLoginStarted, + removeAllListeners, + setClientReady, +} from "#~/discord/hmrRegistry"; import onboardGuild from "#~/discord/onboardGuild"; import { startReactjiChanneler } from "#~/discord/reactjiChanneler"; import { runEffect } from "#~/effects/runtime"; @@ -23,27 +31,83 @@ import Sentry from "#~/helpers/sentry.server"; import { startHoneypotTracking } from "./honeypotTracker"; -// Track if gateway is already initialized to prevent duplicate logins during HMR -// Use globalThis so the flag persists across module reloads -declare global { - var __discordGatewayInitialized: boolean | undefined; +/** + * Initialize all sub-modules that depend on the client being ready. + */ +async function initializeSubModules() { + await trackPerformance( + "gateway_startup", + async () => { + log("info", "Gateway", "Initializing sub-modules", { + guildCount: client.guilds.cache.size, + userCount: client.users.cache.size, + }); + + await Promise.all([ + onboardGuild(client), + automod(client), + modActionLogger(client), + deployCommands(client), + startActivityTracking(client), + startHoneypotTracking(client), + startReactjiChanneler(client), + ]); + + // Start escalation resolver scheduler (must be after client is ready) + startEscalationResolver(client); + + log("info", "Gateway", "Gateway initialization completed", { + guildCount: client.guilds.cache.size, + userCount: client.users.cache.size, + }); + + // Track bot startup in business analytics + botStats.botStarted(client.guilds.cache.size, client.users.cache.size); + }, + { + guildCount: client.guilds.cache.size, + userCount: client.users.cache.size, + }, + ); } export default function init() { - if (globalThis.__discordGatewayInitialized) { - log( - "info", - "Gateway", - "Gateway already initialized, skipping duplicate init", - ); - return; + // Login only happens once - persists across HMR + if (!isLoginStarted()) { + log("info", "Gateway", "Initializing Discord gateway (first time)"); + markLoginStarted(); + void login(); + + // Set ready state when ClientReady fires (only needs to happen once) + client.once(Events.ClientReady, () => { + setClientReady(); + }); + } else { + log("info", "Gateway", "HMR detected, rebinding listeners"); } - log("info", "Gateway", "Initializing Discord gateway"); - globalThis.__discordGatewayInitialized = true; + // Clean up old listeners and scheduled tasks before rebinding + removeAllListeners(client); + clearScheduledTasks(); + + // Bind all listeners (runs on every HMR reload) + bindListeners(); - void login(); + // Initialize sub-modules if client is already ready, otherwise wait + if (isClientReady()) { + void initializeSubModules(); + } else { + // Use once() here since this is a one-time initialization per login + client.once(Events.ClientReady, () => { + void initializeSubModules(); + }); + } +} +/** + * Bind all gateway-level listeners. Called on initial load and every HMR reload. + */ +function bindListeners() { // Diagnostic: log all raw gateway events client.on( Events.Raw, @@ -57,45 +121,6 @@ export default function init() { }, ); - client.on(Events.ClientReady, async () => { - await trackPerformance( - "gateway_startup", - async () => { - log("info", "Gateway", "Bot ready event triggered", { - guildCount: client.guilds.cache.size, - userCount: client.users.cache.size, - }); - - await Promise.all([ - onboardGuild(client), - automod(client), - modActionLogger(client), - deployCommands(client), - startActivityTracking(client), - startHoneypotTracking(client), - startReactjiChanneler(client), - ]); - - // Start escalation resolver scheduler (must be after client is ready) - startEscalationResolver(client); - - log("info", "Gateway", "Gateway initialization completed", { - guildCount: client.guilds.cache.size, - userCount: client.users.cache.size, - }); - - // Track bot startup in business analytics - botStats.botStarted(client.guilds.cache.size, client.users.cache.size); - }, - { - guildCount: client.guilds.cache.size, - userCount: client.users.cache.size, - }, - ); - }); - - // client.on(Events.messageReactionAdd, () => {}); - client.on(Events.ThreadCreate, (thread) => { log("info", "Gateway", "Thread created", { threadId: thread.id, @@ -215,12 +240,6 @@ export default function init() { } }); - // client.on(Events.messageCreate, async (msg) => { - // if (msg.author?.id === client.user?.id) return; - - // // - // }); - const errorHandler = (error: unknown) => { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/app/discord/hmrRegistry.ts b/app/discord/hmrRegistry.ts new file mode 100644 index 00000000..1a817ff5 --- /dev/null +++ b/app/discord/hmrRegistry.ts @@ -0,0 +1,61 @@ +import type { Client } from "discord.js"; + +import { log } from "#~/helpers/observability"; + +// --- Login state --- + +export function isLoginStarted(): boolean { + return globalThis.__discordLoginStarted ?? false; +} + +export function markLoginStarted(): void { + globalThis.__discordLoginStarted = true; +} + +// --- Client ready state --- + +export function isClientReady(): boolean { + return globalThis.__discordClientReady ?? false; +} + +export function setClientReady(): void { + globalThis.__discordClientReady = true; +} + +// --- Scheduled tasks --- + +export function registerScheduledTask( + timer: ReturnType, +): void { + globalThis.__discordScheduledTasks ??= []; + globalThis.__discordScheduledTasks.push(timer); +} + +export function clearScheduledTasks(): void { + const tasks = globalThis.__discordScheduledTasks ?? []; + if (tasks.length > 0) { + log("info", "HMR", `Clearing ${tasks.length} scheduled tasks`); + } + for (const timer of tasks) { + clearTimeout(timer); + clearInterval(timer); + } + globalThis.__discordScheduledTasks = []; +} + +// --- Listener registry --- + +/** + * Remove all tracked listeners from the client. + * Call this before rebinding listeners on HMR. + */ +export function removeAllListeners(client: Client): void { + const registry = globalThis.__discordListenerRegistry ?? []; + if (registry.length > 0) { + log("info", "HMR", `Removing ${registry.length} listeners for HMR`); + } + for (const { event, listener } of registry) { + client.off(event, listener); + } + globalThis.__discordListenerRegistry = []; +} diff --git a/app/helpers/schedule.ts b/app/helpers/schedule.ts index 029d14a5..09cd577d 100644 --- a/app/helpers/schedule.ts +++ b/app/helpers/schedule.ts @@ -27,17 +27,23 @@ export const enum SPECIFIED_TIMES { "midnight" = "0 0 * * *", } +export interface ScheduledTaskHandle { + initialTimer: ReturnType; + intervalTimer?: ReturnType; +} + /** * Schedule messages to run on a consistent interval, assuming a constant * first-run time of Sunday at midnight. * @param interval An interval in milliseconds * @param task A function to run every interval + * @returns Handle containing timer IDs for cleanup during HMR */ export const scheduleTask = ( serviceName: string, interval: number | SPECIFIED_TIMES, task: () => void, -) => { +): ScheduledTaskHandle | undefined => { if (typeof interval === "number") { const firstRun = getFirstRun(interval); log( @@ -46,12 +52,16 @@ export const scheduleTask = ( `Scheduling ${serviceName} in ${Math.floor(firstRun / 1000) / 60}min, repeating ${Math.floor(interval / 1000) / 60}`, { serviceName, interval, firstRun }, ); - setTimeout(() => { - task(); - setInterval(task, interval); - }, firstRun); + const handle: ScheduledTaskHandle = { + initialTimer: setTimeout(() => { + task(); + handle.intervalTimer = setInterval(task, interval); + }, firstRun), + }; + return handle; } else { log("info", "ScheduleTask", JSON.stringify({ serviceName, interval })); scheduleCron(interval, task); + return undefined; } }; From 951bb0f2fc2491f6a79c778fd24f022f814d996a Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 23 Jan 2026 16:09:44 -0500 Subject: [PATCH 03/10] Fix duplicate modlog thread creation from automod events Discord fires multiple AUTO_MODERATION_ACTION_EXECUTION events for the same trigger (one per action type: BlockMessage, SendAlertMessage, etc.). When these arrive within milliseconds, both can check for existing threads before either creates one, resulting in duplicate threads. Add time-window deduplication that only processes the first automod event per user/guild within a 1-second window. Subsequent events log at debug level and return early. Co-Authored-By: Claude Opus 4.5 --- app/commands/report/modActionLogger.ts | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/app/commands/report/modActionLogger.ts b/app/commands/report/modActionLogger.ts index a078644e..dad30b68 100644 --- a/app/commands/report/modActionLogger.ts +++ b/app/commands/report/modActionLogger.ts @@ -24,6 +24,26 @@ import { logModAction } from "./modActionLog"; // Time window to check audit log for matching entries (5 seconds) const AUDIT_LOG_WINDOW_MS = 5000; +// Deduplication for automod events - Discord fires multiple events per trigger +const recentAutomodTriggers = new Set(); + +const getAutomodDedupeKey = (userId: string, guildId: string): string => { + // Group events within the same second + const timeWindow = Math.floor(Date.now() / 1000); + return `${userId}:${guildId}:${timeWindow}`; +}; + +const shouldProcessAutomod = (userId: string, guildId: string): boolean => { + const key = getAutomodDedupeKey(userId, guildId); + if (recentAutomodTriggers.has(key)) { + return false; // Already processed an event for this trigger + } + recentAutomodTriggers.add(key); + // Clean up after 2 seconds to prevent memory leak + setTimeout(() => recentAutomodTriggers.delete(key), 2000); + return true; +}; + interface AuditLogEntryResult { executor: User | PartialUser | null; reason: string | null; @@ -264,6 +284,17 @@ const automodActionEffect = (execution: AutoModerationActionExecution) => return; } + // Deduplicate: only process first event per user/guild/second + // Discord fires multiple events (BlockMessage, SendAlertMessage, etc.) for one trigger + if (!shouldProcessAutomod(userId, guild.id)) { + yield* logEffect("debug", "Automod", "Skipping duplicate automod event", { + userId, + guildId: guild.id, + actionType: action.type, + }); + return; + } + yield* logEffect("info", "Automod", "Automod action executed", { userId, guildId: guild.id, From e1b4111a5045dcd28552796206c8beaba0b81cac Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 23 Jan 2026 18:41:57 -0500 Subject: [PATCH 04/10] Improve git hooks: branch protection, auto npm install, faster pre-push - Add main branch protection to pre-commit hook - Create post-checkout hook to auto-run npm install when deps change - Simplify post-merge to auto-run npm install instead of printing reminder - Remove redundant format check from validate script (pre-commit handles it) Co-Authored-By: Claude Opus 4.5 --- .husky/post-checkout | 12 ++++++++++++ .husky/post-merge | 13 ++++--------- .husky/pre-commit | 6 ++++++ package.json | 2 +- 4 files changed, 23 insertions(+), 10 deletions(-) create mode 100755 .husky/post-checkout diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100755 index 00000000..6edd2d2c --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Only run when switching branches (not checking out files) +# $3 is 1 for branch checkout, 0 for file checkout +if [ "$3" != "1" ]; then + exit 0 +fi + +# Check if package-lock.json changed between old and new HEAD +if git diff --name-only "$1" "$2" | grep -q "^package-lock.json$"; then + echo "📦 Dependencies changed. Running npm install..." + npm install +fi diff --git a/.husky/post-merge b/.husky/post-merge index 3d3c2bc9..8f32a899 100755 --- a/.husky/post-merge +++ b/.husky/post-merge @@ -1,12 +1,7 @@ #!/usr/bin/env bash -# MIT © Sindre Sorhus - sindresorhus.com -# https://gist.github.com/sindresorhus/7996717 changed_files="$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)" -check_run() { - echo "$changed_files" | grep --quiet "$1" && eval "$2" -} - -check_run package-lock.json 'echo "Deps have changed, run \`npm i\`"' -check_run migrations/.* 'echo "Migrations have changed, run \`npm run start:migrate\`"' -check_run seeds/.* 'echo "Seeds have changed, run \`npm run kysely:seed\`"' +if echo "$changed_files" | grep -q "^package-lock.json$"; then + echo "📦 Dependencies changed. Running npm install..." + npm install +fi diff --git a/.husky/pre-commit b/.husky/pre-commit index f841cf21..71b770f0 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,8 @@ #!/usr/bin/env sh +branch="$(git rev-parse --abbrev-ref HEAD)" +if [ "$branch" = "main" ]; then + echo "❌ Direct commits to main are not allowed. Please use a feature branch." + exit 1 +fi + npx lint-staged diff --git a/package.json b/package.json index 15c01f28..f3267640 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "format:fix": "prettier --write", - "validate": "run-p \"test -- run\" lint typecheck format", + "validate": "run-p \"test -- run\" lint typecheck", "kysely": "kysely", "": "", "start:migrate": "kysely --no-outdated-check migrate:list; kysely --no-outdated-check migrate:latest", From d85033c68d2a4f5d38816df42f80ac736f3311ae Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 23 Jan 2026 18:51:01 -0500 Subject: [PATCH 05/10] Disable Star Hunter feature except for Reactiflux It's currently hardcoded to use specific channel IDs in Reactiflux, so gate it off for anything else right now --- app/components/DiscordLayout.tsx | 26 +++++++++++++++----------- app/routes/__auth/dashboard.tsx | 5 +++++ app/routes/__auth/sh-user.tsx | 6 ++++++ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/app/components/DiscordLayout.tsx b/app/components/DiscordLayout.tsx index f00319f0..066c727d 100644 --- a/app/components/DiscordLayout.tsx +++ b/app/components/DiscordLayout.tsx @@ -128,17 +128,21 @@ export function DiscordLayout({