From d86847a7ece3c5c8ed791a68b8001b90e2714395 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 23 Jan 2026 12:54:29 -0500 Subject: [PATCH 01/14] 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 | 138 +++++------------ 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 | 17 ++- app/discord/activityTracker.ts | 5 +- app/discord/escalationResolver.ts | 12 +- app/effects/discordSdk.ts | 161 ++++++++++++++++++++ app/effects/errors.ts | 26 ++-- app/helpers/discord.ts | 158 ++++++++++--------- app/helpers/errors.ts | 5 - app/models/guilds.server.ts | 22 +++ app/models/stripe.server.ts | 19 +-- app/models/userThreads.ts | 19 +-- 21 files changed, 520 insertions(+), 538 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..d188b1b8 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); @@ -167,6 +156,7 @@ export const processEscalationEffect = ( if (!reportedMember) { const userLeft = reportedUser !== null; const reason = userLeft ? "left the server" : "account no longer exists"; + const content = `${noticeText}\n${timing} (${reason})`; yield* logEffect( "info", @@ -185,38 +175,12 @@ 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, content, modLog); return { resolution: resolutions.track, userGone: true }; } @@ -228,38 +192,12 @@ 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); yield* logEffect( "info", 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..a2790bb8 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); }).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..3f90ecba 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); }).pipe( Effect.withSpan("logModAction", { attributes: { diff --git a/app/commands/report/userLog.ts b/app/commands/report/userLog.ts index d7827b9e..59795fc7 100644 --- a/app/commands/report/userLog.ts +++ b/app/commands/report/userLog.ts @@ -78,7 +78,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"), }), ); } @@ -91,7 +91,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "fetchSettings", - discordError: error, + cause: error, }), }), constructLog({ @@ -136,7 +136,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "logUserMessage existing", - discordError: error, + cause: error, }), }); @@ -198,7 +198,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "sendLogMessages", - discordError: error, + cause: error, }), }); @@ -229,7 +229,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", { @@ -248,9 +248,12 @@ export function logUserMessage({ const truncatedMsg = singleLine.length > 80 ? `${singleLine.slice(0, 80)}…` : singleLine; + const stats = yield* getMessageStats(message).pipe( + Effect.catchAll(() => Effect.succeed(undefined)), + ); + yield* Effect.tryPromise({ try: async () => { - const stats = await getMessageStats(message).catch(() => undefined); await parentChannel.send({ allowedMentions: {}, content: `> ${escapeDisruptiveContent(truncatedMsg)}\n-# [${!stats ? "stats failed to load" : `${stats.char_count} chars in ${stats.word_count} words. ${stats.link_stats.length} links, ${stats.code_stats.reduce((count, { lines }) => count + lines, 0)} lines of code. ${message.attachments.size} attachments, ${message.reactions.cache.size} reactions`}](${messageLink(logMessage.channelId, logMessage.id)})`, @@ -259,7 +262,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "logUserMessage", - discordError: error, + cause: error, }), }).pipe( Effect.catchAll((error) => diff --git a/app/discord/activityTracker.ts b/app/discord/activityTracker.ts index 31bbfc5a..263c5334 100644 --- a/app/discord/activityTracker.ts +++ b/app/discord/activityTracker.ts @@ -1,4 +1,5 @@ import { ChannelType, Events, type Client } from "discord.js"; +import { Effect } from "effect"; import db from "#~/db.server"; import { getMessageStats } from "#~/helpers/discord.js"; @@ -34,7 +35,7 @@ export async function startActivityTracking(client: Client) { return; } - const info = await getMessageStats(msg); + const info = await Effect.runPromise(getMessageStats(msg)); const channelInfo = await trackPerformance( "startActivityTracking: getOrFetchChannel", @@ -75,7 +76,7 @@ export async function startActivityTracking(client: Client) { await trackPerformance( "processMessageUpdate", async () => { - const info = await getMessageStats(msg); + const info = await Effect.runPromise(getMessageStats(msg)); await updateStatsById(msg.id) .set({ 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..3422cfa0 --- /dev/null +++ b/app/effects/discordSdk.ts @@ -0,0 +1,161 @@ +/** + * 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, + PartialMessage, + 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) => + Effect.tryPromise({ + try: () => message.forward(targetChannelId), + catch: (error) => error, + }).pipe( + Effect.catchAll((error) => + logEffect("error", "Discord SDK", "failed to forward to modLog", { + error: String(error), + messageId: message.id, + targetChannelId, + }), + ), + ); + +export const replyAndForwardSafe = ( + message: Message, + content: string, + forwardToChannelId: 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", "Discord SDK", "Could not reply and forward message", { + error, + messageId: message.id, + forwardToChannelId, + }), + ), + ); + +/** + * Resolve a potentially partial message to a full Message. + * Only fetches from Discord API if the message is partial. + * Provides type narrowing from Message | PartialMessage to Message. + */ +export const resolveMessagePartial = ( + msg: Message | PartialMessage, +): Effect.Effect => + msg.partial + ? Effect.tryPromise({ + try: () => msg.fetch(), + catch: (error) => + new DiscordApiError({ + operation: "resolveMessagePartial", + cause: error, + }), + }) + : Effect.succeed(msg); diff --git a/app/effects/errors.ts b/app/effects/errors.ts index 35635b90..b386f78b 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")<{ @@ -38,13 +43,6 @@ export class DatabaseCorruptionError extends Data.TaggedError( }> {} // Escalation-specific errors - -export class EscalationNotFoundError extends Data.TaggedError( - "EscalationNotFoundError", -)<{ - escalationId: string; -}> {} - export class AlreadyResolvedError extends Data.TaggedError( "AlreadyResolvedError", )<{ @@ -52,12 +50,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..e5a0b51b 100644 --- a/app/helpers/discord.ts +++ b/app/helpers/discord.ts @@ -18,18 +18,17 @@ import { type Poll, type UserContextMenuCommandInteraction, } from "discord.js"; -import { type Effect } from "effect"; +import { Effect } from "effect"; import { partition } from "lodash-es"; import prettyBytes from "pretty-bytes"; +import { resolveMessagePartial } from "#~/effects/discordSdk"; +import { type DiscordApiError, NotFoundError } from "#~/effects/errors.ts"; import { getChars, getWords, parseMarkdownBlocks, } from "#~/helpers/messageParsing"; -import { trackPerformance } from "#~/helpers/observability"; - -import { NotFoundError } from "./errors"; const staffRoles = ["mvp", "moderator", "admin", "admins"]; const helpfulRoles = ["mvp", "star helper"]; @@ -257,84 +256,97 @@ export interface CodeStats { lines: number; lang: string | undefined; } + +export interface MessageStats { + char_count: number; + word_count: number; + code_stats: CodeStats[]; + link_stats: string[]; + react_count: number; + sent_at: number; +} + /** * getMessageStats is a helper to retrieve common metrics from a message * @param msg A Discord Message or PartialMessage object - * @returns { chars: number; words: number; lines: number; lang?: string } + * @returns MessageStats with char/word counts, code blocks, links, reactions, and timestamp */ -export async function getMessageStats(msg: Message | PartialMessage) { - return trackPerformance( - "startActivityTracking: getMessageStats", - async () => { - const { content } = await msg - .fetch() - .catch((_) => ({ content: undefined })); - if (!content) { - throw new NotFoundError("message", "getMessageStats"); - } - - const blocks = parseMarkdownBlocks(content); - - // TODO: groupBy would be better here, but this was easier to keep typesafe - const [textblocks, nontextblocks] = partition( - blocks, - (b) => b.type === "text", +export const getMessageStats = ( + msg: Message | PartialMessage, +): Effect.Effect => + Effect.gen(function* () { + const message = yield* resolveMessagePartial(msg); + + const { content } = message; + if (!content) { + return yield* Effect.fail( + new NotFoundError({ resource: "message", id: msg.id }), ); - const [links, codeblocks] = partition( - nontextblocks, - (b) => b.type === "link", - ); - - const linkStats = links.map((link) => link.url); - - const { wordCount, charCount } = [...links, ...textblocks].reduce( - (acc, block) => { - const content = - block.type === "link" ? (block.label ?? "") : block.content; - const words = getWords(content).length; - const chars = getChars(content).length; + } + + const blocks = parseMarkdownBlocks(content); + + // TODO: groupBy would be better here, but this was easier to keep typesafe + const [textblocks, nontextblocks] = partition( + blocks, + (b) => b.type === "text", + ); + const [links, codeblocks] = partition( + nontextblocks, + (b) => b.type === "link", + ); + + const linkStats = links.map((link) => link.url); + + const { wordCount, charCount } = [...links, ...textblocks].reduce( + (acc, block) => { + const content = + block.type === "link" ? (block.label ?? "") : block.content; + const words = getWords(content).length; + const chars = getChars(content).length; + return { + wordCount: acc.wordCount + words, + charCount: acc.charCount + chars, + }; + }, + { wordCount: 0, charCount: 0 }, + ); + + const codeStats = codeblocks.map((block): CodeStats => { + switch (block.type) { + case "fencedcode": { + const content = block.code.join("\n"); return { - wordCount: acc.wordCount + words, - charCount: acc.charCount + chars, + chars: getChars(content).length, + words: getWords(content).length, + lines: block.code.length, + lang: block.lang, }; - }, - { wordCount: 0, charCount: 0 }, - ); - - const codeStats = codeblocks.map((block): CodeStats => { - switch (block.type) { - case "fencedcode": { - const content = block.code.join("\n"); - return { - chars: getChars(content).length, - words: getWords(content).length, - lines: block.code.length, - lang: block.lang, - }; - } - case "inlinecode": { - return { - chars: getChars(block.code).length, - words: getWords(block.code).length, - lines: 1, - lang: undefined, - }; - } } - }); - - const values = { - char_count: charCount, - word_count: wordCount, - code_stats: codeStats, - link_stats: linkStats, - react_count: msg.reactions.cache.size, - sent_at: msg.createdTimestamp, - }; - return values; - }, + case "inlinecode": { + return { + chars: getChars(block.code).length, + words: getWords(block.code).length, + lines: 1, + lang: undefined, + }; + } + } + }); + + return { + char_count: charCount, + word_count: wordCount, + code_stats: codeStats, + link_stats: linkStats, + react_count: msg.reactions.cache.size, + sent_at: msg.createdTimestamp, + }; + }).pipe( + Effect.withSpan("getMessageStats", { + attributes: { messageId: msg.id }, + }), ); -} export function hasModRole( interaction: MessageComponentInteraction, 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..76e92e77 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,22 @@ 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 rows = 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); + const result = Object.entries(rows[0] ?? {}) as [T, string][]; + return Object.fromEntries(result) as Pick; + }); diff --git a/app/models/stripe.server.ts b/app/models/stripe.server.ts index a1db74b7..0db7a0be 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: variant }); } + priceId = price.id; try { const session = await stripe.checkout.sessions.create({ diff --git a/app/models/userThreads.ts b/app/models/userThreads.ts index d977506c..280fb358 100644 --- a/app/models/userThreads.ts +++ b/app/models/userThreads.ts @@ -91,7 +91,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 }), }); /** @@ -197,26 +197,20 @@ const doGetOrCreateUserThread = (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"), }), ); } @@ -227,10 +221,7 @@ const doGetOrCreateUserThread = (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 b376a34cfa409599fbb4d79e66a7834052c47b14 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Wed, 28 Jan 2026 15:51:08 -0500 Subject: [PATCH 02/14] Significantly improve automod logging behaviors --- app/commands/report/automodLog.ts | 34 +++++++++++-------- app/commands/report/constructLog.ts | 15 +++----- app/commands/report/modActionLogger.ts | 47 -------------------------- app/commands/report/userLog.ts | 8 ++--- 4 files changed, 26 insertions(+), 78 deletions(-) diff --git a/app/commands/report/automodLog.ts b/app/commands/report/automodLog.ts index a2790bb8..81708556 100644 --- a/app/commands/report/automodLog.ts +++ b/app/commands/report/automodLog.ts @@ -5,7 +5,6 @@ import { DatabaseLayer } from "#~/Database"; import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk"; import { logEffect } from "#~/effects/observability"; import { runEffect } from "#~/effects/runtime"; -import { truncateMessage } from "#~/helpers/string"; import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { getOrCreateUserThread } from "#~/models/userThreads.ts"; @@ -20,22 +19,23 @@ export interface AutomodReport { actionType: AutoModerationActionType; } -const ActionTypeLabels: Record = { - [AutoModerationActionType.BlockMessage]: "blocked message", - [AutoModerationActionType.SendAlertMessage]: "sent alert", - [AutoModerationActionType.Timeout]: "timed out user", - [AutoModerationActionType.BlockMemberInteraction]: "blocked interaction", -}; - export const logAutomod = ({ guild, user, channelId, ruleName, matchedKeyword, + // TODO: log the full blocked message content + // content, actionType, }: AutomodReport) => Effect.gen(function* () { + if ( + actionType === AutoModerationActionType.SendAlertMessage || + actionType === AutoModerationActionType.Timeout + ) { + return; + } yield* logEffect( "info", "logAutomod", @@ -59,12 +59,18 @@ export const logAutomod = ({ // Construct the log message const channelMention = channelId ? `<#${channelId}>` : "Unknown channel"; - const actionLabel = ActionTypeLabels[actionType] ?? "took action"; - - const logContent = truncateMessage( - `<@${user.id}> (${user.username}) triggered automod ${matchedKeyword ? `with text \`${matchedKeyword}\` ` : ""}in ${channelMention} --# ${ruleName} · Automod ${actionLabel}`, - ).trim(); + const logContent = (() => { + switch (actionType) { + case AutoModerationActionType.BlockMemberInteraction: + return `-# Automod blocked member interaction +${channelMention} by <@${user.id}> (${user.username}) +-# ${ruleName} · ${matchedKeyword ? `matched text \`${matchedKeyword}\` ` : ""}`; + case AutoModerationActionType.BlockMessage: + return `-# Automod blocked message +${channelMention} by <@${user.id}> (${user.username}) +-# ${ruleName} · ${matchedKeyword ? `matched text \`${matchedKeyword}\` ` : ""}`; + } + })(); // Send log to thread const logMessage = yield* sendMessage(thread, { diff --git a/app/commands/report/constructLog.ts b/app/commands/report/constructLog.ts index 72b9c00a..fd309286 100644 --- a/app/commands/report/constructLog.ts +++ b/app/commands/report/constructLog.ts @@ -12,7 +12,7 @@ import { truncateMessage } from "#~/helpers/string"; import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { ReportReasons, type Report } from "#~/models/reportedMessages"; -const ReadableReasons: Record = { +export const ReadableReasons: Record = { [ReportReasons.anonReport]: "Reported anonymously", [ReportReasons.track]: "tracked", [ReportReasons.modResolution]: "Mod vote resolved", @@ -20,12 +20,6 @@ const ReadableReasons: Record = { [ReportReasons.automod]: "detected by automod", }; -export const makeReportMessage = ({ message: _, reason, staff }: Report) => { - return { - content: `${staff ? ` ${staff.username} ` : ""}${ReadableReasons[reason]}`, - }; -}; - export const constructLog = ({ logs, extra: origExtra = "", @@ -61,8 +55,6 @@ export const constructLog = ({ ); } - const { content: report } = makeReportMessage(lastReport); - // Add indicator if this is forwarded content const forwardNote = isForwardedMessage(message) ? " (forwarded)" : ""; const preface = `${constructDiscordLink(message)} by <@${author.id}> (${ @@ -71,8 +63,9 @@ export const constructLog = ({ const extra = origExtra ? `${origExtra}\n` : ""; return { - content: truncateMessage(`${preface} --# ${report} + content: truncateMessage(` +-# ${lastReport.staff ? ` ${lastReport.staff.username} ` : ""}${ReadableReasons[lastReport.reason]} +${preface} -# ${extra}${formatDistanceToNowStrict(lastReport.message.createdAt)} ago · `).trim(), allowedMentions: { roles: [moderator] }, } satisfies MessageCreateOptions; diff --git a/app/commands/report/modActionLogger.ts b/app/commands/report/modActionLogger.ts index dad30b68..fee53727 100644 --- a/app/commands/report/modActionLogger.ts +++ b/app/commands/report/modActionLogger.ts @@ -1,7 +1,6 @@ import { formatDistanceToNowStrict } from "date-fns"; import { AuditLogEvent, - AutoModerationActionType, Events, type AutoModerationActionExecution, type Client, @@ -24,26 +23,6 @@ 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; @@ -269,32 +248,6 @@ const automodActionEffect = (execution: AutoModerationActionExecution) => autoModerationRule, } = execution; - // 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; - } - - // 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, diff --git a/app/commands/report/userLog.ts b/app/commands/report/userLog.ts index 59795fc7..3305ca3e 100644 --- a/app/commands/report/userLog.ts +++ b/app/commands/report/userLog.ts @@ -35,7 +35,7 @@ import { getOrCreateUserThread } from "#~/models/userThreads.ts"; import { constructLog, isForwardedMessage, - makeReportMessage, + ReadableReasons, } from "./constructLog"; const getMessageContent = (message: Message): string => { @@ -118,11 +118,7 @@ export function logUserMessage({ const latestReport = yield* Effect.tryPromise({ try: async () => { try { - const reportContents = makeReportMessage({ - message, - reason, - staff, - }); + const reportContents = `${staff ? ` ${staff.username} ` : ""}${ReadableReasons[reason]}`; const priorLogMessage = await thread.messages.fetch( alreadyReported.log_message_id, ); From aa465a647b855d6ade19ae96da07f8f9b9664d45 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 26 Jan 2026 21:22:27 -0500 Subject: [PATCH 03/14] Convert all command handlers to Effect-based implementations - Phase 1: demo.ts, force-ban.ts, report.ts - Phase 2: setup.ts, setupReactjiChannel.ts, setupHoneypot.ts, setupTickets.ts - Phase 3: escalationControls.ts, escalate/handlers.ts (8 handlers) Replaced async/await with Effect.gen, trackPerformance with Effect.withSpan, log() with logEffect(), and try/catch with Effect.catchAll patterns. Co-Authored-By: Claude Opus 4.5 --- app/commands/demo.ts | 65 +- app/commands/escalate/handlers.ts | 739 ++++++++++-------- app/commands/escalationControls.ts | 19 +- app/commands/force-ban.ts | 149 ++-- app/commands/report.ts | 127 ++- app/commands/setup.ts | 197 ++--- app/commands/setupHoneypot.ts | 133 ++-- app/commands/setupReactjiChannel.ts | 142 ++-- app/commands/setupTickets.ts | 545 ++++++++----- .../2026-01-26_1_effect-command-conversion.md | 71 ++ 10 files changed, 1299 insertions(+), 888 deletions(-) create mode 100644 notes/2026-01-26_1_effect-command-conversion.md diff --git a/app/commands/demo.ts b/app/commands/demo.ts index 719b5258..10224e0f 100644 --- a/app/commands/demo.ts +++ b/app/commands/demo.ts @@ -1,24 +1,55 @@ import { ApplicationCommandType } from "discord-api-types/v10"; import { ContextMenuCommandBuilder, + MessageFlags, SlashCommandBuilder, - type CommandInteraction, } from "discord.js"; +import { Effect } from "effect"; -export const command = new SlashCommandBuilder() - .setName("demo") - .setDescription("TODO: replace everything in here"); +import type { + EffectMessageContextCommand, + EffectSlashCommand, + EffectUserContextCommand, +} from "#~/helpers/discord"; -export const handler = async (interaction: CommandInteraction) => { - await interaction.reply({ - flags: "Ephemeral", - content: "ok", - }); -}; - -export const UserCommand = new ContextMenuCommandBuilder() - .setName("demo") - .setType(ApplicationCommandType.User); -export const MessageCommand = new ContextMenuCommandBuilder() - .setName("demo") - .setType(ApplicationCommandType.Message); +export const Command = [ + { + type: "effect", + command: new SlashCommandBuilder() + .setName("demo") + .setDescription("TODO: replace everything in here"), + handler: (interaction) => + Effect.tryPromise(() => + interaction.reply({ + flags: [MessageFlags.Ephemeral], + content: "ok", + }), + ).pipe(Effect.catchAll(() => Effect.void)), + } satisfies EffectSlashCommand, + { + type: "effect", + command: new ContextMenuCommandBuilder() + .setName("demo") + .setType(ApplicationCommandType.User), + handler: (interaction) => + Effect.tryPromise(() => + interaction.reply({ + flags: [MessageFlags.Ephemeral], + content: "ok", + }), + ).pipe(Effect.catchAll(() => Effect.void)), + } satisfies EffectUserContextCommand, + { + type: "effect", + command: new ContextMenuCommandBuilder() + .setName("demo") + .setType(ApplicationCommandType.Message), + handler: (interaction) => + Effect.tryPromise(() => + interaction.reply({ + flags: [MessageFlags.Ephemeral], + content: "ok", + }), + ).pipe(Effect.catchAll(() => Effect.void)), + } satisfies EffectMessageContextCommand, +]; diff --git a/app/commands/escalate/handlers.ts b/app/commands/escalate/handlers.ts index a3b38358..29a7d5c1 100644 --- a/app/commands/escalate/handlers.ts +++ b/app/commands/escalate/handlers.ts @@ -8,14 +8,12 @@ import { import { Effect, Layer } from "effect"; import { DatabaseLayer } from "#~/Database.ts"; -import { runEffectExit } from "#~/effects/runtime.ts"; +import { logEffect } from "#~/effects/observability.ts"; import { humanReadableResolutions, type Resolution, } from "#~/helpers/modResponse"; -import { log } from "#~/helpers/observability"; -import { getFailure } from "."; import { banUserEffect, deleteMessagesEffect, @@ -34,360 +32,465 @@ import { } from "./strings"; import { voteEffect } from "./vote"; -const deleteMessages = async (interaction: MessageComponentInteraction) => { - await interaction.deferReply(); +const deleteMessages = (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + yield* Effect.tryPromise(() => interaction.deferReply()); - const exit = await runEffectExit( - deleteMessagesEffect(interaction).pipe(Effect.provide(DatabaseLayer)), - ); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Error deleting messages", { error }); - - if (error?._tag === "NotAuthorizedError") { - await interaction.editReply({ content: "Insufficient permissions" }); - return; - } - - await interaction.editReply({ content: "Failed to delete messages" }); - return; - } - - const result = exit.value; - await interaction.editReply( - `Messages deleted by ${result.deletedBy} (${result.deleted}/${result.total} successful)`, - ); -}; - -const kickUser = async (interaction: MessageComponentInteraction) => { - const reportedUserId = interaction.customId.split("|")[1]; + const result = yield* deleteMessagesEffect(interaction); - const exit = await runEffectExit( - kickUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + yield* Effect.tryPromise(() => + interaction.editReply( + `Messages deleted by ${result.deletedBy} (${result.deleted}/${result.total} successful)`, + ), + ); + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect( + "error", + "EscalationHandlers", + "Error deleting messages", + { + error: + error instanceof Error ? error.message : JSON.stringify(error), + }, + ); + if (errorObj._tag === "NotAuthorizedError") { + yield* Effect.tryPromise(() => + interaction.editReply({ content: "Insufficient permissions" }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.editReply({ content: "Failed to delete messages" }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-deleteMessages", { + attributes: { guildId: interaction.guildId, userId: interaction.user.id }, + }), ); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Error kicking user", { error }); - - if (error?._tag === "NotAuthorizedError") { - await interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }); - return; - } - await interaction.reply({ - content: "Failed to kick user", - flags: [MessageFlags.Ephemeral], - }); - return; - } - - const result = exit.value; - await interaction.reply(`<@${reportedUserId}> kicked by ${result.actionBy}`); -}; +const kickUser = (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const reportedUserId = interaction.customId.split("|")[1]; -const banUser = async (interaction: MessageComponentInteraction) => { - const reportedUserId = interaction.customId.split("|")[1]; + const result = yield* kickUserEffect(interaction); - const exit = await runEffectExit( - banUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + yield* Effect.tryPromise(() => + interaction.reply(`<@${reportedUserId}> kicked by ${result.actionBy}`), + ); + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect("error", "EscalationHandlers", "Error kicking user", { + error: error instanceof Error ? error.message : JSON.stringify(error), + }); + if (errorObj._tag === "NotAuthorizedError") { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Failed to kick user", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-kickUser", { + attributes: { guildId: interaction.guildId, userId: interaction.user.id }, + }), ); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Error banning user", { error }); - - if (error?._tag === "NotAuthorizedError") { - await interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }); - return; - } - - await interaction.reply({ - content: "Failed to ban user", - flags: [MessageFlags.Ephemeral], - }); - return; - } - const result = exit.value; - await interaction.reply(`<@${reportedUserId}> banned by ${result.actionBy}`); -}; +const banUser = (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const reportedUserId = interaction.customId.split("|")[1]; -const restrictUser = async (interaction: MessageComponentInteraction) => { - const reportedUserId = interaction.customId.split("|")[1]; + const result = yield* banUserEffect(interaction); - const exit = await runEffectExit( - restrictUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + yield* Effect.tryPromise(() => + interaction.reply(`<@${reportedUserId}> banned by ${result.actionBy}`), + ); + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect("error", "EscalationHandlers", "Error banning user", { + error: error instanceof Error ? error.message : JSON.stringify(error), + }); + if (errorObj._tag === "NotAuthorizedError") { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Failed to ban user", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-banUser", { + attributes: { guildId: interaction.guildId, userId: interaction.user.id }, + }), ); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Error restricting user", { error }); - - if (error?._tag === "NotAuthorizedError") { - await interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }); - return; - } - - await interaction.reply({ - content: "Failed to restrict user", - flags: [MessageFlags.Ephemeral], - }); - return; - } - const result = exit.value; - await interaction.reply( - `<@${reportedUserId}> restricted by ${result.actionBy}`, - ); -}; +const restrictUser = (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const reportedUserId = interaction.customId.split("|")[1]; -const timeoutUser = async (interaction: MessageComponentInteraction) => { - const reportedUserId = interaction.customId.split("|")[1]; + const result = yield* restrictUserEffect(interaction); - const exit = await runEffectExit( - timeoutUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + yield* Effect.tryPromise(() => + interaction.reply( + `<@${reportedUserId}> restricted by ${result.actionBy}`, + ), + ); + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect( + "error", + "EscalationHandlers", + "Error restricting user", + { + error: + error instanceof Error ? error.message : JSON.stringify(error), + }, + ); + if (errorObj._tag === "NotAuthorizedError") { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Failed to restrict user", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-restrictUser", { + attributes: { guildId: interaction.guildId, userId: interaction.user.id }, + }), ); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Error timing out user", { error }); - - if (error?._tag === "NotAuthorizedError") { - await interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }); - return; - } - await interaction.reply({ - content: "Failed to timeout user", - flags: [MessageFlags.Ephemeral], - }); - return; - } +const timeoutUser = (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const reportedUserId = interaction.customId.split("|")[1]; - const result = exit.value; - await interaction.reply( - `<@${reportedUserId}> timed out by ${result.actionBy}`, - ); -}; + const result = yield* timeoutUserEffect(interaction); -const vote = (resolution: Resolution) => - async function handleVote( - interaction: MessageComponentInteraction, - ): Promise { - const exit = await runEffectExit( - voteEffect(resolution)(interaction).pipe( - Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), - ), + yield* Effect.tryPromise(() => + interaction.reply(`<@${reportedUserId}> timed out by ${result.actionBy}`), ); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Error voting", { error, resolution }); - - if (error?._tag === "NotAuthorizedError") { - await interaction.reply({ - content: "Only moderators can vote on escalations.", - flags: [MessageFlags.Ephemeral], - }); - return; - } - if (error?._tag === "NotFoundError") { - await interaction.reply({ - content: "Escalation not found.", - flags: [MessageFlags.Ephemeral], - }); - return; - } - if (error?._tag === "AlreadyResolvedError") { - await interaction.reply({ - content: "This escalation has already been resolved.", - flags: [MessageFlags.Ephemeral], - }); - return; - } - - await interaction.reply({ - content: "Something went wrong while recording your vote.", - flags: [MessageFlags.Ephemeral], - }); - return; - } + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect( + "error", + "EscalationHandlers", + "Error timing out user", + { + error: + error instanceof Error ? error.message : JSON.stringify(error), + }, + ); + if (errorObj._tag === "NotAuthorizedError") { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Failed to timeout user", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-timeoutUser", { + attributes: { guildId: interaction.guildId, userId: interaction.user.id }, + }), + ); - const result = exit.value; - const { - escalation, - tally, - modRoleId, - features, - votingStrategy, - earlyResolution, - } = result; - - // Check if early resolution triggered with clear winner - show confirmed state - if (earlyResolution && !tally.isTied && tally.leader) { - await interaction.update({ - content: buildConfirmedMessageContent(escalation, tally.leader, tally), - components: [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`expedite|${escalation.id}`) - .setLabel("Expedite") - .setStyle(ButtonStyle.Primary), - ), - ], - }); - return; - } +const vote = + (resolution: Resolution) => (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const result = yield* voteEffect(resolution)(interaction); - // Update the message with new vote state - await interaction.update({ - content: buildVoteMessageContent( - modRoleId ?? "", - votingStrategy, + const { escalation, tally, - ), - components: buildVoteButtons( + modRoleId, features, votingStrategy, - escalation, - tally, earlyResolution, - ), - }); - }; - -const expedite = async ( - interaction: MessageComponentInteraction, -): Promise => { - await interaction.deferUpdate(); + } = result; + + // Check if early resolution triggered with clear winner - show confirmed state + if (earlyResolution && !tally.isTied && tally.leader) { + yield* Effect.tryPromise(() => + interaction.update({ + content: buildConfirmedMessageContent( + escalation, + tally.leader!, + tally, + ), + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`expedite|${escalation.id}`) + .setLabel("Expedite") + .setStyle(ButtonStyle.Primary), + ), + ], + }), + ); + return; + } - const exit = await runEffectExit( - expediteEffect(interaction).pipe( + // Update the message with new vote state + yield* Effect.tryPromise(() => + interaction.update({ + content: buildVoteMessageContent( + modRoleId ?? "", + votingStrategy, + escalation, + tally, + ), + components: buildVoteButtons( + features, + votingStrategy, + escalation, + tally, + earlyResolution, + ), + }), + ); + }).pipe( Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), - ), - ); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Expedite failed", { error }); - - if (error?._tag === "NotAuthorizedError") { - await interaction.followUp({ - content: "Only moderators can expedite resolutions.", - flags: [MessageFlags.Ephemeral], - }); - return; - } - if (error?._tag === "NotFoundError") { - await interaction.followUp({ - content: "Escalation not found.", - flags: [MessageFlags.Ephemeral], - }); - return; - } - if (error?._tag === "AlreadyResolvedError") { - await interaction.followUp({ - content: "This escalation has already been resolved.", - flags: [MessageFlags.Ephemeral], - }); - return; - } - if (error?._tag === "NoLeaderError") { - await interaction.followUp({ - content: "Cannot expedite: no clear leading resolution.", - flags: [MessageFlags.Ephemeral], - }); - return; - } - - await interaction.followUp({ - content: "Something went wrong while executing the resolution.", - flags: [MessageFlags.Ephemeral], - }); - return; - } - - const result = exit.value; - const expediteNote = `\nResolved early by <@${interaction.user.id}> at `; - - await interaction.message.edit({ - content: `**${humanReadableResolutions[result.resolution]}** ✅ <@${result.escalation.reported_user_id}>${expediteNote} -${buildVotesListContent(result.tally)}`, - components: [], // Remove buttons - }); -}; - -// Escalate Handler + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect("error", "EscalationHandlers", "Error voting", { + error: + error instanceof Error ? error.message : JSON.stringify(error), + resolution, + }); + if (errorObj._tag === "NotAuthorizedError") { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Only moderators can vote on escalations.", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + if (errorObj._tag === "NotFoundError") { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Escalation not found.", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + if (errorObj._tag === "AlreadyResolvedError") { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "This escalation has already been resolved.", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Something went wrong while recording your vote.", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-vote", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + resolution, + }, + }), + ); -const escalate = async (interaction: MessageComponentInteraction) => { - await interaction.deferReply({ flags: ["Ephemeral"] }); +const expedite = (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + yield* Effect.tryPromise(() => interaction.deferUpdate()); - const [_, reportedUserId, level = "0", previousEscalationId = ""] = - interaction.customId.split("|"); + const result = yield* expediteEffect(interaction); - const escalationId = previousEscalationId || crypto.randomUUID(); - log("info", "EscalationHandlers", "Handling escalation", { - reportedUserId, - escalationId, - level, - }); + const expediteNote = `\nResolved early by <@${interaction.user.id}> at `; - if (Number(level) === 0) { - // Create new escalation - const exit = await runEffectExit( - createEscalationEffect(interaction, reportedUserId, escalationId).pipe( - Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), - ), + yield* Effect.tryPromise(() => + interaction.message.edit({ + content: `**${humanReadableResolutions[result.resolution]}** ✅ <@${result.escalation.reported_user_id}>${expediteNote} +${buildVotesListContent(result.tally)}`, + components: [], // Remove buttons + }), ); + }).pipe( + Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect("error", "EscalationHandlers", "Expedite failed", { + error: error instanceof Error ? error.message : JSON.stringify(error), + }); + if (errorObj._tag === "NotAuthorizedError") { + yield* Effect.tryPromise(() => + interaction.followUp({ + content: "Only moderators can expedite resolutions.", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + if (errorObj._tag === "NotFoundError") { + yield* Effect.tryPromise(() => + interaction.followUp({ + content: "Escalation not found.", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + if (errorObj._tag === "AlreadyResolvedError") { + yield* Effect.tryPromise(() => + interaction.followUp({ + content: "This escalation has already been resolved.", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + if (errorObj._tag === "NoLeaderError") { + yield* Effect.tryPromise(() => + interaction.followUp({ + content: "Cannot expedite: no clear leading resolution.", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.followUp({ + content: "Something went wrong while executing the resolution.", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-expedite", { + attributes: { guildId: interaction.guildId, userId: interaction.user.id }, + }), + ); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Error creating escalation vote", { - error, - }); - await interaction.editReply({ - content: "Failed to create escalation vote", - }); - return; - } - - await interaction.editReply("Escalation started"); - } else { - // Upgrade to majority voting - const exit = await runEffectExit( - upgradeToMajorityEffect(interaction, escalationId).pipe( - Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), - ), +const escalate = (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + yield* Effect.tryPromise(() => + interaction.deferReply({ flags: ["Ephemeral"] }), ); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Error upgrading escalation", { - error, - }); + const [_, reportedUserId, level = "0", previousEscalationId = ""] = + interaction.customId.split("|"); - if (error?._tag === "NotFoundError") { - await interaction.editReply({ - content: "Failed to re-escalate, couldn't find escalation", - }); - return; - } + const escalationId = previousEscalationId || crypto.randomUUID(); + yield* logEffect("info", "EscalationHandlers", "Handling escalation", { + reportedUserId, + escalationId, + level, + }); - await interaction.editReply({ content: "Failed to upgrade escalation" }); - return; + if (Number(level) === 0) { + // Create new escalation + yield* createEscalationEffect(interaction, reportedUserId, escalationId); + yield* Effect.tryPromise(() => + interaction.editReply("Escalation started"), + ); + } else { + // Upgrade to majority voting + yield* upgradeToMajorityEffect(interaction, escalationId); + yield* Effect.tryPromise(() => + interaction.editReply("Escalation upgraded to majority voting"), + ); } - - await interaction.editReply("Escalation upgraded to majority voting"); - } -}; + }).pipe( + Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect( + "error", + "EscalationHandlers", + "Error handling escalation", + { + error: + error instanceof Error ? error.message : JSON.stringify(error), + }, + ); + if (errorObj._tag === "NotFoundError") { + yield* Effect.tryPromise(() => + interaction.editReply({ + content: "Failed to re-escalate, couldn't find escalation", + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.editReply({ content: "Failed to process escalation" }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-escalate", { + attributes: { guildId: interaction.guildId, userId: interaction.user.id }, + }), + ); export const EscalationHandlers = { // Direct action commands (no voting) diff --git a/app/commands/escalationControls.ts b/app/commands/escalationControls.ts index 6e717774..47f37f59 100644 --- a/app/commands/escalationControls.ts +++ b/app/commands/escalationControls.ts @@ -1,6 +1,6 @@ import { InteractionType } from "discord.js"; -import { type MessageComponentCommand } from "#~/helpers/discord"; +import { type EffectMessageComponentCommand } from "#~/helpers/discord"; import { resolutions } from "#~/helpers/modResponse"; import { EscalationHandlers } from "./escalate/handlers"; @@ -12,21 +12,22 @@ const button = (name: string) => ({ const h = EscalationHandlers; -export const EscalationCommands: MessageComponentCommand[] = [ - { command: button("escalate-escalate"), handler: h.escalate }, +export const EscalationCommands: EffectMessageComponentCommand[] = [ + { type: "effect", command: button("escalate-escalate"), handler: h.escalate }, // Direct action commands (no voting) - { command: button("escalate-delete"), handler: h.delete }, - { command: button("escalate-kick"), handler: h.kick }, - { command: button("escalate-ban"), handler: h.ban }, - { command: button("escalate-restrict"), handler: h.restrict }, - { command: button("escalate-timeout"), handler: h.timeout }, + { type: "effect", command: button("escalate-delete"), handler: h.delete }, + { type: "effect", command: button("escalate-kick"), handler: h.kick }, + { type: "effect", command: button("escalate-ban"), handler: h.ban }, + { type: "effect", command: button("escalate-restrict"), handler: h.restrict }, + { type: "effect", command: button("escalate-timeout"), handler: h.timeout }, // Expedite handler - { command: button("expedite"), handler: h.expedite }, + { type: "effect", command: button("expedite"), handler: h.expedite }, // Create vote handlers for each resolution ...Object.values(resolutions).map((resolution) => ({ + type: "effect" as const, command: { type: InteractionType.MessageComponent as const, name: `vote-${resolution}`, diff --git a/app/commands/force-ban.ts b/app/commands/force-ban.ts index 8491963b..251c94f2 100644 --- a/app/commands/force-ban.ts +++ b/app/commands/force-ban.ts @@ -3,95 +3,106 @@ import { ContextMenuCommandBuilder, MessageFlags, PermissionFlagsBits, - type UserContextMenuCommandInteraction, } from "discord.js"; +import { Effect } from "effect"; +import { logEffect } from "#~/effects/observability.ts"; +import type { EffectUserContextCommand } from "#~/helpers/discord"; import { commandStats } from "#~/helpers/metrics"; -import { log, trackPerformance } from "#~/helpers/observability"; -const command = new ContextMenuCommandBuilder() - .setName("Force Ban") - .setType(ApplicationCommandType.User) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers); - -const handler = async (interaction: UserContextMenuCommandInteraction) => { - await trackPerformance( - "forceBanCommand", - async () => { - const { targetUser } = interaction; - - log("info", "Commands", "Force ban command executed", { +export const Command = { + type: "effect", + command: new ContextMenuCommandBuilder() + .setName("Force Ban") + .setType(ApplicationCommandType.User) + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + handler: (interaction) => + Effect.gen(function* () { + const { targetUser, guild, user } = interaction; + + yield* logEffect("info", "Commands", "Force ban command executed", { guildId: interaction.guildId, - moderatorUserId: interaction.user.id, + moderatorUserId: user.id, targetUserId: targetUser.id, targetUsername: targetUser.username, }); - const { bans } = interaction.guild ?? {}; - - if (!bans) { - log("error", "Commands", "No guild found on force ban interaction", { - guildId: interaction.guildId, - moderatorUserId: interaction.user.id, - targetUserId: targetUser.id, - }); + if (!guild?.bans) { + yield* logEffect( + "error", + "Commands", + "No guild found on force ban interaction", + { + guildId: interaction.guildId, + moderatorUserId: user.id, + targetUserId: targetUser.id, + }, + ); commandStats.commandFailed(interaction, "force-ban", "No guild found"); - await interaction.reply({ - flags: [MessageFlags.Ephemeral], - content: "Failed to ban user, couldn't find guild", - }); + yield* Effect.tryPromise(() => + interaction.reply({ + flags: [MessageFlags.Ephemeral], + content: "Failed to ban user, couldn't find guild", + }), + ); return; } - try { - await interaction.guild?.bans.create(targetUser, { + yield* Effect.tryPromise(() => + guild.bans.create(targetUser, { reason: "Force banned by staff", - }); + }), + ); - log("info", "Commands", "User force banned successfully", { - guildId: interaction.guildId, - moderatorUserId: interaction.user.id, - targetUserId: targetUser.id, - targetUsername: targetUser.username, - reason: "Force banned by staff", - }); + yield* logEffect("info", "Commands", "User force banned successfully", { + guildId: interaction.guildId, + moderatorUserId: user.id, + targetUserId: targetUser.id, + targetUsername: targetUser.username, + reason: "Force banned by staff", + }); - commandStats.commandExecuted(interaction, "force-ban", true); + commandStats.commandExecuted(interaction, "force-ban", true); - await interaction.reply({ + yield* Effect.tryPromise(() => + interaction.reply({ flags: [MessageFlags.Ephemeral], content: "This member has been banned", - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - - log("error", "Commands", "Force ban failed", { + }), + ); + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + const err = error instanceof Error ? error : new Error(String(error)); + + yield* logEffect("error", "Commands", "Force ban failed", { + guildId: interaction.guildId, + moderatorUserId: interaction.user.id, + targetUserId: interaction.targetUser.id, + targetUsername: interaction.targetUser.username, + error: err.message, + stack: err.stack, + }); + + commandStats.commandFailed(interaction, "force-ban", err.message); + + yield* Effect.tryPromise(() => + interaction.reply({ + flags: [MessageFlags.Ephemeral], + content: + "Failed to ban user, try checking the bot's permissions. If they look okay, make sure that the bot's role is near the top of the roles list — bots can't ban users with roles above their own.", + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("forceBanCommand", { + attributes: { guildId: interaction.guildId, moderatorUserId: interaction.user.id, - targetUserId: targetUser.id, - targetUsername: targetUser.username, - error: err.message, - stack: err.stack, - }); - - commandStats.commandFailed(interaction, "force-ban", err.message); - - await interaction.reply({ - flags: [MessageFlags.Ephemeral], - content: - "Failed to ban user, try checking the bot's permissions. If they look okay, make sure that the bot's role is near the top of the roles list — bots can't ban users with roles above their own.", - }); - } - }, - { - commandName: "force-ban", - guildId: interaction.guildId, - moderatorUserId: interaction.user.id, - targetUserId: interaction.targetUser.id, - }, - ); -}; - -export const Command = { handler, command }; + targetUserId: interaction.targetUser.id, + }, + }), + ), +} satisfies EffectUserContextCommand; diff --git a/app/commands/report.ts b/app/commands/report.ts index d06d15c0..f20aadf9 100644 --- a/app/commands/report.ts +++ b/app/commands/report.ts @@ -3,87 +3,78 @@ import { ContextMenuCommandBuilder, MessageFlags, PermissionFlagsBits, - type MessageContextMenuCommandInteraction, } from "discord.js"; +import { Effect } from "effect"; -import { logUserMessageLegacy } from "#~/commands/report/userLog.ts"; +import { logUserMessage } from "#~/commands/report/userLog.ts"; +import { DatabaseLayer } from "#~/Database.ts"; +import { logEffect } from "#~/effects/observability.ts"; +import type { EffectMessageContextCommand } from "#~/helpers/discord"; import { commandStats } from "#~/helpers/metrics"; -import { log, trackPerformance } from "#~/helpers/observability"; import { ReportReasons } from "#~/models/reportedMessages.ts"; -const command = new ContextMenuCommandBuilder() - .setName("Report") - .setType(ApplicationCommandType.Message) - .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages); - -const handler = async (interaction: MessageContextMenuCommandInteraction) => { - // Defer immediately to avoid 3-second timeout - creating threads can take time - await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); - - await trackPerformance( - "reportCommand", - async () => { +export const Command = { + type: "effect", + command: new ContextMenuCommandBuilder() + .setName("Report") + .setType(ApplicationCommandType.Message) + .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), + handler: (interaction) => + Effect.gen(function* () { const { targetMessage: message } = interaction; - log("info", "Commands", "Report command executed", { - guildId: interaction.guildId, - reporterUserId: interaction.user.id, - targetUserId: message.author.id, - targetMessageId: message.id, - channelId: interaction.channelId, - }); + yield* Effect.tryPromise(() => + interaction.deferReply({ flags: [MessageFlags.Ephemeral] }), + ); - try { - await logUserMessageLegacy({ - reason: ReportReasons.anonReport, - message, - staff: false, - }); + yield* logEffect("info", "Commands", "Report command executed"); - log("info", "Commands", "Report submitted successfully", { - guildId: interaction.guildId, - reporterUserId: interaction.user.id, - targetUserId: message.author.id, - targetMessageId: message.id, - reason: ReportReasons.anonReport, - }); + yield* logUserMessage({ + reason: ReportReasons.anonReport, + message, + staff: false, + }); - // Track successful report in business analytics - commandStats.reportSubmitted(interaction, message.author.id); + yield* logEffect("info", "Commands", "Report submitted successfully"); - // Track command success - commandStats.commandExecuted(interaction, "report", true); + commandStats.reportSubmitted(interaction, message.author.id); + commandStats.commandExecuted(interaction, "report", true); - await interaction.editReply({ + yield* Effect.tryPromise(() => + interaction.editReply({ content: "This message has been reported anonymously", - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); + }), + ); + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect("error", "Commands", "Report command failed", { + error, + }); - log("error", "Commands", "Report command failed", { + yield* Effect.tryPromise(() => + interaction.reply({ + flags: [MessageFlags.Ephemeral], + content: "Failed to submit report. Please try again later.", + }), + ).pipe( + Effect.catchAll(() => { + commandStats.commandFailed(interaction, "report", error.message); + return Effect.void; + }), + ); + }), + ), + Effect.withSpan("reportCommand", { + attributes: { guildId: interaction.guildId, reporterUserId: interaction.user.id, - targetUserId: message.author.id, - targetMessageId: message.id, - error: err.message, - stack: err.stack, - }); - - // Track command failure in business analytics - commandStats.commandFailed(interaction, "report", err.message); - - await interaction.editReply({ - content: "Failed to submit report. Please try again later.", - }); - } - }, - { - commandName: "report", - guildId: interaction.guildId, - reporterUserId: interaction.user.id, - targetUserId: interaction.targetMessage.author.id, - }, - ); -}; - -export const Command = { handler, command }; + targetUserId: interaction.targetMessage.author.id, + targetMessageId: interaction.targetMessage.id, + reason: ReportReasons.anonReport, + channelId: interaction.channelId, + }, + }), + ), +} satisfies EffectMessageContextCommand; diff --git a/app/commands/setup.ts b/app/commands/setup.ts index e5300708..31de8ebd 100644 --- a/app/commands/setup.ts +++ b/app/commands/setup.ts @@ -1,112 +1,121 @@ -import { - PermissionFlagsBits, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; +import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; +import { Effect } from "effect"; +import { logEffect } from "#~/effects/observability.ts"; +import type { EffectSlashCommand } from "#~/helpers/discord"; import { commandStats } from "#~/helpers/metrics"; -import { log, trackPerformance } from "#~/helpers/observability"; import { registerGuild, setSettings, SETTINGS } from "#~/models/guilds.server"; -const command = new SlashCommandBuilder() - .setName("setup") - .setDescription("Set up necessities for using the bot") - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .addRoleOption((x) => - x - .setName("moderator") - .setDescription("The role that grants moderator permissions for a user") - .setRequired(true), - ) - .addChannelOption((x) => - x - .setName("mod-log-channel") - .setDescription("The channel where moderation reports will be sent") - .setRequired(true), - ) - .addRoleOption((x) => - x - .setName("restricted") - .setDescription( - "The role that prevents a member from accessing some channels", - ), - ) as SlashCommandBuilder; - -const handler = async (interaction: ChatInputCommandInteraction) => { - await trackPerformance( - "setupCommand", - async () => { - log("info", "Commands", "Setup command executed", { +export const Command = { + type: "effect", + command: new SlashCommandBuilder() + .setName("setup") + .setDescription("Set up necessities for using the bot") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addRoleOption((x) => + x + .setName("moderator") + .setDescription("The role that grants moderator permissions for a user") + .setRequired(true), + ) + .addChannelOption((x) => + x + .setName("mod-log-channel") + .setDescription("The channel where moderation reports will be sent") + .setRequired(true), + ) + .addRoleOption((x) => + x + .setName("restricted") + .setDescription( + "The role that prevents a member from accessing some channels", + ), + ) as SlashCommandBuilder, + + handler: (interaction) => + Effect.gen(function* () { + yield* logEffect("info", "Commands", "Setup command executed", { guildId: interaction.guildId, userId: interaction.user.id, username: interaction.user.username, }); - try { - if (!interaction.guild) throw new Error("Interaction has no guild"); + if (!interaction.guild) { + yield* Effect.fail(new Error("Interaction has no guild")); + return; + } - await registerGuild(interaction.guildId!); + yield* Effect.tryPromise(() => registerGuild(interaction.guildId!)); - const role = interaction.options.getRole("moderator"); - const channel = interaction.options.getChannel("mod-log-channel"); - const restricted = interaction.options.getRole("restricted"); - if (!role) throw new Error("Interaction has no role"); - if (!channel) throw new Error("Interaction has no channel"); + const role = interaction.options.getRole("moderator"); + const channel = interaction.options.getChannel("mod-log-channel"); + const restricted = interaction.options.getRole("restricted"); - const settings = { - [SETTINGS.modLog]: channel.id, - [SETTINGS.moderator]: role.id, - [SETTINGS.restricted]: restricted?.id, - }; + if (!role) { + yield* Effect.fail(new Error("Interaction has no role")); + return; + } + if (!channel) { + yield* Effect.fail(new Error("Interaction has no channel")); + return; + } - await setSettings(interaction.guildId!, settings); + const settings = { + [SETTINGS.modLog]: channel.id, + [SETTINGS.moderator]: role.id, + [SETTINGS.restricted]: restricted?.id, + }; - log("info", "Commands", "Setup completed successfully", { - guildId: interaction.guildId, - userId: interaction.user.id, - moderatorRoleId: role.id, - modLogChannelId: channel.id, - restrictedRoleId: restricted?.id, - hasRestrictedRole: !!restricted, - }); - - // Track successful setup in business analytics - commandStats.setupCompleted(interaction, { - moderator: role.id, - modLog: channel.id, - restricted: restricted?.id, - }); - - // Track command success - commandStats.commandExecuted(interaction, "setup", true); - - await interaction.reply("Setup completed!"); - } catch (e) { - const error = e instanceof Error ? e : new Error(String(e)); - - log("error", "Commands", "Setup command failed", { - guildId: interaction.guildId, - userId: interaction.user.id, - error: error.message, - stack: error.stack, - }); + yield* Effect.tryPromise(() => + setSettings(interaction.guildId!, settings), + ); + + yield* logEffect("info", "Commands", "Setup completed successfully", { + guildId: interaction.guildId, + userId: interaction.user.id, + moderatorRoleId: role.id, + modLogChannelId: channel.id, + restrictedRoleId: restricted?.id, + hasRestrictedRole: !!restricted, + }); + + commandStats.setupCompleted(interaction, { + moderator: role.id, + modLog: channel.id, + restricted: restricted?.id, + }); + + commandStats.commandExecuted(interaction, "setup", true); + + yield* Effect.tryPromise(() => interaction.reply("Setup completed!")); + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + const err = error instanceof Error ? error : new Error(String(error)); - // Track command failure in business analytics - commandStats.commandFailed(interaction, "setup", error.message); + yield* logEffect("error", "Commands", "Setup command failed", { + guildId: interaction.guildId, + userId: interaction.user.id, + error: err.message, + stack: err.stack, + }); - await interaction.reply(`Something broke: + commandStats.commandFailed(interaction, "setup", err.message); + + yield* Effect.tryPromise(() => + interaction.reply(`Something broke: \`\`\` -${error.toString()} +${err.toString()} \`\`\` -`); - } - }, - { - commandName: "setup", - guildId: interaction.guildId, - userId: interaction.user.id, - }, - ); -}; - -export const Command = { handler, command }; +`), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("setupCommand", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), + ), +} satisfies EffectSlashCommand; diff --git a/app/commands/setupHoneypot.ts b/app/commands/setupHoneypot.ts index 80f4565f..7f3d2cf6 100644 --- a/app/commands/setupHoneypot.ts +++ b/app/commands/setupHoneypot.ts @@ -3,19 +3,21 @@ import { MessageFlags, PermissionFlagsBits, SlashCommandBuilder, - type ChatInputCommandInteraction, type TextChannel, } from "discord.js"; +import { Effect } from "effect"; import db from "#~/db.server.js"; -import type { AnyCommand } from "#~/helpers/discord.js"; +import { logEffect } from "#~/effects/observability.ts"; +import type { EffectSlashCommand } from "#~/helpers/discord.js"; import { featureStats } from "#~/helpers/metrics"; -import { log } from "#~/helpers/observability.js"; const DEFAULT_MESSAGE_TEXT = "This channel is used to catch spambots. Do not send a message in this channel or you will be kicked automatically."; + export const Command = [ { + type: "effect", command: new SlashCommandBuilder() .setName("honeypot-setup") .addChannelOption((o) => { @@ -36,37 +38,51 @@ export const Command = [ .setDefaultMemberPermissions( PermissionFlagsBits.Administrator, ) as SlashCommandBuilder, - handler: async (interaction: ChatInputCommandInteraction) => { - if (!interaction.guild || !interaction.guildId) - throw new Error("Interaction has no guild"); - const honeypotChannel = - interaction.options.getChannel("channel") ?? interaction.channel; - const messageText = - interaction.options.getString("message-text") ?? DEFAULT_MESSAGE_TEXT; - if (!honeypotChannel?.id) { - await interaction.reply({ - content: `You must provide a channel!`, - }); - return; - } - if (honeypotChannel.type !== ChannelType.GuildText) { - await interaction.reply({ - content: `The channel configured must be a text channel!`, - }); - return; - } - try { + + handler: (interaction) => + Effect.gen(function* () { + if (!interaction.guild || !interaction.guildId) { + yield* Effect.fail(new Error("Interaction has no guild")); + return; + } + + const honeypotChannel = + interaction.options.getChannel("channel") ?? interaction.channel; + const messageText = + interaction.options.getString("message-text") ?? DEFAULT_MESSAGE_TEXT; + + if (!honeypotChannel?.id) { + yield* Effect.tryPromise(() => + interaction.reply({ + content: `You must provide a channel!`, + }), + ); + return; + } + + if (honeypotChannel.type !== ChannelType.GuildText) { + yield* Effect.tryPromise(() => + interaction.reply({ + content: `The channel configured must be a text channel!`, + }), + ); + return; + } + const castedChannel = honeypotChannel as TextChannel; - const result = await db - .insertInto("honeypot_config") - .values({ - guild_id: interaction.guildId, - channel_id: honeypotChannel.id, - }) - .onConflict((c) => c.doNothing()) - .execute(); - if (result[0].numInsertedOrUpdatedRows ?? 0 > 0) { - await castedChannel.send(messageText); + const result = yield* Effect.tryPromise(() => + db + .insertInto("honeypot_config") + .values({ + guild_id: interaction.guildId!, + channel_id: honeypotChannel.id, + }) + .onConflict((c) => c.doNothing()) + .execute(), + ); + + if ((result[0].numInsertedOrUpdatedRows ?? 0) > 0) { + yield* Effect.tryPromise(() => castedChannel.send(messageText)); featureStats.honeypotSetup( interaction.guildId, interaction.user.id, @@ -74,19 +90,38 @@ export const Command = [ ); } - await interaction.reply({ - content: "Honeypot setup completed successfully!", - flags: [MessageFlags.Ephemeral], - }); - } catch (e) { - log("error", "HoneypotSetup", "Error during honeypot action", { - error: e, - }); - await interaction.reply({ - content: "Failed to setup honeypot. Please try again.", - flags: [MessageFlags.Ephemeral], - }); - } - }, - }, -] as AnyCommand[]; + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Honeypot setup completed successfully!", + flags: [MessageFlags.Ephemeral], + }), + ); + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect( + "error", + "HoneypotSetup", + "Error during honeypot action", + { + error: String(error), + }, + ); + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Failed to setup honeypot. Please try again.", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("honeypotSetupCommand", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), + ), + } satisfies EffectSlashCommand, +]; diff --git a/app/commands/setupReactjiChannel.ts b/app/commands/setupReactjiChannel.ts index 96f7bca2..58289498 100644 --- a/app/commands/setupReactjiChannel.ts +++ b/app/commands/setupReactjiChannel.ts @@ -3,14 +3,16 @@ import { MessageFlags, PermissionFlagsBits, SlashCommandBuilder, - type ChatInputCommandInteraction, } from "discord.js"; +import { Effect } from "effect"; import db from "#~/db.server.js"; -import { type SlashCommand } from "#~/helpers/discord"; +import { logEffect } from "#~/effects/observability.ts"; +import type { EffectSlashCommand } from "#~/helpers/discord"; import { featureStats } from "#~/helpers/metrics"; export const Command = { + type: "effect", command: new SlashCommandBuilder() .setName("setup-reactji-channel") .addStringOption((o) => { @@ -37,56 +39,62 @@ export const Command = { PermissionFlagsBits.Administrator, ) as SlashCommandBuilder, - handler: async (interaction: ChatInputCommandInteraction) => { - if (!interaction.guild) { - await interaction.reply({ - content: "This command can only be used in a server.", - flags: [MessageFlags.Ephemeral], - }); - return; - } + handler: (interaction) => + Effect.gen(function* () { + if (!interaction.guild) { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "This command can only be used in a server.", + flags: [MessageFlags.Ephemeral], + }), + ); + return; + } - const emojiInput = interaction.options.getString("emoji", true); - const threshold = interaction.options.getInteger("threshold") ?? 1; - const channelId = interaction.channelId; - const guildId = interaction.guild.id; - const configuredById = interaction.user.id; + const emojiInput = interaction.options.getString("emoji", true); + const threshold = interaction.options.getInteger("threshold") ?? 1; + const channelId = interaction.channelId; + const guildId = interaction.guild.id; + const configuredById = interaction.user.id; - // Parse the emoji - handle both unicode and custom emoji formats - // Custom emojis come in as <:name:id> or for animated - const customEmojiRegex = /^$/; - const emoji = customEmojiRegex.exec(emojiInput) - ? emojiInput - : emojiInput.trim(); + // Parse the emoji - handle both unicode and custom emoji formats + // Custom emojis come in as <:name:id> or for animated + const customEmojiRegex = /^$/; + const emoji = customEmojiRegex.exec(emojiInput) + ? emojiInput + : emojiInput.trim(); - if (!emoji) { - await interaction.reply({ - content: "Please provide a valid emoji.", - flags: [MessageFlags.Ephemeral], - }); - return; - } + if (!emoji) { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Please provide a valid emoji.", + flags: [MessageFlags.Ephemeral], + }), + ); + return; + } - try { // Upsert: update if exists, insert if not - await db - .insertInto("reactji_channeler_config") - .values({ - id: randomUUID(), - guild_id: guildId, - channel_id: channelId, - emoji, - configured_by_id: configuredById, - threshold, - }) - .onConflict((oc) => - oc.columns(["guild_id", "emoji"]).doUpdateSet({ + yield* Effect.tryPromise(() => + db + .insertInto("reactji_channeler_config") + .values({ + id: randomUUID(), + guild_id: guildId, channel_id: channelId, + emoji, configured_by_id: configuredById, threshold, - }), - ) - .execute(); + }) + .onConflict((oc) => + oc.columns(["guild_id", "emoji"]).doUpdateSet({ + channel_id: channelId, + configured_by_id: configuredById, + threshold, + }), + ) + .execute(), + ); featureStats.reactjiChannelSetup( guildId, @@ -97,16 +105,36 @@ export const Command = { const thresholdText = threshold === 1 ? "" : ` (after ${threshold} reactions)`; - await interaction.reply({ - content: `Configured by <@${configuredById}>: messages reacted with ${emoji} will be forwarded to this channel${thresholdText}.`, - }); - } catch (e) { - console.error("Error configuring reactji channeler:", e); - await interaction.reply({ - content: - "Something went wrong while configuring the reactji channeler.", - flags: [MessageFlags.Ephemeral], - }); - } - }, -} as SlashCommand; + yield* Effect.tryPromise(() => + interaction.reply({ + content: `Configured by <@${configuredById}>: messages reacted with ${emoji} will be forwarded to this channel${thresholdText}.`, + }), + ); + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect( + "error", + "Commands", + "Error configuring reactji channeler", + { error: String(error) }, + ); + + yield* Effect.tryPromise(() => + interaction.reply({ + content: + "Something went wrong while configuring the reactji channeler.", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("setupReactjiChannelCommand", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + channelId: interaction.channelId, + }, + }), + ), +} satisfies EffectSlashCommand; diff --git a/app/commands/setupTickets.ts b/app/commands/setupTickets.ts index 2d5c27dc..d5c36219 100644 --- a/app/commands/setupTickets.ts +++ b/app/commands/setupTickets.ts @@ -12,17 +12,17 @@ import { PermissionFlagsBits, SlashCommandBuilder, TextInputBuilder, - type ChatInputCommandInteraction, } from "discord.js"; +import { Effect } from "effect"; import db from "#~/db.server.js"; import { ssrDiscordSdk as rest } from "#~/discord/api"; +import { logEffect } from "#~/effects/observability.ts"; import { quoteMessageContent, - type AnyCommand, - type MessageComponentCommand, - type ModalCommand, - type SlashCommand, + type EffectMessageComponentCommand, + type EffectModalCommand, + type EffectSlashCommand, } from "#~/helpers/discord"; import { featureStats } from "#~/helpers/metrics"; import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; @@ -31,6 +31,7 @@ const DEFAULT_BUTTON_TEXT = "Open a private ticket with the moderators"; export const Command = [ { + type: "effect", command: new SlashCommandBuilder() .setName("tickets-channel") .addRoleOption((o) => { @@ -62,231 +63,361 @@ export const Command = [ PermissionFlagsBits.Administrator, ) as SlashCommandBuilder, - handler: async (interaction: ChatInputCommandInteraction) => { - if (!interaction.guild) throw new Error("Interaction has no guild"); - - const pingableRole = interaction.options.getRole("role"); - const ticketChannel = interaction.options.getChannel("channel"); - const buttonText = - interaction.options.getString("button-text") ?? DEFAULT_BUTTON_TEXT; - - if (ticketChannel && ticketChannel.type !== ChannelType.GuildText) { - await interaction.reply({ - content: `The channel configured must be a text channel! Tickets will be created as private threads.`, - }); - return; - } - - try { - const interactionResponse = await interaction.reply({ - components: [ - { - type: ComponentType.ActionRow, - components: [ - { - type: ComponentType.Button, - label: buttonText, - style: ButtonStyle.Primary, - customId: "open-ticket", - }, - ], - }, - ], - }); - const producedMessage = await interactionResponse.fetch(); + handler: (interaction) => + Effect.gen(function* () { + if (!interaction.guild) { + yield* Effect.fail(new Error("Interaction has no guild")); + return; + } + + const pingableRole = interaction.options.getRole("role"); + const ticketChannel = interaction.options.getChannel("channel"); + const buttonText = + interaction.options.getString("button-text") ?? DEFAULT_BUTTON_TEXT; + + if (ticketChannel && ticketChannel.type !== ChannelType.GuildText) { + yield* Effect.tryPromise(() => + interaction.reply({ + content: `The channel configured must be a text channel! Tickets will be created as private threads.`, + }), + ); + return; + } + + const interactionResponse = yield* Effect.tryPromise(() => + interaction.reply({ + components: [ + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + label: buttonText, + style: ButtonStyle.Primary, + customId: "open-ticket", + }, + ], + }, + ], + }), + ); + + const producedMessage = yield* Effect.tryPromise(() => + interactionResponse.fetch(), + ); let roleId = pingableRole?.id; if (!roleId) { - const { [SETTINGS.moderator]: mod } = await fetchSettings( - interaction.guild.id, - [SETTINGS.moderator, SETTINGS.modLog], + const { [SETTINGS.moderator]: mod } = yield* Effect.tryPromise(() => + fetchSettings(interaction.guild!.id, [ + SETTINGS.moderator, + SETTINGS.modLog, + ]), ); roleId = mod; } - await db - .insertInto("tickets_config") - .values({ - message_id: producedMessage.id, - channel_id: ticketChannel?.id, - role_id: roleId, - }) - .execute(); + yield* Effect.tryPromise(() => + db + .insertInto("tickets_config") + .values({ + message_id: producedMessage.id, + channel_id: ticketChannel?.id, + role_id: roleId, + }) + .execute(), + ); featureStats.ticketChannelSetup( interaction.guild.id, interaction.user.id, ticketChannel?.id ?? interaction.channelId, ); - } catch (e) { - console.error(`error:`, e); - } - }, - } as SlashCommand, + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect( + "error", + "TicketsSetup", + "Error setting up tickets", + { + error: String(error), + }, + ); + }), + ), + Effect.withSpan("ticketsChannelCommand", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), + ), + } satisfies EffectSlashCommand, + { + type: "effect", command: { type: InteractionType.MessageComponent, name: "open-ticket" }, - handler: async (interaction) => { - const modal = new ModalBuilder() - .setCustomId("modal-open-ticket") - .setTitle("What do you need from the moderators?"); - const actionRow = new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setLabel("Concern") - .setCustomId("concern") - .setMinLength(30) - .setMaxLength(500) - .setRequired(true) - .setStyle(TextInputStyle.Paragraph), - ); - // @ts-expect-error busted types - modal.addComponents(actionRow); - - await interaction.showModal(modal); - }, - } as MessageComponentCommand, + handler: (interaction) => + Effect.gen(function* () { + const modal = new ModalBuilder() + .setCustomId("modal-open-ticket") + .setTitle("What do you need from the moderators?"); + const actionRow = new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setLabel("Concern") + .setCustomId("concern") + .setMinLength(30) + .setMaxLength(500) + .setRequired(true) + .setStyle(TextInputStyle.Paragraph), + ); + // @ts-expect-error busted types + modal.addComponents(actionRow); + + yield* Effect.tryPromise(() => interaction.showModal(modal)); + }).pipe( + Effect.catchAll(() => Effect.void), + Effect.withSpan("openTicketModal", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), + ), + } satisfies EffectMessageComponentCommand, + { + type: "effect", command: { type: InteractionType.ModalSubmit, name: "modal-open-ticket" }, - handler: async (interaction) => { - if ( - !interaction.channel || - interaction.channel.type !== ChannelType.GuildText || - !interaction.guild || - !interaction.message - ) { - await interaction.reply({ - content: "Something went wrong while creating a ticket", - flags: MessageFlags.Ephemeral, - }); - return; - } - const { channel, fields, user } = interaction; - const concern = fields.getTextInputValue("concern"); - - let config = await db - .selectFrom("tickets_config") - .selectAll() - .where("message_id", "=", interaction.message.id) - .executeTakeFirst(); - // If there's no config, that means that the button was set up before the db was set up. Add one with default values - if (!config) { - const { [SETTINGS.moderator]: mod } = await fetchSettings( - interaction.guild.id, - [SETTINGS.moderator, SETTINGS.modLog], + handler: (interaction) => + Effect.gen(function* () { + if ( + !interaction.channel || + interaction.channel.type !== ChannelType.GuildText || + !interaction.guild || + !interaction.message + ) { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Something went wrong while creating a ticket", + flags: MessageFlags.Ephemeral, + }), + ); + return; + } + + const { channel, fields, user } = interaction; + const concern = fields.getTextInputValue("concern"); + + let config = yield* Effect.tryPromise(() => + db + .selectFrom("tickets_config") + .selectAll() + .where("message_id", "=", interaction.message!.id) + .executeTakeFirst(), ); - config = await db - .insertInto("tickets_config") - .returningAll() - .values({ message_id: interaction.message.id, role_id: mod }) - .executeTakeFirst(); + + // If there's no config, that means that the button was set up before the db was set up. Add one with default values if (!config) { - throw new Error("Something went wrong while fixing tickets config"); + const { [SETTINGS.moderator]: mod } = yield* Effect.tryPromise(() => + fetchSettings(interaction.guild!.id, [ + SETTINGS.moderator, + SETTINGS.modLog, + ]), + ); + config = yield* Effect.tryPromise(() => + db + .insertInto("tickets_config") + .returningAll() + .values({ message_id: interaction.message!.id, role_id: mod }) + .executeTakeFirst(), + ); + if (!config) { + yield* Effect.fail( + new Error("Something went wrong while fixing tickets config"), + ); + return; + } + } + + // If channel_id is configured but fetch returns null (channel deleted), + // this will error, which is intended - the configured channel is invalid + const ticketsChannel = config.channel_id + ? yield* Effect.tryPromise(() => + interaction.guild!.channels.fetch(config.channel_id!), + ) + : channel; + + if ( + !ticketsChannel?.isTextBased() || + ticketsChannel.type !== ChannelType.GuildText + ) { + yield* Effect.tryPromise(() => + interaction.reply( + "Couldn't make a ticket! Tell the admins that their ticket channel is misconfigured.", + ), + ); + return; } - } - - // If channel_id is configured but fetch returns null (channel deleted), - // this will error, which is intended - the configured channel is invalid - const ticketsChannel = config.channel_id - ? await interaction.guild.channels.fetch(config.channel_id) - : channel; - - if ( - !ticketsChannel?.isTextBased() || - ticketsChannel.type !== ChannelType.GuildText - ) { - void interaction.reply( - "Couldn’t make a ticket! Tell the admins that their ticket channel is misconfigured.", + + const thread = yield* Effect.tryPromise(() => + ticketsChannel.threads.create({ + name: `${user.username} – ${format(new Date(), "PP kk:mmX")}`, + autoArchiveDuration: 60 * 24 * 7, + type: ChannelType.PrivateThread, + invitable: false, + }), ); - return; - } - - const thread = await ticketsChannel.threads.create({ - name: `${user.username} – ${format(new Date(), "PP kk:mmX")}`, - autoArchiveDuration: 60 * 24 * 7, - type: ChannelType.PrivateThread, - invitable: false, - }); - await thread.send({ - content: `<@${user.id}>, this is a private space only visible to you and the <@&${config.role_id}> role.`, - }); - await thread.send(`${user.displayName} said: -${quoteMessageContent(concern)}`); - await thread.send({ - content: "When you've finished, please close the ticket.", - components: [ - // @ts-expect-error Types for this are super busted - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`close-ticket||${user.id}|| `) - .setLabel("Close ticket") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`close-ticket||${user.id}||👍`) - .setLabel("Close (👍)") - .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`close-ticket||${user.id}||👎`) - .setLabel("Close (👎)") - .setStyle(ButtonStyle.Danger), - ), - ], - }); - - featureStats.ticketCreated(interaction.guild.id, user.id, thread.id); - - void interaction.reply({ - content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`, - flags: [MessageFlags.Ephemeral], - }); - return; - }, - } as ModalCommand, + + yield* Effect.tryPromise(() => + thread.send({ + content: `<@${user.id}>, this is a private space only visible to you and the <@&${config.role_id}> role.`, + }), + ); + + yield* Effect.tryPromise(() => + thread.send(`${user.displayName} said: +${quoteMessageContent(concern)}`), + ); + + yield* Effect.tryPromise(() => + thread.send({ + content: "When you've finished, please close the ticket.", + components: [ + // @ts-expect-error Types for this are super busted + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`close-ticket||${user.id}|| `) + .setLabel("Close ticket") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`close-ticket||${user.id}||👍`) + .setLabel("Close (👍)") + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`close-ticket||${user.id}||👎`) + .setLabel("Close (👎)") + .setStyle(ButtonStyle.Danger), + ), + ], + }), + ); + + featureStats.ticketCreated(interaction.guild.id, user.id, thread.id); + + yield* Effect.tryPromise(() => + interaction.reply({ + content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`, + flags: [MessageFlags.Ephemeral], + }), + ); + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect( + "error", + "TicketsModal", + "Error creating ticket from modal", + { error: String(error) }, + ); + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Something went wrong while creating the ticket", + flags: MessageFlags.Ephemeral, + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("modalOpenTicket", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), + ), + } satisfies EffectModalCommand, + { + type: "effect", command: { type: InteractionType.MessageComponent, name: "close-ticket" }, - handler: async (interaction) => { - const [, ticketOpenerUserId, feedback] = interaction.customId.split("||"); - const threadId = interaction.channelId; - if (!interaction.member || !interaction.guild) { - console.error( - "[err]: no member in ticket interaction", - JSON.stringify(interaction), + handler: (interaction) => + Effect.gen(function* () { + const [, ticketOpenerUserId, feedback] = + interaction.customId.split("||"); + const threadId = interaction.channelId; + + if (!interaction.member || !interaction.guild) { + yield* logEffect( + "error", + "TicketsClose", + "No member in ticket interaction", + { interactionId: interaction.id }, + ); + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Something went wrong", + flags: [MessageFlags.Ephemeral], + }), + ); + return; + } + + const { [SETTINGS.modLog]: modLog } = yield* Effect.tryPromise(() => + fetchSettings(interaction.guild!.id, [SETTINGS.modLog]), ); - await interaction.reply({ - content: "Something went wrong", - flags: [MessageFlags.Ephemeral], - }); - return; - } - - const { [SETTINGS.modLog]: modLog } = await fetchSettings( - interaction.guild.id, - [SETTINGS.modLog], - ); - - const { user } = interaction.member; - const interactionUserId = user.id; - - await Promise.all([ - rest.delete(Routes.threadMembers(threadId, ticketOpenerUserId)), - rest.post(Routes.channelMessages(modLog), { - body: { - content: `<@${ticketOpenerUserId}>'s ticket <#${threadId}> closed by <@${interactionUserId}>${feedback ? `. feedback: ${feedback}` : ""}`, - allowedMentions: {}, + + const { user } = interaction.member; + const interactionUserId = user.id; + + yield* Effect.all([ + Effect.tryPromise(() => + rest.delete(Routes.threadMembers(threadId, ticketOpenerUserId)), + ), + Effect.tryPromise(() => + rest.post(Routes.channelMessages(modLog), { + body: { + content: `<@${ticketOpenerUserId}>'s ticket <#${threadId}> closed by <@${interactionUserId}>${feedback ? `. feedback: ${feedback}` : ""}`, + allowedMentions: {}, + }, + }), + ), + Effect.tryPromise(() => + interaction.reply({ + content: `The ticket was closed by <@${interactionUserId}>`, + allowedMentions: {}, + }), + ), + ]); + + featureStats.ticketClosed( + interaction.guild.id, + interactionUserId, + ticketOpenerUserId, + !!feedback?.trim(), + ); + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect("error", "TicketsClose", "Error closing ticket", { + error: String(error), + }); + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Something went wrong while closing the ticket", + flags: MessageFlags.Ephemeral, + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("closeTicket", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, }, }), - interaction.reply({ - content: `The ticket was closed by <@${interactionUserId}>`, - allowedMentions: {}, - }), - ]); - - featureStats.ticketClosed( - interaction.guild.id, - interactionUserId, - ticketOpenerUserId, - !!feedback?.trim(), - ); - - return; - }, - } as MessageComponentCommand, -] as AnyCommand[]; + ), + } satisfies EffectMessageComponentCommand, +]; diff --git a/notes/2026-01-26_1_effect-command-conversion.md b/notes/2026-01-26_1_effect-command-conversion.md new file mode 100644 index 00000000..c95543d5 --- /dev/null +++ b/notes/2026-01-26_1_effect-command-conversion.md @@ -0,0 +1,71 @@ +# Effect-Based Command Handler Conversion + +Completed conversion of all async command handlers to Effect-based implementations. + +## Files Modified + +### Phase 1: Simple Commands +- `app/commands/demo.ts` - Converted slash + context menu commands +- `app/commands/force-ban.ts` - Converted user context menu command +- `app/commands/report.ts` - Converted message context menu command + +### Phase 2: Setup Commands +- `app/commands/setupReactjiChannel.ts` - DB upsert with emoji parsing +- `app/commands/setup.ts` - Multi-step guild registration +- `app/commands/setupHoneypot.ts` - DB + Discord channel operations +- `app/commands/setupTickets.ts` - Complex 4-handler ticket system + +### Phase 3: Escalation System +- `app/commands/escalationControls.ts` - Changed to EffectMessageComponentCommand[] +- `app/commands/escalate/handlers.ts` - Converted 8 handlers to pure Effect + +## Pattern Applied + +```typescript +// Before +const handler = async (interaction) => { + await trackPerformance("cmd", async () => { + log("info", "Commands", "..."); + try { + await doSomething(); + } catch (e) { + log("error", "Commands", "..."); + } + }); +}; +export const Command = { handler, command }; + +// After +export const Command = { + type: "effect", + command: new SlashCommandBuilder()..., + handler: (interaction) => + Effect.gen(function* () { + yield* logEffect("info", "Commands", "..."); + yield* Effect.tryPromise(() => doSomething()); + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect("error", "Commands", "..."); + yield* Effect.tryPromise(() => + interaction.reply({ content: "Error", flags: [MessageFlags.Ephemeral] }) + ).pipe(Effect.catchAll(() => Effect.void)); + }) + ), + Effect.withSpan("commandName", { attributes: { ... } }), + ), +} satisfies EffectSlashCommand; +``` + +## Key Changes +1. Removed `trackPerformance()` wrappers (Effect.withSpan replaces this) +2. Replaced `log()` with `yield* logEffect()` +3. Replaced `await` with `yield* Effect.tryPromise()` +4. Replaced try/catch with `.pipe(Effect.catchAll(...))` +5. Added `type: "effect"` discriminator +6. Used `satisfies` for type inference with proper handler types + +## Notes +- `getFailure()` in `app/commands/escalate/index.ts` is still used by `escalationResolver.ts` +- Metrics calls (`commandStats`, `featureStats`) remained as synchronous side effects +- Error replies in catchAll are wrapped with `.pipe(Effect.catchAll(() => Effect.void))` to prevent cascading failures From a6ad192b7f7bf6ab208b2ae0a8292b04c866160a Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Wed, 28 Jan 2026 02:10:11 -0500 Subject: [PATCH 04/14] Use Effect scheduling features in escalation resolver --- app/commands/escalate/escalationResolver.ts | 180 ++++++-------------- 1 file changed, 54 insertions(+), 126 deletions(-) diff --git a/app/commands/escalate/escalationResolver.ts b/app/commands/escalate/escalationResolver.ts index d188b1b8..5714f2ec 100644 --- a/app/commands/escalate/escalationResolver.ts +++ b/app/commands/escalate/escalationResolver.ts @@ -65,19 +65,6 @@ export const processEscalationEffect = ( Effect.gen(function* () { const escalationService = yield* EscalationService; - const logBag = { - escalationId: escalation.id, - reportedUserId: escalation.reported_user_id, - guildId: escalation.guild_id, - }; - - yield* logEffect( - "info", - "EscalationResolver", - "Processing due escalation", - logBag, - ); - // Get votes and determine resolution const votes = yield* escalationService.getVotesForEscalation(escalation.id); const tally = tallyVotes( @@ -95,11 +82,7 @@ export const processEscalationEffect = ( "warn", "EscalationResolver", "Auto-resolve defaulting to track due to tie", - { - ...logBag, - tiedResolutions: tally.tiedResolutions, - votingStrategy, - }, + { tiedResolutions: tally.tiedResolutions, votingStrategy }, ); resolution = resolutions.track; } else if (tally.leader) { @@ -108,39 +91,21 @@ export const processEscalationEffect = ( resolution = resolutions.track; } - yield* logEffect( - "info", - "EscalationResolver", - "Auto-resolving escalation", - { - ...logBag, + const [{ modLog }, reportedUser, guild, channel] = yield* Effect.all([ + fetchSettingsEffect(escalation.guild_id, [SETTINGS.modLog]), + fetchUserOrNull(client, escalation.reported_user_id), + fetchGuild(client, escalation.guild_id), + fetchChannelFromClient(client, escalation.thread_id), + logEffect("info", "EscalationResolver", "Auto-resolving escalation", { resolution, - }, - ); + }), + ]).pipe(Effect.withConcurrency("unbounded")); - // Fetch Discord resources - const { modLog } = yield* fetchSettingsEffect(escalation.guild_id, [ - SETTINGS.modLog, + const [reportedMember, voteMessage] = yield* Effect.all([ + fetchMemberOrNull(guild, escalation.reported_user_id), + fetchMessage(channel, escalation.vote_message_id), ]); - 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); const createdAt = Math.floor( @@ -153,58 +118,40 @@ export const processEscalationEffect = ( const timing = `-# Resolved , ${elapsedHours}hrs after escalation`; // Handle case where user left the server or deleted their account + const disabledButtons = getDisabledButtons(voteMessage); if (!reportedMember) { const userLeft = reportedUser !== null; const reason = userLeft ? "left the server" : "account no longer exists"; const content = `${noticeText}\n${timing} (${reason})`; - yield* logEffect( - "info", - "EscalationResolver", - "Resolving escalation - user gone", - { - ...logBag, - reason, - userLeft, - }, - ); - - // Mark as resolved with "track" since we can't take action - yield* escalationService.resolveEscalation( - escalation.id, - resolutions.track, - ); - - yield* editMessage(voteMessage, { - components: getDisabledButtons(voteMessage), - }); - - // Try to reply and forward - but don't fail if it doesn't work - yield* replyAndForwardSafe(voteMessage, content, modLog); + yield* Effect.all([ + logEffect( + "info", + "EscalationResolver", + "Resolving escalation - user gone", + { reason, userLeft }, + ), + escalationService.resolveEscalation(escalation.id, resolutions.track), + editMessage(voteMessage, { components: disabledButtons }), + replyAndForwardSafe(voteMessage, content, modLog), + ]).pipe(Effect.withConcurrency("unbounded")); return { resolution: resolutions.track, userGone: true }; } - // Execute the resolution + // Execute mod action first, then update DB/Discord in parallel yield* escalationService.executeResolution(resolution, escalation, guild); - - // Mark as resolved - yield* escalationService.resolveEscalation(escalation.id, resolution); - - // Update Discord message - yield* editMessage(voteMessage, { - components: getDisabledButtons(voteMessage), - }); - - // Try to reply and forward - but don't fail if it doesn't work - yield* replyAndForwardSafe(voteMessage, `${noticeText}\n${timing}`, modLog); - - yield* logEffect( - "info", - "EscalationResolver", - "Successfully auto-resolved escalation", - { ...logBag, resolution }, - ); + yield* Effect.all([ + escalationService.resolveEscalation(escalation.id, resolution), + editMessage(voteMessage, { components: disabledButtons }), + replyAndForwardSafe(voteMessage, `${noticeText}\n${timing}`, modLog), + logEffect( + "info", + "EscalationResolver", + "Successfully auto-resolved escalation", + { resolution }, + ), + ]).pipe(Effect.withConcurrency("unbounded")); return { resolution, userGone: false }; }).pipe( @@ -212,6 +159,7 @@ export const processEscalationEffect = ( attributes: { escalationId: escalation.id, guildId: escalation.guild_id, + reportedUserId: escalation.reported_user_id, }, }), ); @@ -225,57 +173,37 @@ export const checkPendingEscalationsEffect = (client: Client) => const escalationService = yield* EscalationService; const due = yield* escalationService.getDueEscalations(); + yield* Effect.annotateCurrentSpan({ processed: due.length }); if (due.length === 0) { return { processed: 0, succeeded: 0, failed: 0 }; } - yield* logEffect( - "debug", - "EscalationResolver", - "Processing due escalations", - { - count: due.length, - }, - ); - - let succeeded = 0; - let failed = 0; + yield* logEffect("debug", "EscalationResolver", "Processing escalations"); // Process escalations sequentially to avoid rate limits - for (const escalation of due) { - yield* processEscalationEffect(client, escalation).pipe( - Effect.map(() => { - succeeded++; - return true; - }), + // TODO: In the future, we should have a smarter fetch that manages that + const results = yield* Effect.forEach(due, (escalation) => + processEscalationEffect(client, escalation).pipe( Effect.catchAll((error) => - Effect.gen(function* () { - failed++; - yield* logEffect( - "error", - "EscalationResolver", - "Error processing escalation", - { - escalationId: escalation.id, - error: String(error), - }, - ); - return false; - }), + logEffect( + "error", + "EscalationResolver", + "Error processing escalation", + { escalationId: escalation.id, error: String(error) }, + ), ), - ); - } + ), + ); + + const succeeded = results.filter(Boolean).length; + const failed = results.length - succeeded; yield* logEffect( "info", "EscalationResolver", "Finished processing escalations", - { - processed: due.length, - succeeded, - failed, - }, + { succeeded, failed }, ); return { processed: due.length, succeeded, failed }; From d2ae7e791f9df16f230459729fcad28d18af73c7 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 29 Jan 2026 11:55:48 -0500 Subject: [PATCH 05/14] Remove a lot of "legacy" functions --- app/commands/escalate/directActions.ts | 19 +- app/commands/escalate/handlers.ts | 449 ++++++++++++------------- app/commands/report/automodLog.ts | 9 - app/commands/report/modActionLog.ts | 9 - app/commands/track.ts | 15 +- app/models/reportedMessages.ts | 17 +- 6 files changed, 238 insertions(+), 280 deletions(-) diff --git a/app/commands/escalate/directActions.ts b/app/commands/escalate/directActions.ts index c3e4e22c..11187fe2 100644 --- a/app/commands/escalate/directActions.ts +++ b/app/commands/escalate/directActions.ts @@ -10,7 +10,7 @@ import { logEffect } from "#~/effects/observability"; import { hasModRole } from "#~/helpers/discord"; import { applyRestriction, ban, kick, timeout } from "#~/models/discord.server"; import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; -import { deleteAllReportedForUserEffect } from "#~/models/reportedMessages"; +import { deleteAllReportedForUser } from "#~/models/reportedMessages"; export interface DeleteMessagesResult { deleted: number; @@ -22,9 +22,7 @@ export interface DeleteMessagesResult { * Delete all reported messages for a user. * Requires ManageMessages permission. */ -export const deleteMessagesEffect = ( - interaction: MessageComponentInteraction, -) => +export const deleteMessages = (interaction: MessageComponentInteraction) => Effect.gen(function* () { const reportedUserId = interaction.customId.split("|")[1]; const guildId = interaction.guildId!; @@ -43,10 +41,7 @@ export const deleteMessagesEffect = ( } // Delete messages - const result = yield* deleteAllReportedForUserEffect( - reportedUserId, - guildId, - ); + const result = yield* deleteAllReportedForUser(reportedUserId, guildId); yield* logEffect("info", "DirectActions", "Deleted reported messages", { reportedUserId, @@ -76,7 +71,7 @@ export interface ModActionResult { * Kick a user from the guild. * Requires moderator role. */ -export const kickUserEffect = (interaction: MessageComponentInteraction) => +export const kickUser = (interaction: MessageComponentInteraction) => Effect.gen(function* () { const reportedUserId = interaction.customId.split("|")[1]; const guildId = interaction.guildId!; @@ -129,7 +124,7 @@ export const kickUserEffect = (interaction: MessageComponentInteraction) => * Ban a user from the guild. * Requires moderator role. */ -export const banUserEffect = (interaction: MessageComponentInteraction) => +export const banUser = (interaction: MessageComponentInteraction) => Effect.gen(function* () { const reportedUserId = interaction.customId.split("|")[1]; const guildId = interaction.guildId!; @@ -181,7 +176,7 @@ export const banUserEffect = (interaction: MessageComponentInteraction) => * Apply restriction role to a user. * Requires moderator role. */ -export const restrictUserEffect = (interaction: MessageComponentInteraction) => +export const restrictUser = (interaction: MessageComponentInteraction) => Effect.gen(function* () { const reportedUserId = interaction.customId.split("|")[1]; const guildId = interaction.guildId!; @@ -237,7 +232,7 @@ export const restrictUserEffect = (interaction: MessageComponentInteraction) => * Timeout a user for 12 hours. * Requires moderator role. */ -export const timeoutUserEffect = (interaction: MessageComponentInteraction) => +export const timeoutUser = (interaction: MessageComponentInteraction) => Effect.gen(function* () { const reportedUserId = interaction.customId.split("|")[1]; const guildId = interaction.guildId!; diff --git a/app/commands/escalate/handlers.ts b/app/commands/escalate/handlers.ts index 29a7d5c1..f1265b48 100644 --- a/app/commands/escalate/handlers.ts +++ b/app/commands/escalate/handlers.ts @@ -15,11 +15,11 @@ import { } from "#~/helpers/modResponse"; import { - banUserEffect, - deleteMessagesEffect, - kickUserEffect, - restrictUserEffect, - timeoutUserEffect, + banUser, + deleteMessages, + kickUser, + restrictUser, + timeoutUser, } from "./directActions"; import { createEscalationEffect, upgradeToMajorityEffect } from "./escalate"; import { expediteEffect } from "./expedite"; @@ -32,222 +32,6 @@ import { } from "./strings"; import { voteEffect } from "./vote"; -const deleteMessages = (interaction: MessageComponentInteraction) => - Effect.gen(function* () { - yield* Effect.tryPromise(() => interaction.deferReply()); - - const result = yield* deleteMessagesEffect(interaction); - - yield* Effect.tryPromise(() => - interaction.editReply( - `Messages deleted by ${result.deletedBy} (${result.deleted}/${result.total} successful)`, - ), - ); - }).pipe( - Effect.provide(DatabaseLayer), - Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect( - "error", - "EscalationHandlers", - "Error deleting messages", - { - error: - error instanceof Error ? error.message : JSON.stringify(error), - }, - ); - if (errorObj._tag === "NotAuthorizedError") { - yield* Effect.tryPromise(() => - interaction.editReply({ content: "Insufficient permissions" }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.editReply({ content: "Failed to delete messages" }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), - ), - Effect.withSpan("escalation-deleteMessages", { - attributes: { guildId: interaction.guildId, userId: interaction.user.id }, - }), - ); - -const kickUser = (interaction: MessageComponentInteraction) => - Effect.gen(function* () { - const reportedUserId = interaction.customId.split("|")[1]; - - const result = yield* kickUserEffect(interaction); - - yield* Effect.tryPromise(() => - interaction.reply(`<@${reportedUserId}> kicked by ${result.actionBy}`), - ); - }).pipe( - Effect.provide(DatabaseLayer), - Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect("error", "EscalationHandlers", "Error kicking user", { - error: error instanceof Error ? error.message : JSON.stringify(error), - }); - if (errorObj._tag === "NotAuthorizedError") { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Failed to kick user", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), - ), - Effect.withSpan("escalation-kickUser", { - attributes: { guildId: interaction.guildId, userId: interaction.user.id }, - }), - ); - -const banUser = (interaction: MessageComponentInteraction) => - Effect.gen(function* () { - const reportedUserId = interaction.customId.split("|")[1]; - - const result = yield* banUserEffect(interaction); - - yield* Effect.tryPromise(() => - interaction.reply(`<@${reportedUserId}> banned by ${result.actionBy}`), - ); - }).pipe( - Effect.provide(DatabaseLayer), - Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect("error", "EscalationHandlers", "Error banning user", { - error: error instanceof Error ? error.message : JSON.stringify(error), - }); - if (errorObj._tag === "NotAuthorizedError") { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Failed to ban user", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), - ), - Effect.withSpan("escalation-banUser", { - attributes: { guildId: interaction.guildId, userId: interaction.user.id }, - }), - ); - -const restrictUser = (interaction: MessageComponentInteraction) => - Effect.gen(function* () { - const reportedUserId = interaction.customId.split("|")[1]; - - const result = yield* restrictUserEffect(interaction); - - yield* Effect.tryPromise(() => - interaction.reply( - `<@${reportedUserId}> restricted by ${result.actionBy}`, - ), - ); - }).pipe( - Effect.provide(DatabaseLayer), - Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect( - "error", - "EscalationHandlers", - "Error restricting user", - { - error: - error instanceof Error ? error.message : JSON.stringify(error), - }, - ); - if (errorObj._tag === "NotAuthorizedError") { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Failed to restrict user", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), - ), - Effect.withSpan("escalation-restrictUser", { - attributes: { guildId: interaction.guildId, userId: interaction.user.id }, - }), - ); - -const timeoutUser = (interaction: MessageComponentInteraction) => - Effect.gen(function* () { - const reportedUserId = interaction.customId.split("|")[1]; - - const result = yield* timeoutUserEffect(interaction); - - yield* Effect.tryPromise(() => - interaction.reply(`<@${reportedUserId}> timed out by ${result.actionBy}`), - ); - }).pipe( - Effect.provide(DatabaseLayer), - Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect( - "error", - "EscalationHandlers", - "Error timing out user", - { - error: - error instanceof Error ? error.message : JSON.stringify(error), - }, - ); - if (errorObj._tag === "NotAuthorizedError") { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Failed to timeout user", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), - ), - Effect.withSpan("escalation-timeoutUser", { - attributes: { guildId: interaction.guildId, userId: interaction.user.id }, - }), - ); - const vote = (resolution: Resolution) => (interaction: MessageComponentInteraction) => Effect.gen(function* () { @@ -494,11 +278,224 @@ const escalate = (interaction: MessageComponentInteraction) => export const EscalationHandlers = { // Direct action commands (no voting) - delete: deleteMessages, - kick: kickUser, - ban: banUser, - restrict: restrictUser, - timeout: timeoutUser, + delete: (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + yield* Effect.tryPromise(() => interaction.deferReply()); + + const result = yield* deleteMessages(interaction); + + yield* Effect.tryPromise(() => + interaction.editReply( + `Messages deleted by ${result.deletedBy} (${result.deleted}/${result.total} successful)`, + ), + ); + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchTag("NotAuthorizedError", () => + Effect.tryPromise(() => + interaction.editReply({ content: "Insufficient permissions" }), + ).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchAll((error) => + logEffect("error", "EscalationHandlers", "Error deleting messages", { + error, + }).pipe(() => + Effect.tryPromise(() => + interaction.editReply({ content: "Failed to delete messages" }), + ).pipe(Effect.catchAll(() => Effect.void)), + ), + ), + Effect.withSpan("escalation-deleteMessages", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), + ), + kick: (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const reportedUserId = interaction.customId.split("|")[1]; + + const result = yield* kickUser(interaction); + + yield* Effect.tryPromise(() => + interaction.reply(`<@${reportedUserId}> kicked by ${result.actionBy}`), + ); + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect( + "error", + "EscalationHandlers", + "Error kicking user", + { error }, + ); + if (errorObj._tag === "NotAuthorizedError") { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Failed to kick user", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-kickUser", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), + ), + ban: (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const reportedUserId = interaction.customId.split("|")[1]; + + const result = yield* banUser(interaction); + + yield* Effect.tryPromise(() => + interaction.reply(`<@${reportedUserId}> banned by ${result.actionBy}`), + ); + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect( + "error", + "EscalationHandlers", + "Error banning user", + { error }, + ); + if (errorObj._tag === "NotAuthorizedError") { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Failed to ban user", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-banUser", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), + ), + restrict: (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const reportedUserId = interaction.customId.split("|")[1]; + + const result = yield* restrictUser(interaction); + + yield* Effect.tryPromise(() => + interaction.reply( + `<@${reportedUserId}> restricted by ${result.actionBy}`, + ), + ); + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect( + "error", + "EscalationHandlers", + "Error restricting user", + { error }, + ); + if (errorObj._tag === "NotAuthorizedError") { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Failed to restrict user", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-restrictUser", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), + ), + timeout: (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const reportedUserId = interaction.customId.split("|")[1]; + + const result = yield* timeoutUser(interaction); + + yield* Effect.tryPromise(() => + interaction.reply( + `<@${reportedUserId}> timed out by ${result.actionBy}`, + ), + ); + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + const errorObj = error as { _tag?: string }; + yield* logEffect( + "error", + "EscalationHandlers", + "Error timing out user", + { error }, + ); + if (errorObj._tag === "NotAuthorizedError") { + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + return; + } + + yield* Effect.tryPromise(() => + interaction.reply({ + content: "Failed to timeout user", + flags: [MessageFlags.Ephemeral], + }), + ).pipe(Effect.catchAll(() => Effect.void)); + }), + ), + Effect.withSpan("escalation-timeoutUser", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), + ), // Voting handlers expedite, diff --git a/app/commands/report/automodLog.ts b/app/commands/report/automodLog.ts index 81708556..466d9618 100644 --- a/app/commands/report/automodLog.ts +++ b/app/commands/report/automodLog.ts @@ -1,10 +1,8 @@ import { AutoModerationActionType, type Guild, type User } from "discord.js"; import { Effect } from "effect"; -import { DatabaseLayer } from "#~/Database"; import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk"; import { logEffect } from "#~/effects/observability"; -import { runEffect } from "#~/effects/runtime"; import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { getOrCreateUserThread } from "#~/models/userThreads.ts"; @@ -85,10 +83,3 @@ ${channelMention} by <@${user.id}> (${user.username}) attributes: { userId: user.id, guildId: guild.id, ruleName }, }), ); - -/** - * Logs an automod action when we don't have a full Message object. - * Used when Discord's automod blocks/deletes a message before we can fetch it. - */ -export const logAutomodLegacy = (report: AutomodReport): Promise => - runEffect(Effect.provide(logAutomod(report), DatabaseLayer)); diff --git a/app/commands/report/modActionLog.ts b/app/commands/report/modActionLog.ts index 3f90ecba..6cadbef5 100644 --- a/app/commands/report/modActionLog.ts +++ b/app/commands/report/modActionLog.ts @@ -1,10 +1,8 @@ import { type Guild, type PartialUser, type User } from "discord.js"; import { Effect } from "effect"; -import { DatabaseLayer } from "#~/Database"; import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk"; import { logEffect } from "#~/effects/observability"; -import { runEffect } from "#~/effects/runtime"; import { truncateMessage } from "#~/helpers/string"; import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { getOrCreateUserThread } from "#~/models/userThreads.ts"; @@ -109,10 +107,3 @@ export const logModAction = (report: ModActionReport) => }, }), ); - -/** - * 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/track.ts b/app/commands/track.ts index 71febabd..cd443a9e 100644 --- a/app/commands/track.ts +++ b/app/commands/track.ts @@ -10,7 +10,7 @@ import { } from "discord.js"; import { Effect } from "effect"; -import { logUserMessageLegacy } from "#~/commands/report/userLog.ts"; +import { logUserMessage } from "#~/commands/report/userLog.ts"; import { DatabaseLayer } from "#~/Database.ts"; import { client } from "#~/discord/client.server"; import { logEffect } from "#~/effects/observability.ts"; @@ -49,13 +49,11 @@ export const Command = [ ); } - const { reportId, thread } = yield* Effect.tryPromise(() => - logUserMessageLegacy({ - reason: ReportReasons.track, - message, - staff: user, - }), - ); + const { reportId, thread } = yield* logUserMessage({ + reason: ReportReasons.track, + message, + staff: user, + }); yield* Effect.tryPromise(() => interaction.editReply({ @@ -73,6 +71,7 @@ export const Command = [ }), ); }).pipe( + Effect.provide(DatabaseLayer), Effect.catchAll((error) => Effect.gen(function* () { yield* logEffect("error", "Track", "Error tracking message", { diff --git a/app/models/reportedMessages.ts b/app/models/reportedMessages.ts index 71e680da..7b428667 100644 --- a/app/models/reportedMessages.ts +++ b/app/models/reportedMessages.ts @@ -6,7 +6,6 @@ import { DatabaseLayer, DatabaseService } from "#~/Database"; import type { DB } from "#~/db"; import { client } from "#~/discord/client.server"; import { logEffect } from "#~/effects/observability"; -import { runEffect } from "#~/effects/runtime"; export type ReportedMessage = Selectable; @@ -323,10 +322,7 @@ const deleteSingleMessage = ( * Delete all reported messages for a user. * Uses Effect-native logging throughout. */ -export const deleteAllReportedForUserEffect = ( - userId: string, - guildId: string, -) => +export const deleteAllReportedForUser = (userId: string, guildId: string) => Effect.gen(function* () { const uniqueMessages = yield* Effect.provide( getUniqueNonDeletedMessages(userId, guildId), @@ -378,14 +374,3 @@ export const deleteAllReportedForUserEffect = ( attributes: { userId, guildId }, }), ); - -/** - * Delete all reported messages for a user. - * Legacy wrapper that runs the Effect. - */ -export const deleteAllReportedForUser = (userId: string, guildId: string) => - runEffect( - deleteAllReportedForUserEffect(userId, guildId).pipe( - Effect.provide(DatabaseLayer), - ), - ); From 8448cca73022846315f921afc83f2e2aef22259b Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 29 Jan 2026 12:50:50 -0500 Subject: [PATCH 06/14] Restructure Effect docs to prioritize onboarding over API coverage EFFECT.md rewritten as a "reading and writing Effect code" guide with real codebase examples and file references. EFFECT_REFERENCE.md trimmed to only patterns we use, with outdated syntax fixed. Unused patterns (Streams, Schedules, etc.) moved to new EFFECT_ADVANCED.md. Co-Authored-By: Claude Opus 4.5 --- notes/EFFECT.md | 448 ++++++++++++++++++++++++++--- notes/EFFECT_ADVANCED.md | 197 +++++++++++++ notes/EFFECT_REFERENCE.md | 578 ++++++++++++++------------------------ 3 files changed, 828 insertions(+), 395 deletions(-) create mode 100644 notes/EFFECT_ADVANCED.md diff --git a/notes/EFFECT.md b/notes/EFFECT.md index 7cda42a1..70763e6e 100644 --- a/notes/EFFECT.md +++ b/notes/EFFECT.md @@ -1,38 +1,422 @@ -⏺ Effect-TS Service Implementation Checklist - -Each service follows this pattern: Types → Interface → Implementation → Layer → -Integration, maintaining functional purity while managing real-world side -effects through the Effect system. - -1. Define Domain Types & Errors: Create pure TypeScript interfaces for your - domain models and custom error classes with \_tag discriminators. Keep these - in a separate file (e.g., FlagService.ts) for clean separation. -2. Define the service contract as an interface with methods returning - Effect.Effect. The - never indicates dependencies are resolved internally. -3. Implement Core Business Logic: Write pure functions using - Effect.gen(function* () { ... }) that access dependencies via yield*. Keep - these functions focused on single responsibilities. -4. Integrate External Services: For third-party APIs, use Effect.tryPromise() to - convert promises to Effects, providing structured error handling with your - domain errors. -5. Compose Service Layer: Create the live implementation using Layer.effect() - that yields dependencies and returns a service record satisfying the - interface. -6. Wire Dependencies: Chain layers using .pipe(Layer.provide(...)) to inject - required services into your service layer. -7. Implement Error Recovery: Add retry logic with Effect.retry() and timeout - policies where appropriate. Use Effect.catchTag() for specific error handling - branches. -8. Add Observability: Include structured logging within Effect flows and - consider adding spans for tracing. Use Effect.tap() for side effects like - logging without affecting the flow. - -Use [EFFECT_REFERENCE.md](./EFFECT_REFERENCE.md) when you're planning change to -existing Effects, or designing something new. +# Effect in This Codebase + +This document gets you reading and writing Effect code in this codebase. It +covers the patterns we actually use, with references to real files. For a quick +lookup reference, see [EFFECT_REFERENCE.md](./EFFECT_REFERENCE.md). + +## Reading Effect Code + +### The Mental Model + +Effect is like async/await but with: + +- **Explicit error types** — errors are part of the type signature, not just + `throw` +- **Dependency injection built-in** — services are declared as type parameters + and provided at composition time +- **Composable operations** — everything chains with `.pipe()` and composes with + `yield*` + +The type `Effect.Effect` describes a lazy +computation that: + +- Produces a `Success` value +- May fail with an `Error` +- Requires `Requirements` (services) to run + +### The Core Pattern + +Every Effect operation in this codebase looks like: + +```typescript +export const myHandler = (input: Input) => + Effect.gen(function* () { + // 1. Get dependencies + const service = yield* MyService; + + // 2. Do work (yield* unwraps Effects) + const result = yield* service.doSomething(input); + + // 3. Return value + return result; + }).pipe( + Effect.provide(DatabaseLayer), // Inject dependencies + Effect.catchAll((e) => ...), // Handle errors + Effect.withSpan("myHandler"), // Add tracing + ); +``` + +### What `yield*` Does + +`yield*` is like `await` — it unwraps an Effect and gives you the value: + +- `const user = yield* fetchUser(id)` — user is `User`, not `Effect` +- `const db = yield* DatabaseService` — db is the service implementation +- If the Effect fails, execution stops and the error propagates + +### The `.pipe()` Pattern + +`.pipe()` chains operations left-to-right. Read it top to bottom: + +```typescript +someEffect.pipe( + Effect.map((x) => x + 1), // Transform success value + Effect.catchAll((e) => ...), // Handle errors + Effect.withSpan("name"), // Add tracing +); +``` + +### How to Trace Through Existing Code + +When reading a function like `processEscalationEffect` in +`app/commands/escalate/escalationResolver.ts`: + +1. Find the `Effect.gen(function* () { ... })` — this is the body +2. Each `yield*` is an async step that can fail +3. Look at the `.pipe(...)` at the end for error handling and tracing +4. Follow `yield* SomeService` to find what services are used +5. Check the calling code for `Effect.provide(...)` to see where dependencies + come from + +## Patterns We Use + +### Error Handling + +We use tagged errors for type-safe error handling. Each error has a `_tag` field +that TypeScript uses for discrimination: + +```typescript +// Define errors (see app/effects/errors.ts) +export class NotFoundError extends Data.TaggedError("NotFoundError")<{ + resource: string; + id: string; +}> {} +``` + +Catch specific errors by tag: + +```typescript +effect.pipe( + Effect.catchTag("NotFoundError", (e) => + Effect.succeed(defaultValue), + ), +); +``` + +Catch all errors uniformly: + +```typescript +effect.pipe( + Effect.catchAll((error) => + logEffect("error", "Handler", "Operation failed", { + error: String(error), + }), + ), +); +``` + +**See:** `app/effects/errors.ts` for all error types + +### Parallel Operations + +Use `Effect.all` with `withConcurrency("unbounded")` for independent operations: + +```typescript +const [settings, reportedUser, guild, channel] = yield* Effect.all([ + fetchSettingsEffect(escalation.guild_id, [SETTINGS.modLog]), + fetchUserOrNull(client, escalation.reported_user_id), + fetchGuild(client, escalation.guild_id), + fetchChannelFromClient(client, escalation.thread_id), +]).pipe(Effect.withConcurrency("unbounded")); +``` + +**See:** `app/commands/escalate/escalationResolver.ts:94-102` + +### Sequential Operations (Rate-Limited) + +Use `Effect.forEach` when items must be processed one at a time (e.g., Discord +rate limits): + +```typescript +const results = yield* Effect.forEach(due, (escalation) => + processEscalationEffect(client, escalation).pipe( + Effect.catchAll((error) => + logEffect("error", "EscalationResolver", "Error processing escalation", { + escalationId: escalation.id, + error: String(error), + }), + ), + ), +); +``` + +**See:** `app/commands/escalate/escalationResolver.ts:186-197` + +### Services & Dependency Injection + +Services have three parts: an interface, a tag, and a live implementation. + +**1. Define the interface:** + +```typescript +export interface IEscalationService { + readonly getEscalation: ( + id: string, + ) => Effect.Effect; + // ... +} +``` + +**2. Create the tag (using class pattern):** + +```typescript +export class EscalationService extends Context.Tag("EscalationService")< + EscalationService, + IEscalationService +>() {} +``` + +**3. Implement with Layer.effect:** + +```typescript +export const EscalationServiceLive = Layer.effect( + EscalationService, + Effect.gen(function* () { + const db = yield* DatabaseService; + return { + getEscalation: (id) => + Effect.gen(function* () { + // implementation using db + }), + }; + }), +).pipe(Layer.provide(DatabaseLayer)); +``` + +**4. Use in handlers:** + +```typescript +const escalationService = yield* EscalationService; +const votes = yield* escalationService.getVotesForEscalation(escalation.id); +``` + +**See:** `app/commands/escalate/service.ts` (full service), +`app/Database.ts` (simpler service) + +### Observability + +**Tracing with `withSpan`:** + +```typescript +Effect.withSpan("operationName", { + attributes: { + escalationId: escalation.id, + resolution, + }, +}); +``` + +**Structured logging with `logEffect`:** + +```typescript +yield* logEffect("info", "ServiceName", "What happened", { + key: "contextual data", +}); +``` + +**Annotating the current span:** + +```typescript +yield* Effect.annotateCurrentSpan({ processed: due.length }); +``` + +**See:** `app/effects/observability.ts` for `logEffect` and `tapLog` + +### Promise Integration + +Use `Effect.tryPromise` to wrap external Promise-based APIs: + +```typescript +export const fetchGuild = (client: Client, guildId: string) => + Effect.tryPromise({ + try: () => client.guilds.fetch(guildId), + catch: (error) => + new DiscordApiError({ operation: "fetchGuild", cause: error }), + }); +``` + +For cases where failure is acceptable (returns null): + +```typescript +export const fetchMemberOrNull = (guild: Guild, userId: string) => + Effect.tryPromise({ + try: () => guild.members.fetch(userId), + catch: () => null, + }).pipe(Effect.catchAll(() => Effect.succeed(null))); +``` + +**See:** `app/effects/discordSdk.ts` for all Discord SDK wrappers + +## Writing New Code + +### Checklist + +1. **Define errors** — add to `app/effects/errors.ts` using `Data.TaggedError` +2. **Define service interface** — see `app/commands/escalate/service.ts` for the + pattern +3. **Implement with `Effect.gen`** — see `escalationResolver.ts` for complex + examples +4. **Create a Layer** — see `app/Database.ts` for Layer composition +5. **Add observability** — use `Effect.withSpan()` on every public function, use + `logEffect()` for important events + +### Template: New Handler + +```typescript +import { Effect } from "effect"; +import { DatabaseLayer } from "#~/Database"; +import { logEffect } from "#~/effects/observability"; + +export const handleMyCommand = (input: Input) => + Effect.gen(function* () { + // Get services + const db = yield* DatabaseService; + + // Do work + const result = yield* db.selectFrom("table").selectAll().where(...); + + yield* logEffect("info", "MyCommand", "Handled command", { + inputId: input.id, + }); + + return result; + }).pipe( + Effect.catchAll((error) => + logEffect("error", "MyCommand", "Command failed", { + error: String(error), + }), + ), + Effect.withSpan("handleMyCommand"), + Effect.provide(DatabaseLayer), + ); +``` + +### Template: New Service + +```typescript +import { Context, Effect, Layer } from "effect"; +import { DatabaseLayer, DatabaseService } from "#~/Database"; + +// 1. Interface +export interface IMyService { + readonly doThing: (id: string) => Effect.Effect; +} + +// 2. Tag +export class MyService extends Context.Tag("MyService")< + MyService, + IMyService +>() {} + +// 3. Implementation +export const MyServiceLive = Layer.effect( + MyService, + Effect.gen(function* () { + const db = yield* DatabaseService; + return { + doThing: (id) => + Effect.gen(function* () { + // Use db here + }).pipe( + Effect.withSpan("doThing", { attributes: { id } }), + ), + }; + }), +).pipe(Layer.provide(DatabaseLayer)); +``` + +## Anti-Patterns + +### Don't nest `Effect.runPromise` + +```typescript +// WRONG — breaks the Effect chain, loses error types +const bad = Effect.gen(function* () { + const result = yield* Effect.tryPromise(async () => { + const data = await Effect.runPromise(someEffect); + return processData(data); + }); +}); + +// RIGHT — keep everything in the Effect chain +const good = Effect.gen(function* () { + const data = yield* someEffect; + return processData(data); +}); +``` + +### Don't create services in business logic + +```typescript +// WRONG — bypasses dependency injection +const bad = Effect.gen(function* () { + const db = new DatabaseService(); + return yield* db.getUser("123"); +}); + +// RIGHT — use yield* to get injected services +const good = Effect.gen(function* () { + const db = yield* DatabaseService; + return yield* db.getUser("123"); +}); +``` + +### Don't ignore error types + +```typescript +// WRONG — swallows all error information +const bad = effect.pipe( + Effect.catchAll(() => Effect.succeed(null)), +); + +// RIGHT — handle errors specifically +const good = effect.pipe( + Effect.catchTag("NotFoundError", () => Effect.succeed(defaultValue)), +); +``` + +### Don't wrap pure functions in Effect + +```typescript +// WRONG — unnecessary Effect wrapper +const add = (a: number, b: number): Effect.Effect => + Effect.succeed(a + b); + +// RIGHT — keep pure functions pure +const add = (a: number, b: number): number => a + b; +``` + +## Model Files + +These are the best files to study when learning how Effect is used here: + +- **`app/commands/escalate/escalationResolver.ts`** — parallel operations, + sequential processing, error recovery, span annotations +- **`app/effects/discordSdk.ts`** — Promise wrapping, error mapping, + null-safe variants +- **`app/commands/escalate/service.ts`** — full service pattern with interface, + tag, Layer, and dependency injection +- **`app/Database.ts`** — Layer composition, merging independent layers +- **`app/effects/observability.ts`** — `logEffect` and `tapLog` utilities +- **`app/effects/errors.ts`** — `Data.TaggedError` definitions + +## Further Reading Much of the Effect-TS docs are [online in a compacted form](https://effect.website/llms-small.txt). The unabridged versions of the documentation are [indexed here](https://effect.website/llms.txt); you can retrieve a URL with more detailed information from there. + +For patterns not used in this codebase (Streams, Schedules, etc.), see +[EFFECT_ADVANCED.md](./EFFECT_ADVANCED.md). diff --git a/notes/EFFECT_ADVANCED.md b/notes/EFFECT_ADVANCED.md new file mode 100644 index 00000000..e1f426aa --- /dev/null +++ b/notes/EFFECT_ADVANCED.md @@ -0,0 +1,197 @@ +# Effect Advanced Patterns + +These patterns are **not currently used** in this codebase but are documented +here for future reference. For patterns we actually use, see +[EFFECT.md](./EFFECT.md) and [EFFECT_REFERENCE.md](./EFFECT_REFERENCE.md). + +## Stream Processing + +For large or potentially infinite data pipelines: + +```typescript +import { Stream, Sink } from "effect"; + +// Create from array +const stream = Stream.fromIterable(items); + +// Process with effects +const processed = stream.pipe( + Stream.mapEffect(processItem), + Stream.buffer(100), // Backpressure control + Stream.run(Sink.collectAll()), +); +``` + +### Stream Sources + +| Data Source | Create With | Process With | +| --------------- | --------------------------- | ---------------------------------- | +| Array | `Stream.fromIterable` | `Stream.map`, `Stream.filter` | +| Async iterator | `Stream.fromAsyncIterable` | `Stream.mapEffect` | +| Events | `Stream.async` | `Stream.buffer`, `Stream.debounce` | +| Intervals | `Stream.repeatEffect` | `Stream.take`, `Stream.takeWhile` | +| File lines | `Stream.fromReadableStream` | `Stream.transduce` | + +### When to Consider Streams + +- Processing more than ~1000 items +- Real-time event processing +- Data that arrives over time (not all at once) +- Need backpressure control + +## Sink Patterns + +Custom accumulation logic for Streams: + +```typescript +import { Sink } from "effect"; + +// Collect all results +const collectAll = Sink.collectAll(); + +// Custom fold with stop condition +const customSink = Sink.fold( + 0, // Initial state + (sum, n) => sum < 100, // Continue condition + (sum, n) => sum + n, // Accumulator +); +``` + +## Schedule Combinators + +For retry and repeat logic beyond simple patterns: + +| Pattern | Schedule | Use Case | +| ------------------- | ------------------------------------ | --------------------------- | +| Fixed delay | `Schedule.fixed("1 second")` | Polling, heartbeats | +| Exponential backoff | `Schedule.exponential("100 millis")` | Retry with increasing delay | +| Limited attempts | `Schedule.recurs(5)` | Max retry count | +| Fibonacci delays | `Schedule.fibonacci("100 millis")` | Gradual backoff | +| Cron-like | `Schedule.cron("0 */15 * * * *")` | Scheduled tasks | +| Jittered | `Schedule.jittered()` | Avoid thundering herd | + +### Combining Schedules + +```typescript +// Exponential backoff with max 5 retries +const policy = Schedule.exponential("100 millis").pipe( + Schedule.intersect(Schedule.recurs(5)), +); + +effect.pipe(Effect.retry(policy)); +``` + +## Config Module + +Type-safe configuration from environment variables: + +```typescript +import { Config } from "effect"; + +const config = yield* Config.struct({ + cacheEnabled: Config.boolean("CACHE_ENABLED"), + timeout: Config.duration("USER_TIMEOUT"), + port: Config.number("PORT"), +}); +``` + +## Resource Management + +Safe acquire/use/release pattern: + +```typescript +const managed = Effect.acquireUseRelease( + // Acquire + openConnection(), + // Use + (conn) => doWork(conn), + // Release (always runs) + (conn) => closeConnection(conn), +); +``` + +## Queue Patterns + +Bounded and unbounded queues for producer/consumer patterns: + +```typescript +import { Queue } from "effect"; + +const queue = yield* Queue.bounded(100); + +// Producer +yield* Queue.offer(queue, task); + +// Consumer +const task = yield* Queue.take(queue); +``` + +## Ref / TRef State Management + +Mutable references for state within Effect: + +```typescript +import { Ref } from "effect"; + +const counter = yield* Ref.make(0); +yield* Ref.update(counter, (n) => n + 1); +const value = yield* Ref.get(counter); +``` + +## Fiber Supervision + +For advanced concurrent execution control: + +```typescript +// Fork a background task +const fiber = yield* Effect.fork(backgroundTask); + +// Wait for it later +const result = yield* Fiber.join(fiber); + +// Or interrupt it +yield* Fiber.interrupt(fiber); +``` + +## Migration: Callback-based Code + +```typescript +// Convert callback-based APIs to Effect +const readFile = (path: string): Effect.Effect => + Effect.async((resume) => { + fs.readFile(path, "utf8", (err, data) => { + if (err) resume(Effect.fail(new FileError(err.message))); + else resume(Effect.succeed(data)); + }); + }); +``` + +## Migration: Class-based Services + +```typescript +// Before: class-based +class UserService { + constructor(private db: Database) {} + async getUser(id: string): Promise { ... } +} + +// After: Effect service +interface IUserService { + readonly getUser: (id: string) => Effect.Effect; +} + +export class UserService extends Context.Tag("UserService")< + UserService, + IUserService +>() {} + +export const UserServiceLive = Layer.effect( + UserService, + Effect.gen(function* () { + const db = yield* DatabaseService; + return { + getUser: (id) => Effect.gen(function* () { ... }), + }; + }), +); +``` diff --git a/notes/EFFECT_REFERENCE.md b/notes/EFFECT_REFERENCE.md index 89a24ed3..bfaa177e 100644 --- a/notes/EFFECT_REFERENCE.md +++ b/notes/EFFECT_REFERENCE.md @@ -1,460 +1,312 @@ -# Effect-TS Construct Decision Guide +# Effect Quick Reference -## Quick Decision Tree +Quick lookup for patterns used in this codebase. For onboarding and +explanations, see [EFFECT.md](./EFFECT.md). For patterns not used here +(Streams, Schedules, etc.), see [EFFECT_ADVANCED.md](./EFFECT_ADVANCED.md). -### 🚦 Start Here: What Are You Building? +## Error Handling -``` -Are you handling side effects? -├─ YES → Use Effect -│ └─ Do you need dependencies? -│ ├─ YES → Use Services + Layers -│ └─ NO → Pure Effect -└─ NO → Use pure functions with Schema validation -``` - -### 🔄 Data Processing Decision Path +### Defining Errors -``` -What kind of data are you processing? -├─ Single values → Effect.Effect -├─ Collections (transform all at once) → Array + Effect.forEach -├─ Large datasets (process incrementally) → Stream -├─ Real-time events → Stream + Sink -└─ Need backpressure control → Stream with buffers -``` +All errors use `Data.TaggedError` for type-safe discrimination: -### ⏰ Timing & Scheduling Decision Path +```typescript +import { Data } from "effect"; -``` -Do you need timing control? -├─ One-time delay → Effect.sleep -├─ Retry failed operations → Effect.retry + Schedule -├─ Repeat successful operations → Effect.repeat + Schedule -├─ Complex recurring patterns → Schedule combinators -└─ Background tasks → Effect.fork +export class MyError extends Data.TaggedError("MyError")<{ + field: string; + message: string; +}> {} ``` -## Construct Selection Matrix +**File:** `app/effects/errors.ts` -| **Problem** | **Primary Construct** | **Supporting Constructs** | **When to Use** | -| ----------------------- | --------------------------- | --------------------------- | -------------------------------------- | -| **API Calls** | `Effect.tryPromise` | `Schedule` for retries | Converting promises to Effects | -| **Database Operations** | `Effect.gen + Service` | `Layer` for connection pool | Type-safe DB with dependency injection | -| **Configuration** | `Config` module | `Layer.setConfigProvider` | Type-safe env vars with validation | -| **Background Jobs** | `Effect.fork` | `Queue`, `Schedule` | Fire-and-forget or periodic tasks | -| **Rate Limiting** | `Schedule.spaced` | `Effect.repeat` | Controlling execution frequency | -| **Circuit Breaking** | `Schedule.intersect` | `Effect.retry` | Preventing cascade failures | -| **Event Processing** | `Stream` | `Sink`, `Schedule` | Real-time data pipelines | -| **Resource Management** | `Effect.acquireUseRelease` | `Scope`, `Layer` | Safe cleanup of resources | -| **Validation** | `Schema` | `Effect.mapError` | Input/output validation with errors | -| **Metrics/Tracing** | `Metric`, `Effect.withSpan` | `Layer` for providers | Observability and monitoring | +### Our Error Types -## Pattern Matching Guide +| Error | Tag | Used For | +| -------------------------- | -------------------------- | ---------------------------------- | +| `NotFoundError` | `"NotFoundError"` | Missing DB records | +| `NotAuthorizedError` | `"NotAuthorizedError"` | Permission failures | +| `DiscordApiError` | `"DiscordApiError"` | Discord SDK call failures | +| `StripeApiError` | `"StripeApiError"` | Stripe SDK call failures | +| `ValidationError` | `"ValidationError"` | Input validation failures | +| `ConfigError` | `"ConfigError"` | Missing/invalid configuration | +| `DatabaseCorruptionError` | `"DatabaseCorruptionError"`| Integrity check failures | +| `AlreadyResolvedError` | `"AlreadyResolvedError"` | Double-resolution attempts | +| `NoLeaderError` | `"NoLeaderError"` | Vote tallying with no clear winner | +| `ResolutionExecutionError` | `"ResolutionExecutionError"`| Mod action execution failures | +| `SqlError` | `"SqlError"` | Database query failures | -### Error Handling Patterns - -#### ✅ **When to Use Effect.catchAll vs Effect.catchTag** +### catchAll vs catchTag ```typescript -// Use catchAll for handling any error -const handleAnyError = effect.pipe( - Effect.catchAll((error) => Effect.succeed(defaultValue)), +// catchAll — handle any error uniformly +effect.pipe( + Effect.catchAll((error) => Effect.succeed(fallback)), ); -// Use catchTag for specific error types -const handleSpecificErrors = effect.pipe( - Effect.catchTags({ - ValidationError: (error) => Effect.succeed(correctedValue), - NetworkError: (error) => Effect.retry(Schedule.exponential("1 second")), - }), +// catchTag — handle specific error types differently +effect.pipe( + Effect.catchTag("NotFoundError", (e) => + Effect.succeed(defaultValue), + ), + Effect.catchTag("SqlError", (e) => + logEffect("error", "Handler", "DB error", { error: e.message }), + ), ); ``` -**Decision Rule**: Use `catchTag` when you need different recovery strategies -per error type, `catchAll` for uniform handling. - -### Concurrency Patterns - -#### ✅ **Sequential vs Parallel vs Racing** +### Error Recovery in Pipelines ```typescript -// Sequential: One after another (dependencies) -const sequential = Effect.gen(function* () { - const user = yield* getUser(id); - const profile = yield* getProfile(user.profileId); // Depends on user - return { user, profile }; -}); +// Catch and recover inside Effect.forEach or Effect.all +yield* Effect.forEach(items, (item) => + processItem(item).pipe( + Effect.catchAll((error) => + logEffect("error", "Handler", "Item failed", { + itemId: item.id, + error: String(error), + }), + ), + ), +); +``` -// Parallel: Independent operations -const parallel = Effect.gen(function* () { - const [user, settings] = yield* Effect.all([getUser(id), getSettings(id)], { - concurrency: "unbounded", - }); - return { user, settings }; -}); +## Concurrency -// Racing: First successful result -const racing = Effect.race(getUserFromCache(id), getUserFromDB(id)); -``` +### Parallel: Effect.all + withConcurrency -**Decision Rule**: +Use for independent operations that don't depend on each other: -- Sequential when operations depend on each other -- Parallel when operations are independent -- Racing when you want the fastest successful result +```typescript +const [a, b, c] = yield* Effect.all([ + fetchA(), + fetchB(), + fetchC(), +]).pipe(Effect.withConcurrency("unbounded")); +``` -### Data Processing Patterns +### Sequential: Effect.forEach -#### ✅ **Array vs Stream vs Sink** +Default behavior — processes items one at a time. Use when rate limits apply: ```typescript -// Array processing: Small, finite datasets -const processArray = (items: Item[]) => - Effect.forEach(items, processItem, { concurrency: 5 }); - -// Stream processing: Large, potentially infinite data -const processStream = Stream.fromIterable(items).pipe( - Stream.mapEffect(processItem), - Stream.buffer(100), // Backpressure control - Stream.run(Sink.collectAll()), -); - -// Sink usage: Custom accumulation logic -const customSink = Sink.fold( - 0, // Initial state - (sum, n) => sum < 100, // Continue condition - (sum, n) => sum + n, // Accumulator +const results = yield* Effect.forEach(items, (item) => + processItem(item), ); ``` -**Decision Rule**: +### When to Use Which -- Array processing for < 1000 items -- Stream for large datasets or real-time processing -- Custom Sinks when you need specialized accumulation +| Scenario | Use | +| --------------------------- | ------------------------------------------ | +| Independent API calls | `Effect.all` + `withConcurrency` | +| Discord API calls in a loop | `Effect.forEach` (sequential, rate limits) | +| Operations with deps | Sequential `yield*` in `Effect.gen` | -## Service Architecture Decision Guide +## Services -### 🏗️ Service Design Patterns +### Context.Tag Class Pattern -#### Simple Service (No Dependencies) +This is the current pattern — use this, not `Context.GenericTag`: ```typescript -// Use when: Pure business logic, no external dependencies -interface ICalculatorService { - readonly add: (a: number, b: number) => Effect.Effect; - readonly divide: ( - a: number, - b: number, - ) => Effect.Effect; -} +export class MyService extends Context.Tag("MyService")< + MyService, + IMyService +>() {} ``` -#### Service with Dependencies +### Layer.effect Implementation ```typescript -// Use when: Needs other services, external resources -interface IUserService { - readonly getUser: ( - id: UserId, - ) => Effect.Effect; -} - -export const UserServiceLive = Layer.effect( - UserService, +export const MyServiceLive = Layer.effect( + MyService, Effect.gen(function* () { - const db = yield* DatabaseService; // Dependency injection - const cache = yield* CacheService; - // Implementation uses both services + const dep = yield* SomeDependency; + return { + method: (arg) => + Effect.gen(function* () { + // use dep + }).pipe(Effect.withSpan("method")), + }; }), +).pipe(Layer.provide(DependencyLayer)); +``` + +### Layer Composition + +```typescript +// Merge independent layers +const AppLayer = Layer.mergeAll(LayerA, LayerB, LayerC); + +// Chain dependent layers +const ServiceLayer = Layer.effect(MyService, impl).pipe( + Layer.provide(DependencyLayer), ); ``` -#### Service with Configuration +**Files:** `app/Database.ts:33-38` (mergeAll), `app/commands/escalate/service.ts:429` (provide) + +## Observability + +### withSpan + +Add to every public function for tracing: ```typescript -// Use when: Behavior varies by environment -const UserServiceLive = Layer.effect( - UserService, - Effect.gen(function* () { - const config = yield* Config.struct({ - cacheEnabled: Config.boolean("CACHE_ENABLED"), - timeout: Config.duration("USER_TIMEOUT"), - }); - // Implementation adapts to config +myEffect.pipe( + Effect.withSpan("operationName", { + attributes: { key: "value" }, }), ); ``` -### 🔧 Layer Composition Strategies +### logEffect -#### Merge Strategy: Independent Services +Structured logging at different levels: ```typescript -// Use when: Services don't depend on each other -const AppLayer = Layer.mergeAll(DatabaseLive, CacheLive, MetricsLive); +yield* logEffect("info", "ServiceName", "What happened", { + contextKey: "value", +}); +// Levels: "debug" | "info" | "warn" | "error" ``` -#### Chain Strategy: Sequential Dependencies +### tapLog + +Log without affecting the pipeline value: ```typescript -// Use when: Services have linear dependencies -const AppLayer = DatabaseLive.pipe( - Layer.provide(CacheLive), - Layer.provide(UserServiceLive), +import { tapLog } from "#~/effects/observability"; + +const pipeline = fetchUser(id).pipe( + tapLog("info", "UserService", "User fetched", (user) => ({ + userId: user.id, + })), ); ``` -#### Complex Dependencies: Explicit Wiring +### annotateCurrentSpan + +Add data to the current tracing span: ```typescript -// Use when: Complex dependency graph -const AppLayer = Layer.make( - DatabaseLive, - CacheLive.pipe(Layer.provide(DatabaseLive)), - UserServiceLive.pipe(Layer.provide(Layer.merge(DatabaseLive, CacheLive))), -); +yield* Effect.annotateCurrentSpan({ processed: items.length }); ``` -## Migration Strategies +**File:** `app/effects/observability.ts` + +## Discord SDK Helpers -### 🔄 Converting Existing Code to Effect +Wrappers for Discord.js operations that provide consistent error handling. -#### Promise-based Code +### Available Functions + +| Function | Returns | Error | +| ----------------------- | ---------------------------------- | ------------- | +| `fetchGuild` | `Guild` | `DiscordApiError` | +| `fetchChannel` | `Channel \| null` | `DiscordApiError` | +| `fetchChannelFromClient`| `T` (generic) | `DiscordApiError` | +| `fetchMember` | `GuildMember` | `DiscordApiError` | +| `fetchMemberOrNull` | `GuildMember \| null` | never | +| `fetchUser` | `User` | `DiscordApiError` | +| `fetchUserOrNull` | `User \| null` | never | +| `fetchMessage` | `Message` | `DiscordApiError` | +| `sendMessage` | `Message` | `DiscordApiError` | +| `editMessage` | `Message` | `DiscordApiError` | +| `forwardMessageSafe` | `void` | never (logs) | +| `replyAndForwardSafe` | `Message \| null` | never (logs) | +| `resolveMessagePartial` | `Message` | `DiscordApiError` | + +### Pattern: Adding a New Helper + +Follow the existing pattern — wrap with `tryPromise`, map error to `DiscordApiError`: ```typescript -// Before: Promise-based -const fetchUser = async (id: string): Promise => { - const response = await fetch(`/users/${id}`); - if (!response.ok) throw new Error("User not found"); - return response.json(); -}; - -// After: Effect-based -const fetchUser = (id: string): Effect.Effect => +export const myNewHelper = (guild: Guild, arg: string) => Effect.tryPromise({ - try: () => - fetch(`/users/${id}`).then((r) => { - if (!r.ok) throw new Error("User not found"); - return r.json(); - }), - catch: (error) => new FetchError(String(error)), + try: () => guild.someMethod(arg), + catch: (error) => + new DiscordApiError({ operation: "myNewHelper", cause: error }), }); ``` -#### Callback-based Code +For null-safe variants, catch and return null: ```typescript -// Before: Callback-based -const readFileCallback = ( - path: string, - callback: (err: Error | null, data: string) => void, -) => { - fs.readFile(path, "utf8", callback); -}; - -// After: Effect-based -const readFile = (path: string): Effect.Effect => - Effect.async((resume) => { - fs.readFile(path, "utf8", (err, data) => { - if (err) resume(Effect.fail(new FileError(err.message))); - else resume(Effect.succeed(data)); - }); - }); +export const myNewHelperOrNull = (guild: Guild, arg: string) => + Effect.tryPromise({ + try: () => guild.someMethod(arg), + catch: () => null, + }).pipe(Effect.catchAll(() => Effect.succeed(null))); ``` -#### Class-based Services - -```typescript -// Before: Class-based -class UserService { - constructor( - private db: Database, - private cache: Cache, - ) {} - - async getUser(id: string): Promise { - // Implementation - } -} - -// After: Effect service -interface IUserService { - readonly getUser: (id: string) => Effect.Effect; -} - -const UserService = Context.GenericTag("UserService"); - -const UserServiceLive = Layer.effect( - UserService, - Effect.gen(function* () { - const db = yield* DatabaseService; - const cache = yield* CacheService; +**File:** `app/effects/discordSdk.ts` - return { - getUser: (id: string) => - Effect.gen(function* () { - // Implementation using db and cache - }), - }; - }), -); -``` +## Database Patterns -## Anti-Patterns to Avoid +### DatabaseService -### ❌ **Don't: Nested Effect.runPromise** +The database is an effectified Kysely instance provided as a service: ```typescript -// Wrong -const badExample = Effect.gen(function* () { - const result = yield* Effect.tryPromise(async () => { - const data = await Effect.runPromise(someEffect); // DON'T DO THIS - return processData(data); - }); -}); - -// Right -const goodExample = Effect.gen(function* () { - const data = yield* someEffect; - const result = yield* processDataEffect(data); - return result; -}); +export class DatabaseService extends Context.Tag("DatabaseService")< + DatabaseService, + EffectKysely +>() {} ``` -### ❌ **Don't: Create Services in Business Logic** +### Querying ```typescript -// Wrong -const badUserFunction = Effect.gen(function* () { - const db = new DatabaseService(); // DON'T CREATE SERVICES HERE - return yield* db.getUser("123"); -}); - -// Right -const goodUserFunction = Effect.gen(function* () { - const db = yield* DatabaseService; // USE DEPENDENCY INJECTION - return yield* db.getUser("123"); -}); +const db = yield* DatabaseService; + +// Select +const rows = yield* db + .selectFrom("table") + .selectAll() + .where("column", "=", value); + +// Insert +yield* db.insertInto("table").values({ ... }); + +// Update +yield* db + .updateTable("table") + .set({ column: newValue }) + .where("id", "=", id); + +// Delete +yield* db + .deleteFrom("table") + .where("id", "=", id); ``` -### ❌ **Don't: Ignore Error Types** +### Layer Setup ```typescript -// Wrong - loses error information -const badErrorHandling = effect.pipe( - Effect.catchAll(() => Effect.succeed(null)), -); +// Base SQLite client +const SqliteLive = SqliteClient.layer({ filename: databaseUrl }); -// Right - handle errors appropriately -const goodErrorHandling = effect.pipe( - Effect.catchTags({ - NetworkError: () => Effect.retry(Schedule.exponential("1 second")), - ValidationError: (error) => Effect.fail(new UserFacingError(error.message)), - }), +// Kysely service on top of SQLite +const KyselyLive = Layer.effect(DatabaseService, Sqlite.make()).pipe( + Layer.provide(SqliteLive), ); + +// Combined layer provides both +export const DatabaseLayer = Layer.mergeAll(SqliteLive, KyselyLive); ``` -### ❌ **Don't: Use Effect for Pure Computations** +**File:** `app/Database.ts` -```typescript -// Wrong - unnecessary Effect wrapper -const addNumbers = ( - a: number, - b: number, -): Effect.Effect => Effect.succeed(a + b); - -// Right - keep pure functions pure -const addNumbers = (a: number, b: number): number => a + b; - -// Use Effect when you actually have effects -const addNumbersWithLogging = ( - a: number, - b: number, -): Effect.Effect => - Effect.gen(function* () { - yield* Effect.log(`Adding ${a} + ${b}`); - return a + b; - }); -``` +## Effect Constructors Quick Reference -## Quick Reference Charts - -### 🎯 **Effect Constructors Quick Pick** - -| **Need** | **Use** | **Example** | -| ----------------- | ------------------- | ---------------------------------------------- | -| Pure value | `Effect.succeed` | `Effect.succeed(42)` | -| Pure error | `Effect.fail` | `Effect.fail(new MyError())` | -| Sync side effect | `Effect.sync` | `Effect.sync(() => Math.random())` | -| Async side effect | `Effect.tryPromise` | `Effect.tryPromise(() => fetch(url))` | -| Conditional logic | `Effect.if` | `Effect.if(condition, thenEffect, elseEffect)` | -| Loop with effects | `Effect.loop` | `Effect.loop(state, condition, update)` | - -### ⚡ **Schedule Quick Pick** - -| **Pattern** | **Schedule** | **Use Case** | -| ------------------- | ------------------------------------ | --------------------------- | -| Fixed delay | `Schedule.fixed("1 second")` | Polling, heartbeats | -| Exponential backoff | `Schedule.exponential("100 millis")` | Retry with increasing delay | -| Limited attempts | `Schedule.recurs(5)` | Max retry count | -| Fibonacci delays | `Schedule.fibonacci("100 millis")` | Gradual backoff | -| Cron-like | `Schedule.cron("0 */15 * * * *")` | Scheduled tasks | -| Jittered | `Schedule.jittered()` | Avoid thundering herd | - -### 🌊 **Stream Processing Quick Pick** - -| **Data Source** | **Create With** | **Process With** | -| --------------- | --------------------------- | ---------------------------------- | -| Array | `Stream.fromIterable` | `Stream.map`, `Stream.filter` | -| Async iterator | `Stream.fromAsyncIterable` | `Stream.mapEffect` | -| Events | `Stream.async` | `Stream.buffer`, `Stream.debounce` | -| Intervals | `Stream.repeatEffect` | `Stream.take`, `Stream.takeWhile` | -| File lines | `Stream.fromReadableStream` | `Stream.transduce` | - -### 🏛️ **Layer Composition Quick Pick** - -| **Relationship** | **Combinator** | **Example** | -| ---------------- | ---------------- | --------------------------- | -| Independent | `Layer.merge` | `Layer.merge(A, B)` | -| Sequential | `Layer.provide` | `B.pipe(Layer.provide(A))` | -| Multiple | `Layer.mergeAll` | `Layer.mergeAll(A, B, C)` | -| Conditional | `Layer.if` | `Layer.if(condition, A, B)` | - -## Decision Checklist - -### ✅ **Before You Code** - -- [ ] Do I need side effects? → Use Effect -- [ ] Do I need dependencies? → Use Services + Layers -- [ ] Do I need configuration? → Use Config module -- [ ] Do I need error recovery? → Define error types + Schedule -- [ ] Do I need observability? → Add Metrics + Spans -- [ ] Am I processing large data? → Consider Stream -- [ ] Do I need resource cleanup? → Use acquireUseRelease or Scope - -### ✅ **Code Review Checklist** - -- [ ] All effects are properly typed with error types -- [ ] Services use dependency injection, not direct instantiation -- [ ] Resources are cleaned up (no leaks) -- [ ] Error handling is specific, not generic `catchAll` -- [ ] Retry policies are appropriate for the operation -- [ ] Pure functions stay pure (no unnecessary Effects) -- [ ] Configuration is externalized and validated -- [ ] Operations are instrumented for observability - -## Conclusion - -Effect-TS provides powerful primitives, but choosing the right construct for -each situation is crucial for maintainable, performant code. Use this guide to: - -1. **Start with the decision trees** to quickly narrow your options -2. **Consult the selection matrix** for specific problem-construct mappings -3. **Follow the patterns** shown for common scenarios -4. **Avoid the anti-patterns** that lead to brittle code -5. **Use the checklists** to verify your design decisions - -Remember: Effect shines when you embrace its functional paradigm and leverage -its type system to catch errors at compile time rather than runtime. +| Need | Use | Example | +| ----------------- | ------------------- | ------------------------------------- | +| Pure value | `Effect.succeed` | `Effect.succeed(42)` | +| Pure error | `Effect.fail` | `Effect.fail(new MyError(...))` | +| Sync side effect | `Effect.sync` | `Effect.sync(() => Date.now())` | +| Async side effect | `Effect.tryPromise` | `Effect.tryPromise({ try, catch })` | +| Generator body | `Effect.gen` | `Effect.gen(function* () { ... })` | +| Do nothing | `Effect.void` | `Effect.void` | From 6e25ff60a15ac7c089f30038db6bd3cf5389a703 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 29 Jan 2026 13:34:34 -0500 Subject: [PATCH 07/14] Use more idiomatic patterns in escalation handlers --- app/commands/escalate/handlers.ts | 485 ++++++++++++------------------ app/effects/discordSdk.ts | 62 ++++ 2 files changed, 259 insertions(+), 288 deletions(-) diff --git a/app/commands/escalate/handlers.ts b/app/commands/escalate/handlers.ts index f1265b48..618cf169 100644 --- a/app/commands/escalate/handlers.ts +++ b/app/commands/escalate/handlers.ts @@ -8,6 +8,14 @@ import { import { Effect, Layer } from "effect"; import { DatabaseLayer } from "#~/Database.ts"; +import { + editMessage, + interactionDeferReply, + interactionEditReply, + interactionFollowUp, + interactionReply, + interactionUpdate, +} from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; import { humanReadableResolutions, @@ -35,8 +43,6 @@ import { voteEffect } from "./vote"; const vote = (resolution: Resolution) => (interaction: MessageComponentInteraction) => Effect.gen(function* () { - const result = yield* voteEffect(resolution)(interaction); - const { escalation, tally, @@ -44,93 +50,76 @@ const vote = features, votingStrategy, earlyResolution, - } = result; + } = yield* voteEffect(resolution)(interaction); - // Check if early resolution triggered with clear winner - show confirmed state + // Check if early resolution triggered with clear winner if (earlyResolution && !tally.isTied && tally.leader) { - yield* Effect.tryPromise(() => - interaction.update({ - content: buildConfirmedMessageContent( - escalation, - tally.leader!, - tally, + yield* interactionUpdate(interaction, { + content: buildConfirmedMessageContent( + escalation, + tally.leader, + tally, + ), + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`expedite|${escalation.id}`) + .setLabel("Expedite") + .setStyle(ButtonStyle.Primary), ), - components: [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`expedite|${escalation.id}`) - .setLabel("Expedite") - .setStyle(ButtonStyle.Primary), - ), - ], - }), - ); + ], + }); return; } // Update the message with new vote state - yield* Effect.tryPromise(() => - interaction.update({ - content: buildVoteMessageContent( - modRoleId ?? "", - votingStrategy, - escalation, - tally, - ), - components: buildVoteButtons( - features, - votingStrategy, - escalation, - tally, - earlyResolution, - ), - }), - ); + yield* interactionUpdate(interaction, { + content: buildVoteMessageContent( + modRoleId ?? "", + votingStrategy, + escalation, + tally, + ), + components: buildVoteButtons( + features, + votingStrategy, + escalation, + tally, + earlyResolution, + ), + }); }).pipe( Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + Effect.catchTag("NotAuthorizedError", () => + interactionReply(interaction, { + content: "Only moderators can vote on escalations.", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchTag("NotFoundError", () => + interactionReply(interaction, { + content: "Escalation not found.", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchTag("AlreadyResolvedError", () => + interactionReply(interaction, { + content: "This escalation has already been resolved.", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect("error", "EscalationHandlers", "Error voting", { - error: - error instanceof Error ? error.message : JSON.stringify(error), - resolution, - }); - if (errorObj._tag === "NotAuthorizedError") { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Only moderators can vote on escalations.", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - if (errorObj._tag === "NotFoundError") { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Escalation not found.", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - if (errorObj._tag === "AlreadyResolvedError") { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "This escalation has already been resolved.", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.reply({ + logEffect("error", "EscalationHandlers", "Error voting", { + error, + resolution, + }) + .pipe(() => + interactionReply(interaction, { content: "Something went wrong while recording your vote.", flags: [MessageFlags.Ephemeral], }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), + ) + .pipe(Effect.catchAll(() => Effect.void)), ), Effect.withSpan("escalation-vote", { attributes: { @@ -143,71 +132,52 @@ const vote = const expedite = (interaction: MessageComponentInteraction) => Effect.gen(function* () { - yield* Effect.tryPromise(() => interaction.deferUpdate()); + yield* interactionDeferReply(interaction); const result = yield* expediteEffect(interaction); const expediteNote = `\nResolved early by <@${interaction.user.id}> at `; - yield* Effect.tryPromise(() => - interaction.message.edit({ - content: `**${humanReadableResolutions[result.resolution]}** ✅ <@${result.escalation.reported_user_id}>${expediteNote} + yield* editMessage(interaction.message, { + content: `**${humanReadableResolutions[result.resolution]}** ✅ <@${result.escalation.reported_user_id}>${expediteNote} ${buildVotesListContent(result.tally)}`, - components: [], // Remove buttons - }), - ); + components: [], // Remove buttons + }); }).pipe( Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + Effect.catchTag("NotAuthorizedError", () => + interactionFollowUp(interaction, { + content: "Only moderators can expedite resolutions.", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchTag("NotFoundError", () => + interactionFollowUp(interaction, { + content: "Escalation not found.", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchTag("AlreadyResolvedError", () => + interactionFollowUp(interaction, { + content: "This escalation has already been resolved.", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchTag("NoLeaderError", () => + interactionFollowUp(interaction, { + content: "Cannot expedite: no clear leading resolution.", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect("error", "EscalationHandlers", "Expedite failed", { - error: error instanceof Error ? error.message : JSON.stringify(error), - }); - if (errorObj._tag === "NotAuthorizedError") { - yield* Effect.tryPromise(() => - interaction.followUp({ - content: "Only moderators can expedite resolutions.", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - if (errorObj._tag === "NotFoundError") { - yield* Effect.tryPromise(() => - interaction.followUp({ - content: "Escalation not found.", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - if (errorObj._tag === "AlreadyResolvedError") { - yield* Effect.tryPromise(() => - interaction.followUp({ - content: "This escalation has already been resolved.", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - if (errorObj._tag === "NoLeaderError") { - yield* Effect.tryPromise(() => - interaction.followUp({ - content: "Cannot expedite: no clear leading resolution.", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.followUp({ - content: "Something went wrong while executing the resolution.", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), + logEffect("error", "EscalationHandlers", "Expedite failed", { + error, + }).pipe(() => + interactionFollowUp(interaction, { + content: "Something went wrong while executing the resolution.", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), ), Effect.withSpan("escalation-expedite", { attributes: { guildId: interaction.guildId, userId: interaction.user.id }, @@ -216,9 +186,7 @@ ${buildVotesListContent(result.tally)}`, const escalate = (interaction: MessageComponentInteraction) => Effect.gen(function* () { - yield* Effect.tryPromise(() => - interaction.deferReply({ flags: ["Ephemeral"] }), - ); + yield* interactionDeferReply(interaction, { flags: ["Ephemeral"] }); const [_, reportedUserId, level = "0", previousEscalationId = ""] = interaction.customId.split("|"); @@ -233,43 +201,30 @@ const escalate = (interaction: MessageComponentInteraction) => if (Number(level) === 0) { // Create new escalation yield* createEscalationEffect(interaction, reportedUserId, escalationId); - yield* Effect.tryPromise(() => - interaction.editReply("Escalation started"), - ); + yield* interactionEditReply(interaction, "Escalation started"); } else { // Upgrade to majority voting yield* upgradeToMajorityEffect(interaction, escalationId); - yield* Effect.tryPromise(() => - interaction.editReply("Escalation upgraded to majority voting"), + yield* interactionEditReply( + interaction, + "Escalation upgraded to majority voting", ); } }).pipe( Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + Effect.catchTag("NotFoundError", () => + interactionEditReply(interaction, { + content: "Failed to re-escalate, couldn't find escalation", + }).pipe(Effect.catchAll(() => Effect.void)), + ), Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect( - "error", - "EscalationHandlers", - "Error handling escalation", - { - error: - error instanceof Error ? error.message : JSON.stringify(error), - }, - ); - if (errorObj._tag === "NotFoundError") { - yield* Effect.tryPromise(() => - interaction.editReply({ - content: "Failed to re-escalate, couldn't find escalation", - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.editReply({ content: "Failed to process escalation" }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), + logEffect("error", "EscalationHandlers", "Error handling escalation", { + error, + }).pipe(() => + interactionEditReply(interaction, { + content: "Failed to process escalation", + }).pipe(Effect.catchAll(() => Effect.void)), + ), ), Effect.withSpan("escalation-escalate", { attributes: { guildId: interaction.guildId, userId: interaction.user.id }, @@ -280,29 +235,28 @@ export const EscalationHandlers = { // Direct action commands (no voting) delete: (interaction: MessageComponentInteraction) => Effect.gen(function* () { - yield* Effect.tryPromise(() => interaction.deferReply()); + yield* interactionDeferReply(interaction); const result = yield* deleteMessages(interaction); - yield* Effect.tryPromise(() => - interaction.editReply( - `Messages deleted by ${result.deletedBy} (${result.deleted}/${result.total} successful)`, - ), + yield* interactionEditReply( + interaction, + `Messages deleted by ${result.deletedBy} (${result.deleted}/${result.total} successful)`, ); }).pipe( Effect.provide(DatabaseLayer), Effect.catchTag("NotAuthorizedError", () => - Effect.tryPromise(() => - interaction.editReply({ content: "Insufficient permissions" }), - ).pipe(Effect.catchAll(() => Effect.void)), + interactionEditReply(interaction, { + content: "Insufficient permissions", + }).pipe(Effect.catchAll(() => Effect.void)), ), Effect.catchAll((error) => logEffect("error", "EscalationHandlers", "Error deleting messages", { error, }).pipe(() => - Effect.tryPromise(() => - interaction.editReply({ content: "Failed to delete messages" }), - ).pipe(Effect.catchAll(() => Effect.void)), + interactionEditReply(interaction, { + content: "Failed to delete messages", + }).pipe(Effect.catchAll(() => Effect.void)), ), ), Effect.withSpan("escalation-deleteMessages", { @@ -315,40 +269,29 @@ export const EscalationHandlers = { kick: (interaction: MessageComponentInteraction) => Effect.gen(function* () { const reportedUserId = interaction.customId.split("|")[1]; - const result = yield* kickUser(interaction); - yield* Effect.tryPromise(() => - interaction.reply(`<@${reportedUserId}> kicked by ${result.actionBy}`), + yield* interactionReply( + interaction, + `<@${reportedUserId}> kicked by ${result.actionBy}`, ); }).pipe( Effect.provide(DatabaseLayer), + Effect.catchTag("NotAuthorizedError", () => + interactionReply(interaction, { + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect( - "error", - "EscalationHandlers", - "Error kicking user", - { error }, - ); - if (errorObj._tag === "NotAuthorizedError") { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Failed to kick user", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), + logEffect("error", "EscalationHandlers", "Error kicking user", { + error, + }).pipe(() => + interactionReply(interaction, { + content: "Failed to kick user", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), ), Effect.withSpan("escalation-kickUser", { attributes: { @@ -363,37 +306,27 @@ export const EscalationHandlers = { const result = yield* banUser(interaction); - yield* Effect.tryPromise(() => - interaction.reply(`<@${reportedUserId}> banned by ${result.actionBy}`), + yield* interactionReply( + interaction, + `<@${reportedUserId}> banned by ${result.actionBy}`, ); }).pipe( Effect.provide(DatabaseLayer), + Effect.catchTag("NotAuthorizedError", () => + interactionReply(interaction, { + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect( - "error", - "EscalationHandlers", - "Error banning user", - { error }, - ); - if (errorObj._tag === "NotAuthorizedError") { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Failed to ban user", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), + logEffect("error", "EscalationHandlers", "Error banning user", { + error, + }).pipe(() => + interactionReply(interaction, { + content: "Failed to ban user", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), ), Effect.withSpan("escalation-banUser", { attributes: { @@ -408,39 +341,27 @@ export const EscalationHandlers = { const result = yield* restrictUser(interaction); - yield* Effect.tryPromise(() => - interaction.reply( - `<@${reportedUserId}> restricted by ${result.actionBy}`, - ), + yield* interactionReply( + interaction, + `<@${reportedUserId}> restricted by ${result.actionBy}`, ); }).pipe( Effect.provide(DatabaseLayer), + Effect.catchTag("NotAuthorizedError", () => + interactionReply(interaction, { + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect( - "error", - "EscalationHandlers", - "Error restricting user", - { error }, - ); - if (errorObj._tag === "NotAuthorizedError") { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Failed to restrict user", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), + logEffect("error", "EscalationHandlers", "Error restricting user", { + error, + }).pipe(() => + interactionReply(interaction, { + content: "Failed to restrict user", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), ), Effect.withSpan("escalation-restrictUser", { attributes: { @@ -455,39 +376,27 @@ export const EscalationHandlers = { const result = yield* timeoutUser(interaction); - yield* Effect.tryPromise(() => - interaction.reply( - `<@${reportedUserId}> timed out by ${result.actionBy}`, - ), + yield* interactionReply( + interaction, + `<@${reportedUserId}> timed out by ${result.actionBy}`, ); }).pipe( Effect.provide(DatabaseLayer), + Effect.catchTag("NotAuthorizedError", () => + interactionReply(interaction, { + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), Effect.catchAll((error) => - Effect.gen(function* () { - const errorObj = error as { _tag?: string }; - yield* logEffect( - "error", - "EscalationHandlers", - "Error timing out user", - { error }, - ); - if (errorObj._tag === "NotAuthorizedError") { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - return; - } - - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Failed to timeout user", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), + logEffect("error", "EscalationHandlers", "Error timing out user", { + error, + }).pipe(() => + interactionReply(interaction, { + content: "Failed to timeout user", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), ), Effect.withSpan("escalation-timeoutUser", { attributes: { diff --git a/app/effects/discordSdk.ts b/app/effects/discordSdk.ts index 3422cfa0..865087d7 100644 --- a/app/effects/discordSdk.ts +++ b/app/effects/discordSdk.ts @@ -5,14 +5,17 @@ * when calling Discord.js APIs from Effect-based code. */ import type { + ChatInputCommandInteraction, Client, Guild, GuildMember, GuildTextBasedChannel, Message, + MessageComponentInteraction, PartialMessage, ThreadChannel, User, + UserContextMenuCommandInteraction, } from "discord.js"; import { Effect } from "effect"; @@ -159,3 +162,62 @@ export const resolveMessagePartial = ( }), }) : Effect.succeed(msg); + +export const interactionReply = ( + interaction: + | MessageComponentInteraction + | ChatInputCommandInteraction + | UserContextMenuCommandInteraction, + options: Parameters[0], +) => + Effect.tryPromise({ + try: () => interaction.reply(options), + catch: (error) => + new DiscordApiError({ operation: "interactionReply", cause: error }), + }); +export const interactionDeferReply = ( + interaction: + | MessageComponentInteraction + | ChatInputCommandInteraction + | UserContextMenuCommandInteraction, + options?: Parameters[0], +) => + Effect.tryPromise({ + try: () => interaction.deferReply(options), + catch: (error) => + new DiscordApiError({ operation: "interactionDeferReply", cause: error }), + }); +export const interactionEditReply = ( + interaction: + | MessageComponentInteraction + | ChatInputCommandInteraction + | UserContextMenuCommandInteraction, + options: Parameters[0], +) => + Effect.tryPromise({ + try: () => interaction.editReply(options), + catch: (error) => + new DiscordApiError({ operation: "interactionEditReply", cause: error }), + }); +export const interactionFollowUp = ( + interaction: + | MessageComponentInteraction + | ChatInputCommandInteraction + | UserContextMenuCommandInteraction, + options: Parameters[0], +) => + Effect.tryPromise({ + try: () => interaction.followUp(options), + catch: (error) => + new DiscordApiError({ operation: "interactionFollowUp", cause: error }), + }); + +export const interactionUpdate = ( + interaction: MessageComponentInteraction, + options: Parameters[0], +) => + Effect.tryPromise({ + try: () => interaction.update(options), + catch: (error) => + new DiscordApiError({ operation: "interactionUpdate", cause: error }), + }); From 58499facb38efc56ef9d9337d7334e2adcc7f084 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 29 Jan 2026 13:46:56 -0500 Subject: [PATCH 08/14] Remove point-in-time notes --- .../2026-01-26_1_effect-command-conversion.md | 71 ------------------- 1 file changed, 71 deletions(-) delete mode 100644 notes/2026-01-26_1_effect-command-conversion.md diff --git a/notes/2026-01-26_1_effect-command-conversion.md b/notes/2026-01-26_1_effect-command-conversion.md deleted file mode 100644 index c95543d5..00000000 --- a/notes/2026-01-26_1_effect-command-conversion.md +++ /dev/null @@ -1,71 +0,0 @@ -# Effect-Based Command Handler Conversion - -Completed conversion of all async command handlers to Effect-based implementations. - -## Files Modified - -### Phase 1: Simple Commands -- `app/commands/demo.ts` - Converted slash + context menu commands -- `app/commands/force-ban.ts` - Converted user context menu command -- `app/commands/report.ts` - Converted message context menu command - -### Phase 2: Setup Commands -- `app/commands/setupReactjiChannel.ts` - DB upsert with emoji parsing -- `app/commands/setup.ts` - Multi-step guild registration -- `app/commands/setupHoneypot.ts` - DB + Discord channel operations -- `app/commands/setupTickets.ts` - Complex 4-handler ticket system - -### Phase 3: Escalation System -- `app/commands/escalationControls.ts` - Changed to EffectMessageComponentCommand[] -- `app/commands/escalate/handlers.ts` - Converted 8 handlers to pure Effect - -## Pattern Applied - -```typescript -// Before -const handler = async (interaction) => { - await trackPerformance("cmd", async () => { - log("info", "Commands", "..."); - try { - await doSomething(); - } catch (e) { - log("error", "Commands", "..."); - } - }); -}; -export const Command = { handler, command }; - -// After -export const Command = { - type: "effect", - command: new SlashCommandBuilder()..., - handler: (interaction) => - Effect.gen(function* () { - yield* logEffect("info", "Commands", "..."); - yield* Effect.tryPromise(() => doSomething()); - }).pipe( - Effect.catchAll((error) => - Effect.gen(function* () { - yield* logEffect("error", "Commands", "..."); - yield* Effect.tryPromise(() => - interaction.reply({ content: "Error", flags: [MessageFlags.Ephemeral] }) - ).pipe(Effect.catchAll(() => Effect.void)); - }) - ), - Effect.withSpan("commandName", { attributes: { ... } }), - ), -} satisfies EffectSlashCommand; -``` - -## Key Changes -1. Removed `trackPerformance()` wrappers (Effect.withSpan replaces this) -2. Replaced `log()` with `yield* logEffect()` -3. Replaced `await` with `yield* Effect.tryPromise()` -4. Replaced try/catch with `.pipe(Effect.catchAll(...))` -5. Added `type: "effect"` discriminator -6. Used `satisfies` for type inference with proper handler types - -## Notes -- `getFailure()` in `app/commands/escalate/index.ts` is still used by `escalationResolver.ts` -- Metrics calls (`commandStats`, `featureStats`) remained as synchronous side effects -- Error replies in catchAll are wrapped with `.pipe(Effect.catchAll(() => Effect.void))` to prevent cascading failures From 9d83633f67f0a7aad2caa59d2ee33e3264fe5ded Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 29 Jan 2026 14:40:05 -0500 Subject: [PATCH 09/14] Streamline excalations service a little bit --- app/commands/escalate/escalationResolver.ts | 4 +- app/commands/escalate/handlers.ts | 75 +++++++++++---------- app/commands/escalate/service.ts | 65 ++---------------- app/discord/escalationResolver.ts | 3 +- 4 files changed, 47 insertions(+), 100 deletions(-) diff --git a/app/commands/escalate/escalationResolver.ts b/app/commands/escalate/escalationResolver.ts index 5714f2ec..f366e965 100644 --- a/app/commands/escalate/escalationResolver.ts +++ b/app/commands/escalate/escalationResolver.ts @@ -8,6 +8,7 @@ import { } from "discord.js"; import { Effect } from "effect"; +import { DatabaseLayer } from "#~/Database.ts"; import { editMessage, fetchChannelFromClient, @@ -185,12 +186,13 @@ export const checkPendingEscalationsEffect = (client: Client) => // TODO: In the future, we should have a smarter fetch that manages that const results = yield* Effect.forEach(due, (escalation) => processEscalationEffect(client, escalation).pipe( + Effect.provide(DatabaseLayer), Effect.catchAll((error) => logEffect( "error", "EscalationResolver", "Error processing escalation", - { escalationId: escalation.id, error: String(error) }, + { escalationId: escalation.id, error }, ), ), ), diff --git a/app/commands/escalate/handlers.ts b/app/commands/escalate/handlers.ts index 618cf169..12c9c00f 100644 --- a/app/commands/escalate/handlers.ts +++ b/app/commands/escalate/handlers.ts @@ -89,6 +89,13 @@ const vote = ), }); }).pipe( + Effect.withSpan("escalation-vote", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + resolution, + }, + }), Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), Effect.catchTag("NotAuthorizedError", () => interactionReply(interaction, { @@ -121,13 +128,6 @@ const vote = ) .pipe(Effect.catchAll(() => Effect.void)), ), - Effect.withSpan("escalation-vote", { - attributes: { - guildId: interaction.guildId, - userId: interaction.user.id, - resolution, - }, - }), ); const expedite = (interaction: MessageComponentInteraction) => @@ -144,6 +144,9 @@ ${buildVotesListContent(result.tally)}`, components: [], // Remove buttons }); }).pipe( + Effect.withSpan("escalation-expedite", { + attributes: { guildId: interaction.guildId, userId: interaction.user.id }, + }), Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), Effect.catchTag("NotAuthorizedError", () => interactionFollowUp(interaction, { @@ -179,9 +182,6 @@ ${buildVotesListContent(result.tally)}`, }).pipe(Effect.catchAll(() => Effect.void)), ), ), - Effect.withSpan("escalation-expedite", { - attributes: { guildId: interaction.guildId, userId: interaction.user.id }, - }), ); const escalate = (interaction: MessageComponentInteraction) => @@ -209,8 +209,12 @@ const escalate = (interaction: MessageComponentInteraction) => interaction, "Escalation upgraded to majority voting", ); + return; } }).pipe( + Effect.withSpan("escalation-escalate", { + attributes: { guildId: interaction.guildId, userId: interaction.user.id }, + }), Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), Effect.catchTag("NotFoundError", () => interactionEditReply(interaction, { @@ -226,9 +230,6 @@ const escalate = (interaction: MessageComponentInteraction) => }).pipe(Effect.catchAll(() => Effect.void)), ), ), - Effect.withSpan("escalation-escalate", { - attributes: { guildId: interaction.guildId, userId: interaction.user.id }, - }), ); export const EscalationHandlers = { @@ -244,6 +245,12 @@ export const EscalationHandlers = { `Messages deleted by ${result.deletedBy} (${result.deleted}/${result.total} successful)`, ); }).pipe( + Effect.withSpan("escalation-deleteMessages", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), Effect.provide(DatabaseLayer), Effect.catchTag("NotAuthorizedError", () => interactionEditReply(interaction, { @@ -259,12 +266,6 @@ export const EscalationHandlers = { }).pipe(Effect.catchAll(() => Effect.void)), ), ), - Effect.withSpan("escalation-deleteMessages", { - attributes: { - guildId: interaction.guildId, - userId: interaction.user.id, - }, - }), ), kick: (interaction: MessageComponentInteraction) => Effect.gen(function* () { @@ -311,6 +312,12 @@ export const EscalationHandlers = { `<@${reportedUserId}> banned by ${result.actionBy}`, ); }).pipe( + Effect.withSpan("escalation-banUser", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), Effect.provide(DatabaseLayer), Effect.catchTag("NotAuthorizedError", () => interactionReply(interaction, { @@ -328,12 +335,6 @@ export const EscalationHandlers = { }).pipe(Effect.catchAll(() => Effect.void)), ), ), - Effect.withSpan("escalation-banUser", { - attributes: { - guildId: interaction.guildId, - userId: interaction.user.id, - }, - }), ), restrict: (interaction: MessageComponentInteraction) => Effect.gen(function* () { @@ -346,6 +347,12 @@ export const EscalationHandlers = { `<@${reportedUserId}> restricted by ${result.actionBy}`, ); }).pipe( + Effect.withSpan("escalation-restrictUser", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), Effect.provide(DatabaseLayer), Effect.catchTag("NotAuthorizedError", () => interactionReply(interaction, { @@ -363,12 +370,6 @@ export const EscalationHandlers = { }).pipe(Effect.catchAll(() => Effect.void)), ), ), - Effect.withSpan("escalation-restrictUser", { - attributes: { - guildId: interaction.guildId, - userId: interaction.user.id, - }, - }), ), timeout: (interaction: MessageComponentInteraction) => Effect.gen(function* () { @@ -381,6 +382,12 @@ export const EscalationHandlers = { `<@${reportedUserId}> timed out by ${result.actionBy}`, ); }).pipe( + Effect.withSpan("escalation-timeoutUser", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), Effect.provide(DatabaseLayer), Effect.catchTag("NotAuthorizedError", () => interactionReply(interaction, { @@ -398,12 +405,6 @@ export const EscalationHandlers = { }).pipe(Effect.catchAll(() => Effect.void)), ), ), - Effect.withSpan("escalation-timeoutUser", { - attributes: { - guildId: interaction.guildId, - userId: interaction.user.id, - }, - }), ), // Voting handlers diff --git a/app/commands/escalate/service.ts b/app/commands/escalate/service.ts index c2448ad1..58af22df 100644 --- a/app/commands/escalate/service.ts +++ b/app/commands/escalate/service.ts @@ -35,67 +35,39 @@ export interface RecordVoteData { } export interface IEscalationService { - /** - * Create a new escalation record. - */ readonly createEscalation: ( data: CreateEscalationData, ) => Effect.Effect; - /** - * Get an escalation by ID. - */ readonly getEscalation: ( id: string, ) => Effect.Effect; - /** - * Record a vote for an escalation. - * Toggle behavior: voting the same resolution twice removes the vote. - */ readonly recordVote: ( data: RecordVoteData, ) => Effect.Effect<{ isNew: boolean }, SqlError>; - /** - * Get all votes for an escalation. - */ readonly getVotesForEscalation: ( escalationId: string, ) => Effect.Effect; - /** - * Mark an escalation as resolved. - */ readonly resolveEscalation: ( id: string, resolution: Resolution, ) => Effect.Effect; - /** - * Update the voting strategy for an escalation. - */ readonly updateEscalationStrategy: ( id: string, strategy: VotingStrategy, ) => Effect.Effect; - /** - * Update the scheduled resolution time for an escalation. - */ readonly updateScheduledFor: ( id: string, timestamp: string, ) => Effect.Effect; - /** - * Get all escalations that are due for auto-resolution. - */ readonly getDueEscalations: () => Effect.Effect; - /** - * Execute the resolution action for an escalation. - */ readonly executeResolution: ( resolution: Resolution, escalation: Escalation, @@ -135,8 +107,6 @@ export const EscalationServiceLive = Layer.effect( yield* db.insertInto("escalations").values(escalation); yield* logEffect("info", "EscalationService", "Created escalation", { - escalationId: data.id, - reportedUserId: data.reportedUserId, guildId: data.guildId, }); @@ -197,11 +167,7 @@ export const EscalationServiceLive = Layer.effect( "info", "EscalationService", "Deleted existing vote", - { - escalationId: data.escalationId, - voterId: data.voterId, - vote: data.vote, - }, + { vote: data.vote }, ); return { isNew: false }; @@ -276,10 +242,7 @@ export const EscalationServiceLive = Layer.effect( }) .where("id", "=", id); - yield* logEffect("info", "EscalationService", "Resolved escalation", { - escalationId: id, - resolution, - }); + yield* logEffect("info", "EscalationService", "Resolved escalation"); }).pipe( Effect.withSpan("resolveEscalation", { attributes: { escalationId: id, resolution }, @@ -297,10 +260,6 @@ export const EscalationServiceLive = Layer.effect( "info", "EscalationService", "Updated voting strategy", - { - escalationId: id, - strategy, - }, ); }).pipe( Effect.withSpan("updateEscalationStrategy", { @@ -319,14 +278,10 @@ export const EscalationServiceLive = Layer.effect( "debug", "EscalationService", "Updated scheduled_for", - { - escalationId: id, - scheduledFor: timestamp, - }, ); }).pipe( Effect.withSpan("updateScheduledFor", { - attributes: { escalationId: id }, + attributes: { escalationId: id, scheduledFor: timestamp }, }), ), @@ -342,9 +297,7 @@ export const EscalationServiceLive = Layer.effect( "debug", "EscalationService", "Found due escalations", - { - count: escalations.length, - }, + { count: escalations.length }, ); return escalations; @@ -374,10 +327,6 @@ export const EscalationServiceLive = Layer.effect( "debug", "EscalationService", "Member not found, skipping action", - { - escalationId: escalation.id, - reportedUserId: escalation.reported_user_id, - }, ); return; } @@ -410,11 +359,7 @@ export const EscalationServiceLive = Layer.effect( }), }); - yield* logEffect("info", "EscalationService", "Resolution executed", { - resolution, - escalationId: escalation.id, - reportedUserId: escalation.reported_user_id, - }); + yield* logEffect("info", "EscalationService", "Resolution executed"); }).pipe( Effect.withSpan("executeResolution", { attributes: { diff --git a/app/discord/escalationResolver.ts b/app/discord/escalationResolver.ts index 797b4d3b..81006e7b 100644 --- a/app/discord/escalationResolver.ts +++ b/app/discord/escalationResolver.ts @@ -4,7 +4,6 @@ import { Effect, Layer } from "effect"; import { checkPendingEscalationsEffect } from "#~/commands/escalate/escalationResolver"; 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"; @@ -17,7 +16,7 @@ const ONE_MINUTE = 60 * 1000; async function checkPendingEscalations(client: Client): Promise { const exit = await runEffectExit( checkPendingEscalationsEffect(client).pipe( - Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + Effect.provide(Layer.mergeAll(EscalationServiceLive)), ), ); From 19aeba53889a13309e7a6fd19a03af2cbdfe15eb Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 29 Jan 2026 15:38:38 -0500 Subject: [PATCH 10/14] Make sure fetchSettingsEffect throws an error if no settings are found --- app/commands/report/userLog.ts | 4 ++-- app/models/guilds.server.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/commands/report/userLog.ts b/app/commands/report/userLog.ts index 3305ca3e..d3ee4a09 100644 --- a/app/commands/report/userLog.ts +++ b/app/commands/report/userLog.ts @@ -11,7 +11,7 @@ import { type DatabaseService, type SqlError, } from "#~/Database"; -import { DiscordApiError } from "#~/effects/errors"; +import { DiscordApiError, type NotFoundError } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; import { runEffect } from "#~/effects/runtime"; import { @@ -69,7 +69,7 @@ export function logUserMessage({ allReportedMessages: Report[]; reportId: string; }, - DiscordApiError | SqlError, + DiscordApiError | SqlError | NotFoundError, DatabaseService > { return Effect.gen(function* () { diff --git a/app/models/guilds.server.ts b/app/models/guilds.server.ts index 76e92e77..b43b4b47 100644 --- a/app/models/guilds.server.ts +++ b/app/models/guilds.server.ts @@ -2,6 +2,7 @@ import { Effect } from "effect"; import { DatabaseService } from "#~/Database.ts"; import db, { SqliteError, type DB } from "#~/db.server"; +import { NotFoundError } from "#~/effects/errors.ts"; import { log, trackPerformance } from "#~/helpers/observability"; export type Guild = DB["guilds"]; @@ -134,5 +135,10 @@ export const fetchSettingsEffect = ( ) .where("id", "=", guildId); const result = Object.entries(rows[0] ?? {}) as [T, string][]; + if (result.length === 0) { + return yield* Effect.fail( + new NotFoundError({ id: guildId, resource: "guild" }), + ); + } return Object.fromEntries(result) as Pick; }); From 7d02348fae33f02009d197bb9a7843e8a56aea25 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 29 Jan 2026 17:54:37 -0500 Subject: [PATCH 11/14] Swap out a ton more promises for effects --- app/commands/demo.ts | 31 ++-- app/commands/escalate/expedite.ts | 20 +-- app/commands/escalate/service.ts | 9 +- app/commands/force-ban.ts | 33 ++-- app/commands/report.ts | 27 ++-- app/commands/report/modActionLogger.ts | 6 +- app/commands/report/userLog.ts | 65 ++------ app/commands/setup.ts | 10 +- app/commands/setupHoneypot.ts | 39 ++--- app/commands/setupReactjiChannel.ts | 41 ++--- app/commands/setupTickets.ts | 207 +++++++++++-------------- app/commands/track.ts | 135 ++++++++-------- app/effects/discordSdk.ts | 32 +++- app/models/userThreads.ts | 42 ++--- 14 files changed, 316 insertions(+), 381 deletions(-) diff --git a/app/commands/demo.ts b/app/commands/demo.ts index 10224e0f..c94c183b 100644 --- a/app/commands/demo.ts +++ b/app/commands/demo.ts @@ -6,6 +6,7 @@ import { } from "discord.js"; import { Effect } from "effect"; +import { interactionReply } from "#~/effects/discordSdk.ts"; import type { EffectMessageContextCommand, EffectSlashCommand, @@ -19,12 +20,10 @@ export const Command = [ .setName("demo") .setDescription("TODO: replace everything in here"), handler: (interaction) => - Effect.tryPromise(() => - interaction.reply({ - flags: [MessageFlags.Ephemeral], - content: "ok", - }), - ).pipe(Effect.catchAll(() => Effect.void)), + interactionReply(interaction, { + flags: [MessageFlags.Ephemeral], + content: "ok", + }).pipe(Effect.catchAll(() => Effect.void)), } satisfies EffectSlashCommand, { type: "effect", @@ -32,12 +31,10 @@ export const Command = [ .setName("demo") .setType(ApplicationCommandType.User), handler: (interaction) => - Effect.tryPromise(() => - interaction.reply({ - flags: [MessageFlags.Ephemeral], - content: "ok", - }), - ).pipe(Effect.catchAll(() => Effect.void)), + interactionReply(interaction, { + flags: [MessageFlags.Ephemeral], + content: "ok", + }).pipe(Effect.catchAll(() => Effect.void)), } satisfies EffectUserContextCommand, { type: "effect", @@ -45,11 +42,9 @@ export const Command = [ .setName("demo") .setType(ApplicationCommandType.Message), handler: (interaction) => - Effect.tryPromise(() => - interaction.reply({ - flags: [MessageFlags.Ephemeral], - content: "ok", - }), - ).pipe(Effect.catchAll(() => Effect.void)), + interactionReply(interaction, { + flags: [MessageFlags.Ephemeral], + content: "ok", + }).pipe(Effect.catchAll(() => Effect.void)), } satisfies EffectMessageContextCommand, ]; diff --git a/app/commands/escalate/expedite.ts b/app/commands/escalate/expedite.ts index b279984b..044a2f7a 100644 --- a/app/commands/escalate/expedite.ts +++ b/app/commands/escalate/expedite.ts @@ -3,9 +3,9 @@ import { Effect } from "effect"; import { AlreadyResolvedError, - DiscordApiError, NoLeaderError, NotAuthorizedError, + NotFoundError, } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; import { hasModRole } from "#~/helpers/discord"; @@ -28,6 +28,11 @@ export interface ExpediteResult { */ export const expediteEffect = (interaction: MessageComponentInteraction) => Effect.gen(function* () { + if (!interaction.guild) { + return yield* Effect.fail( + new NotFoundError({ resource: "guild", id: interaction.guildId ?? "" }), + ); + } const escalationService = yield* EscalationService; const escalationId = interaction.customId.split("|")[1]; const guildId = interaction.guildId!; @@ -77,15 +82,12 @@ export const expediteEffect = (interaction: MessageComponentInteraction) => ); } - // Fetch the guild for resolution execution - const guild = yield* Effect.tryPromise({ - try: () => interaction.guild!.fetch(), - catch: (error) => - new DiscordApiError({ operation: "fetchGuild", cause: error }), - }); - // Execute the resolution - yield* escalationService.executeResolution(tally.leader, escalation, guild); + yield* escalationService.executeResolution( + tally.leader, + escalation, + interaction.guild, + ); // Mark as resolved yield* escalationService.resolveEscalation(escalationId, tally.leader); diff --git a/app/commands/escalate/service.ts b/app/commands/escalate/service.ts index 58af22df..e1848520 100644 --- a/app/commands/escalate/service.ts +++ b/app/commands/escalate/service.ts @@ -4,6 +4,7 @@ import type { Selectable } from "kysely"; import { DatabaseLayer, DatabaseService, type SqlError } from "#~/Database"; import type { DB } from "#~/db"; +import { fetchMember } from "#~/effects/discordSdk.ts"; import { AlreadyResolvedError, NotFoundError, @@ -317,10 +318,10 @@ export const EscalationServiceLive = Layer.effect( ); // Try to fetch the member - they may have left - const reportedMember = yield* Effect.tryPromise({ - try: () => guild.members.fetch(escalation.reported_user_id), - catch: () => null, - }).pipe(Effect.catchAll(() => Effect.succeed(null))); + const reportedMember = yield* fetchMember( + guild, + escalation.reported_user_id, + ).pipe(Effect.catchAll(() => Effect.succeed(null))); if (!reportedMember) { yield* logEffect( diff --git a/app/commands/force-ban.ts b/app/commands/force-ban.ts index 251c94f2..d2ed0506 100644 --- a/app/commands/force-ban.ts +++ b/app/commands/force-ban.ts @@ -6,6 +6,7 @@ import { } from "discord.js"; import { Effect } from "effect"; +import { interactionReply } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; import type { EffectUserContextCommand } from "#~/helpers/discord"; import { commandStats } from "#~/helpers/metrics"; @@ -41,12 +42,10 @@ export const Command = { commandStats.commandFailed(interaction, "force-ban", "No guild found"); - yield* Effect.tryPromise(() => - interaction.reply({ - flags: [MessageFlags.Ephemeral], - content: "Failed to ban user, couldn't find guild", - }), - ); + yield* interactionReply(interaction, { + flags: [MessageFlags.Ephemeral], + content: "Failed to ban user, couldn't find guild", + }); return; } @@ -66,12 +65,10 @@ export const Command = { commandStats.commandExecuted(interaction, "force-ban", true); - yield* Effect.tryPromise(() => - interaction.reply({ - flags: [MessageFlags.Ephemeral], - content: "This member has been banned", - }), - ); + yield* interactionReply(interaction, { + flags: [MessageFlags.Ephemeral], + content: "This member has been banned", + }); }).pipe( Effect.catchAll((error) => Effect.gen(function* () { @@ -88,13 +85,11 @@ export const Command = { commandStats.commandFailed(interaction, "force-ban", err.message); - yield* Effect.tryPromise(() => - interaction.reply({ - flags: [MessageFlags.Ephemeral], - content: - "Failed to ban user, try checking the bot's permissions. If they look okay, make sure that the bot's role is near the top of the roles list — bots can't ban users with roles above their own.", - }), - ).pipe(Effect.catchAll(() => Effect.void)); + yield* interactionReply(interaction, { + flags: [MessageFlags.Ephemeral], + content: + "Failed to ban user, try checking the bot's permissions. If they look okay, make sure that the bot's role is near the top of the roles list — bots can't ban users with roles above their own.", + }).pipe(Effect.catchAll(() => Effect.void)); }), ), Effect.withSpan("forceBanCommand", { diff --git a/app/commands/report.ts b/app/commands/report.ts index f20aadf9..f862d54d 100644 --- a/app/commands/report.ts +++ b/app/commands/report.ts @@ -8,6 +8,10 @@ import { Effect } from "effect"; import { logUserMessage } from "#~/commands/report/userLog.ts"; import { DatabaseLayer } from "#~/Database.ts"; +import { + interactionDeferReply, + interactionEditReply, +} from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; import type { EffectMessageContextCommand } from "#~/helpers/discord"; import { commandStats } from "#~/helpers/metrics"; @@ -23,9 +27,9 @@ export const Command = { Effect.gen(function* () { const { targetMessage: message } = interaction; - yield* Effect.tryPromise(() => - interaction.deferReply({ flags: [MessageFlags.Ephemeral] }), - ); + yield* interactionDeferReply(interaction, { + flags: [MessageFlags.Ephemeral], + }); yield* logEffect("info", "Commands", "Report command executed"); @@ -40,11 +44,9 @@ export const Command = { commandStats.reportSubmitted(interaction, message.author.id); commandStats.commandExecuted(interaction, "report", true); - yield* Effect.tryPromise(() => - interaction.editReply({ - content: "This message has been reported anonymously", - }), - ); + yield* interactionEditReply(interaction, { + content: "This message has been reported anonymously", + }); }).pipe( Effect.provide(DatabaseLayer), Effect.catchAll((error) => @@ -53,12 +55,9 @@ export const Command = { error, }); - yield* Effect.tryPromise(() => - interaction.reply({ - flags: [MessageFlags.Ephemeral], - content: "Failed to submit report. Please try again later.", - }), - ).pipe( + yield* interactionEditReply(interaction, { + content: "Failed to submit report. Please try again later.", + }).pipe( Effect.catchAll(() => { commandStats.commandFailed(interaction, "report", error.message); return Effect.void; diff --git a/app/commands/report/modActionLogger.ts b/app/commands/report/modActionLogger.ts index fee53727..c2ea781d 100644 --- a/app/commands/report/modActionLogger.ts +++ b/app/commands/report/modActionLogger.ts @@ -15,6 +15,7 @@ import { Effect } from "effect"; import { logAutomod } from "#~/commands/report/automodLog.ts"; import { DatabaseLayer } from "#~/Database.ts"; +import { fetchUser } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; import { runEffect } from "#~/effects/runtime.ts"; @@ -258,10 +259,7 @@ const automodActionEffect = (execution: AutoModerationActionExecution) => matchedKeyword, }); - const user = yield* Effect.tryPromise({ - try: () => guild.client.users.fetch(userId), - catch: (error) => error, - }); + const user = yield* fetchUser(guild.client, userId); yield* logAutomod({ guild, diff --git a/app/commands/report/userLog.ts b/app/commands/report/userLog.ts index d3ee4a09..dffcd8e8 100644 --- a/app/commands/report/userLog.ts +++ b/app/commands/report/userLog.ts @@ -11,6 +11,7 @@ import { type DatabaseService, type SqlError, } from "#~/Database"; +import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk.ts"; import { DiscordApiError, type NotFoundError } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; import { runEffect } from "#~/effects/runtime"; @@ -22,7 +23,7 @@ import { quoteAndEscape, quoteAndEscapePoll, } from "#~/helpers/discord"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { getReportsForMessage, getUserReportStats, @@ -86,14 +87,7 @@ export function logUserMessage({ // Check if this exact message has already been reported const [existingReports, { modLog }, logBody, thread] = yield* Effect.all([ getReportsForMessage(message.id, guild.id), - Effect.tryPromise({ - try: () => fetchSettings(guild.id, [SETTINGS.modLog]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - cause: error, - }), - }), + fetchSettingsEffect(guild.id, [SETTINGS.modLog]), constructLog({ extra, logs: [{ message, reason, staff }], @@ -181,22 +175,14 @@ export function logUserMessage({ : quoteAndEscape(getMessageContent(message)).trim(); // Send the detailed log message to thread - const [logMessage] = yield* Effect.tryPromise({ - try: () => - Promise.all([ - thread.send(logBody), - thread.send({ - content: reportedMessage, - allowedMentions: {}, - embeds: embeds.length === 0 ? undefined : embeds, - }), - ]), - catch: (error) => - new DiscordApiError({ - operation: "sendLogMessages", - cause: error, - }), - }); + const [logMessage] = yield* Effect.all([ + sendMessage(thread, logBody), + sendMessage(thread, { + content: reportedMessage, + allowedMentions: {}, + embeds: embeds.length === 0 ? undefined : embeds, + }), + ]); // Record the report in database const recordResult = yield* recordReport({ @@ -222,17 +208,7 @@ export function logUserMessage({ } // Forward to mod log (non-critical) - yield* Effect.tryPromise({ - try: () => logMessage.forward(modLog), - catch: (error) => - new DiscordApiError({ operation: "forwardLog", cause: error }), - }).pipe( - Effect.catchAll((error) => - logEffect("error", "logUserMessage", "failed to forward to modLog", { - error: String(error), - }), - ), - ); + yield* forwardMessageSafe(logMessage, modLog); // Send summary to parent channel if possible (non-critical) const parentChannel = thread.parent; @@ -248,22 +224,13 @@ export function logUserMessage({ Effect.catchAll(() => Effect.succeed(undefined)), ); - yield* Effect.tryPromise({ - try: async () => { - await parentChannel.send({ - allowedMentions: {}, - content: `> ${escapeDisruptiveContent(truncatedMsg)}\n-# [${!stats ? "stats failed to load" : `${stats.char_count} chars in ${stats.word_count} words. ${stats.link_stats.length} links, ${stats.code_stats.reduce((count, { lines }) => count + lines, 0)} lines of code. ${message.attachments.size} attachments, ${message.reactions.cache.size} reactions`}](${messageLink(logMessage.channelId, logMessage.id)})`, - }); - }, - catch: (error) => - new DiscordApiError({ - operation: "logUserMessage", - cause: error, - }), + yield* sendMessage(parentChannel, { + allowedMentions: {}, + content: `> ${escapeDisruptiveContent(truncatedMsg)}\n-# [${!stats ? "stats failed to load" : `${stats.char_count} chars in ${stats.word_count} words. ${stats.link_stats.length} links, ${stats.code_stats.reduce((count, { lines }) => count + lines, 0)} lines of code. ${message.attachments.size} attachments, ${message.reactions.cache.size} reactions`}](${messageLink(logMessage.channelId, logMessage.id)})`, }).pipe( Effect.catchAll((error) => logEffect("error", "logUserMessage", "failed to forward to modLog", { - error: String(error), + error, }), ), ); diff --git a/app/commands/setup.ts b/app/commands/setup.ts index 31de8ebd..0c885613 100644 --- a/app/commands/setup.ts +++ b/app/commands/setup.ts @@ -1,6 +1,7 @@ import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; import { Effect } from "effect"; +import { interactionReply } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; import type { EffectSlashCommand } from "#~/helpers/discord"; import { commandStats } from "#~/helpers/metrics"; @@ -87,7 +88,7 @@ export const Command = { commandStats.commandExecuted(interaction, "setup", true); - yield* Effect.tryPromise(() => interaction.reply("Setup completed!")); + yield* interactionReply(interaction, "Setup completed!"); }).pipe( Effect.catchAll((error) => Effect.gen(function* () { @@ -102,12 +103,13 @@ export const Command = { commandStats.commandFailed(interaction, "setup", err.message); - yield* Effect.tryPromise(() => - interaction.reply(`Something broke: + yield* interactionReply( + interaction, + `Something broke: \`\`\` ${err.toString()} \`\`\` -`), +`, ).pipe(Effect.catchAll(() => Effect.void)); }), ), diff --git a/app/commands/setupHoneypot.ts b/app/commands/setupHoneypot.ts index 7f3d2cf6..b3babef7 100644 --- a/app/commands/setupHoneypot.ts +++ b/app/commands/setupHoneypot.ts @@ -8,6 +8,7 @@ import { import { Effect } from "effect"; import db from "#~/db.server.js"; +import { interactionReply, sendMessage } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; import type { EffectSlashCommand } from "#~/helpers/discord.js"; import { featureStats } from "#~/helpers/metrics"; @@ -52,20 +53,16 @@ export const Command = [ interaction.options.getString("message-text") ?? DEFAULT_MESSAGE_TEXT; if (!honeypotChannel?.id) { - yield* Effect.tryPromise(() => - interaction.reply({ - content: `You must provide a channel!`, - }), - ); + yield* interactionReply(interaction, { + content: `You must provide a channel!`, + }); return; } if (honeypotChannel.type !== ChannelType.GuildText) { - yield* Effect.tryPromise(() => - interaction.reply({ - content: `The channel configured must be a text channel!`, - }), - ); + yield* interactionReply(interaction, { + content: `The channel configured must be a text channel!`, + }); return; } @@ -82,7 +79,7 @@ export const Command = [ ); if ((result[0].numInsertedOrUpdatedRows ?? 0) > 0) { - yield* Effect.tryPromise(() => castedChannel.send(messageText)); + yield* sendMessage(castedChannel, messageText); featureStats.honeypotSetup( interaction.guildId, interaction.user.id, @@ -90,12 +87,10 @@ export const Command = [ ); } - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Honeypot setup completed successfully!", - flags: [MessageFlags.Ephemeral], - }), - ); + yield* interactionReply(interaction, { + content: "Honeypot setup completed successfully!", + flags: [MessageFlags.Ephemeral], + }); }).pipe( Effect.catchAll((error) => Effect.gen(function* () { @@ -108,12 +103,10 @@ export const Command = [ }, ); - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Failed to setup honeypot. Please try again.", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); + yield* interactionReply(interaction, { + content: "Failed to setup honeypot. Please try again.", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)); }), ), Effect.withSpan("honeypotSetupCommand", { diff --git a/app/commands/setupReactjiChannel.ts b/app/commands/setupReactjiChannel.ts index 58289498..9a936ce2 100644 --- a/app/commands/setupReactjiChannel.ts +++ b/app/commands/setupReactjiChannel.ts @@ -7,6 +7,7 @@ import { import { Effect } from "effect"; import db from "#~/db.server.js"; +import { interactionReply } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; import type { EffectSlashCommand } from "#~/helpers/discord"; import { featureStats } from "#~/helpers/metrics"; @@ -42,12 +43,10 @@ export const Command = { handler: (interaction) => Effect.gen(function* () { if (!interaction.guild) { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "This command can only be used in a server.", - flags: [MessageFlags.Ephemeral], - }), - ); + yield* interactionReply(interaction, { + content: "This command can only be used in a server.", + flags: [MessageFlags.Ephemeral], + }); return; } @@ -65,12 +64,10 @@ export const Command = { : emojiInput.trim(); if (!emoji) { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Please provide a valid emoji.", - flags: [MessageFlags.Ephemeral], - }), - ); + yield* interactionReply(interaction, { + content: "Please provide a valid emoji.", + flags: [MessageFlags.Ephemeral], + }); return; } @@ -105,11 +102,9 @@ export const Command = { const thresholdText = threshold === 1 ? "" : ` (after ${threshold} reactions)`; - yield* Effect.tryPromise(() => - interaction.reply({ - content: `Configured by <@${configuredById}>: messages reacted with ${emoji} will be forwarded to this channel${thresholdText}.`, - }), - ); + yield* interactionReply(interaction, { + content: `Configured by <@${configuredById}>: messages reacted with ${emoji} will be forwarded to this channel${thresholdText}.`, + }); }).pipe( Effect.catchAll((error) => Effect.gen(function* () { @@ -120,13 +115,11 @@ export const Command = { { error: String(error) }, ); - yield* Effect.tryPromise(() => - interaction.reply({ - content: - "Something went wrong while configuring the reactji channeler.", - flags: [MessageFlags.Ephemeral], - }), - ).pipe(Effect.catchAll(() => Effect.void)); + yield* interactionReply(interaction, { + content: + "Something went wrong while configuring the reactji channeler.", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)); }), ), Effect.withSpan("setupReactjiChannelCommand", { diff --git a/app/commands/setupTickets.ts b/app/commands/setupTickets.ts index d5c36219..d3220e5a 100644 --- a/app/commands/setupTickets.ts +++ b/app/commands/setupTickets.ts @@ -15,8 +15,14 @@ import { } from "discord.js"; import { Effect } from "effect"; +import { DatabaseLayer } from "#~/Database.ts"; import db from "#~/db.server.js"; import { ssrDiscordSdk as rest } from "#~/discord/api"; +import { + fetchChannel, + interactionReply, + sendMessage, +} from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; import { quoteMessageContent, @@ -25,7 +31,7 @@ import { type EffectSlashCommand, } from "#~/helpers/discord"; import { featureStats } from "#~/helpers/metrics"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; const DEFAULT_BUTTON_TEXT = "Open a private ticket with the moderators"; @@ -76,31 +82,27 @@ export const Command = [ interaction.options.getString("button-text") ?? DEFAULT_BUTTON_TEXT; if (ticketChannel && ticketChannel.type !== ChannelType.GuildText) { - yield* Effect.tryPromise(() => - interaction.reply({ - content: `The channel configured must be a text channel! Tickets will be created as private threads.`, - }), - ); + yield* interactionReply(interaction, { + content: `The channel configured must be a text channel! Tickets will be created as private threads.`, + }); return; } - const interactionResponse = yield* Effect.tryPromise(() => - interaction.reply({ - components: [ - { - type: ComponentType.ActionRow, - components: [ - { - type: ComponentType.Button, - label: buttonText, - style: ButtonStyle.Primary, - customId: "open-ticket", - }, - ], - }, - ], - }), - ); + const interactionResponse = yield* interactionReply(interaction, { + components: [ + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + label: buttonText, + style: ButtonStyle.Primary, + customId: "open-ticket", + }, + ], + }, + ], + }); const producedMessage = yield* Effect.tryPromise(() => interactionResponse.fetch(), @@ -108,11 +110,9 @@ export const Command = [ let roleId = pingableRole?.id; if (!roleId) { - const { [SETTINGS.moderator]: mod } = yield* Effect.tryPromise(() => - fetchSettings(interaction.guild!.id, [ - SETTINGS.moderator, - SETTINGS.modLog, - ]), + const { [SETTINGS.moderator]: mod } = yield* fetchSettingsEffect( + interaction.guild.id, + [SETTINGS.moderator, SETTINGS.modLog], ); roleId = mod; } @@ -134,15 +134,14 @@ export const Command = [ ticketChannel?.id ?? interaction.channelId, ); }).pipe( + Effect.provide(DatabaseLayer), Effect.catchAll((error) => Effect.gen(function* () { yield* logEffect( "error", "TicketsSetup", "Error setting up tickets", - { - error: String(error), - }, + { error }, ); }), ), @@ -198,12 +197,10 @@ export const Command = [ !interaction.guild || !interaction.message ) { - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Something went wrong while creating a ticket", - flags: MessageFlags.Ephemeral, - }), - ); + yield* interactionReply(interaction, { + content: "Something went wrong while creating a ticket", + flags: MessageFlags.Ephemeral, + }); return; } @@ -220,11 +217,9 @@ export const Command = [ // If there's no config, that means that the button was set up before the db was set up. Add one with default values if (!config) { - const { [SETTINGS.moderator]: mod } = yield* Effect.tryPromise(() => - fetchSettings(interaction.guild!.id, [ - SETTINGS.moderator, - SETTINGS.modLog, - ]), + const { [SETTINGS.moderator]: mod } = yield* fetchSettingsEffect( + interaction.guild.id, + [SETTINGS.moderator, SETTINGS.modLog], ); config = yield* Effect.tryPromise(() => db @@ -244,19 +239,16 @@ export const Command = [ // If channel_id is configured but fetch returns null (channel deleted), // this will error, which is intended - the configured channel is invalid const ticketsChannel = config.channel_id - ? yield* Effect.tryPromise(() => - interaction.guild!.channels.fetch(config.channel_id!), - ) + ? yield* fetchChannel(interaction.guild, config.channel_id) : channel; if ( !ticketsChannel?.isTextBased() || ticketsChannel.type !== ChannelType.GuildText ) { - yield* Effect.tryPromise(() => - interaction.reply( - "Couldn't make a ticket! Tell the admins that their ticket channel is misconfigured.", - ), + yield* interactionReply( + interaction, + "Couldn't make a ticket! Tell the admins that their ticket channel is misconfigured.", ); return; } @@ -270,64 +262,57 @@ export const Command = [ }), ); - yield* Effect.tryPromise(() => - thread.send({ - content: `<@${user.id}>, this is a private space only visible to you and the <@&${config.role_id}> role.`, - }), - ); + yield* sendMessage(thread, { + content: `<@${user.id}>, this is a private space only visible to you and the <@&${config.role_id}> role.`, + }); - yield* Effect.tryPromise(() => - thread.send(`${user.displayName} said: -${quoteMessageContent(concern)}`), + yield* sendMessage( + thread, + `${user.displayName} said:\n${quoteMessageContent(concern)}`, ); - yield* Effect.tryPromise(() => - thread.send({ - content: "When you've finished, please close the ticket.", - components: [ - // @ts-expect-error Types for this are super busted - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`close-ticket||${user.id}|| `) - .setLabel("Close ticket") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`close-ticket||${user.id}||👍`) - .setLabel("Close (👍)") - .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`close-ticket||${user.id}||👎`) - .setLabel("Close (👎)") - .setStyle(ButtonStyle.Danger), - ), - ], - }), - ); + yield* sendMessage(thread, { + content: "When you've finished, please close the ticket.", + components: [ + // @ts-expect-error Types for this are super busted + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`close-ticket||${user.id}|| `) + .setLabel("Close ticket") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`close-ticket||${user.id}||👍`) + .setLabel("Close (👍)") + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`close-ticket||${user.id}||👎`) + .setLabel("Close (👎)") + .setStyle(ButtonStyle.Danger), + ), + ], + }); featureStats.ticketCreated(interaction.guild.id, user.id, thread.id); - yield* Effect.tryPromise(() => - interaction.reply({ - content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`, - flags: [MessageFlags.Ephemeral], - }), - ); + yield* interactionReply(interaction, { + content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`, + flags: [MessageFlags.Ephemeral], + }); }).pipe( + Effect.provide(DatabaseLayer), Effect.catchAll((error) => Effect.gen(function* () { yield* logEffect( "error", "TicketsModal", "Error creating ticket from modal", - { error: String(error) }, + { error }, ); - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Something went wrong while creating the ticket", - flags: MessageFlags.Ephemeral, - }), - ).pipe(Effect.catchAll(() => Effect.void)); + yield* interactionReply(interaction, { + content: "Something went wrong while creating the ticket", + flags: MessageFlags.Ephemeral, + }).pipe(Effect.catchAll(() => Effect.void)); }), ), Effect.withSpan("modalOpenTicket", { @@ -355,17 +340,16 @@ ${quoteMessageContent(concern)}`), "No member in ticket interaction", { interactionId: interaction.id }, ); - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Something went wrong", - flags: [MessageFlags.Ephemeral], - }), - ); + yield* interactionReply(interaction, { + content: "Something went wrong", + flags: [MessageFlags.Ephemeral], + }); return; } - const { [SETTINGS.modLog]: modLog } = yield* Effect.tryPromise(() => - fetchSettings(interaction.guild!.id, [SETTINGS.modLog]), + const { [SETTINGS.modLog]: modLog } = yield* fetchSettingsEffect( + interaction.guild.id, + [SETTINGS.modLog], ); const { user } = interaction.member; @@ -383,12 +367,10 @@ ${quoteMessageContent(concern)}`), }, }), ), - Effect.tryPromise(() => - interaction.reply({ - content: `The ticket was closed by <@${interactionUserId}>`, - allowedMentions: {}, - }), - ), + interactionReply(interaction, { + content: `The ticket was closed by <@${interactionUserId}>`, + allowedMentions: {}, + }), ]); featureStats.ticketClosed( @@ -398,18 +380,17 @@ ${quoteMessageContent(concern)}`), !!feedback?.trim(), ); }).pipe( + Effect.provide(DatabaseLayer), Effect.catchAll((error) => Effect.gen(function* () { yield* logEffect("error", "TicketsClose", "Error closing ticket", { - error: String(error), + error, }); - yield* Effect.tryPromise(() => - interaction.reply({ - content: "Something went wrong while closing the ticket", - flags: MessageFlags.Ephemeral, - }), - ).pipe(Effect.catchAll(() => Effect.void)); + yield* interactionReply(interaction, { + content: "Something went wrong while closing the ticket", + flags: MessageFlags.Ephemeral, + }).pipe(Effect.catchAll(() => Effect.void)); }), ), Effect.withSpan("closeTicket", { diff --git a/app/commands/track.ts b/app/commands/track.ts index cd443a9e..2fe94321 100644 --- a/app/commands/track.ts +++ b/app/commands/track.ts @@ -13,6 +13,15 @@ import { Effect } from "effect"; import { logUserMessage } from "#~/commands/report/userLog.ts"; import { DatabaseLayer } from "#~/Database.ts"; import { client } from "#~/discord/client.server"; +import { + deleteMessage, + fetchChannelFromClient, + fetchMessage, + interactionDeferReply, + interactionEditReply, + interactionUpdate, + messageReply, +} from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; import type { EffectMessageComponentCommand, @@ -35,9 +44,9 @@ export const Command = [ handler: (interaction) => Effect.gen(function* () { // Defer immediately to avoid 3-second timeout - creating threads can take time - yield* Effect.tryPromise(() => - interaction.deferReply({ flags: [MessageFlags.Ephemeral] }), - ); + yield* interactionDeferReply(interaction, { + flags: [MessageFlags.Ephemeral], + }); const { targetMessage: message, user } = interaction; @@ -55,32 +64,30 @@ export const Command = [ staff: user, }); - yield* Effect.tryPromise(() => - interaction.editReply({ - content: `Tracked <#${thread.id}>`, - components: reportId - ? [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`delete-tracked|${reportId}`) - .setLabel("Delete message") - .setStyle(ButtonStyle.Danger), - ), - ] - : [], - }), - ); + yield* interactionEditReply(interaction, { + content: `Tracked <#${thread.id}>`, + components: reportId + ? [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`delete-tracked|${reportId}`) + .setLabel("Delete message") + .setStyle(ButtonStyle.Danger), + ), + ] + : [], + }); }).pipe( Effect.provide(DatabaseLayer), Effect.catchAll((error) => - Effect.gen(function* () { - yield* logEffect("error", "Track", "Error tracking message", { + Effect.all([ + logEffect("error", "Track", "Error tracking message", { error, - }); - yield* Effect.tryPromise(() => - interaction.editReply({ content: "Failed to track message" }), - ).pipe(Effect.catchAll(() => Effect.void)); - }), + }), + interactionEditReply(interaction, { + content: "Failed to track message", + }).pipe(Effect.catchAll(() => Effect.void)), + ]), ), ), } satisfies EffectMessageContextCommand, @@ -96,53 +103,45 @@ export const Command = [ ); if (!report) { - yield* Effect.tryPromise(() => - interaction.update({ - content: "Report not found", - components: [], - }), - ); + yield* interactionUpdate(interaction, { + content: "Report not found", + components: [], + }); return; } - // Try to delete the original message (may already be deleted) - yield* Effect.tryPromise(async () => { - const channel = await client.channels.fetch( - report.reported_channel_id, - ); - if (channel && "messages" in channel) { - const originalMessage = await channel.messages.fetch( - report.reported_message_id, - ); - await originalMessage.delete(); - } - }).pipe(Effect.catchAll(() => Effect.void)); + const channel = yield* fetchChannelFromClient( + client, + report.reported_channel_id, + ); + const originalMessage = yield* fetchMessage( + channel, + report.reported_message_id, + ); + yield* deleteMessage(originalMessage); yield* markMessageAsDeleted( report.reported_message_id, report.guild_id, ).pipe(Effect.provide(DatabaseLayer)); - // Update the log message to show deletion (may not be accessible) - yield* Effect.tryPromise(async () => { - const logChannel = await client.channels.fetch(report.log_channel_id); - if (logChannel && "messages" in logChannel) { - const logMessage = await logChannel.messages.fetch( - report.log_message_id, - ); - await logMessage.reply({ - allowedMentions: { users: [] }, - content: `deleted by ${interaction.user.username}`, - }); - } + const logChannel = yield* fetchChannelFromClient( + client, + report.log_channel_id, + ); + const logMessage = yield* fetchMessage( + logChannel, + report.log_message_id, + ); + yield* messageReply(logMessage, { + allowedMentions: { users: [] }, + content: `deleted by ${interaction.user.username}`, }).pipe(Effect.catchAll(() => Effect.void)); - yield* Effect.tryPromise(() => - interaction.update({ - content: `Tracked <#${report.log_channel_id}>`, - components: [], - }), - ); + yield* interactionUpdate(interaction, { + content: `Tracked <#${report.log_channel_id}>`, + components: [], + }); }).pipe( Effect.catchAll((error) => Effect.gen(function* () { @@ -150,16 +149,12 @@ export const Command = [ "error", "Track", "Error deleting tracked message", - { - error, - }, + { error }, ); - yield* Effect.tryPromise(() => - interaction.update({ - content: "Failed to delete message", - components: [], - }), - ).pipe(Effect.catchAll(() => Effect.void)); + yield* interactionUpdate(interaction, { + content: "Failed to delete message", + components: [], + }).pipe(Effect.catchAll(() => Effect.void)); }), ), ), diff --git a/app/effects/discordSdk.ts b/app/effects/discordSdk.ts index 865087d7..24037512 100644 --- a/app/effects/discordSdk.ts +++ b/app/effects/discordSdk.ts @@ -12,6 +12,8 @@ import type { GuildTextBasedChannel, Message, MessageComponentInteraction, + MessageContextMenuCommandInteraction, + ModalSubmitInteraction, PartialMessage, ThreadChannel, User, @@ -88,6 +90,13 @@ export const fetchMessage = ( new DiscordApiError({ operation: "fetchMessage", cause: error }), }); +export const deleteMessage = (message: Message | PartialMessage) => + Effect.tryPromise({ + try: () => message.delete(), + catch: (error) => + new DiscordApiError({ operation: "deleteMessage", cause: error }), + }); + export const sendMessage = ( channel: GuildTextBasedChannel | ThreadChannel, options: Parameters[0], @@ -122,6 +131,16 @@ export const forwardMessageSafe = (message: Message, targetChannelId: string) => ), ); +export const messageReply = ( + message: Message, + options: Parameters[0], +) => + Effect.tryPromise({ + try: () => message.reply(options), + catch: (error) => + new DiscordApiError({ operation: "messageReply", cause: error }), + }).pipe(Effect.withSpan("messageReply")); + export const replyAndForwardSafe = ( message: Message, content: string, @@ -166,8 +185,10 @@ export const resolveMessagePartial = ( export const interactionReply = ( interaction: | MessageComponentInteraction + | ModalSubmitInteraction | ChatInputCommandInteraction - | UserContextMenuCommandInteraction, + | UserContextMenuCommandInteraction + | MessageContextMenuCommandInteraction, options: Parameters[0], ) => Effect.tryPromise({ @@ -179,7 +200,8 @@ export const interactionDeferReply = ( interaction: | MessageComponentInteraction | ChatInputCommandInteraction - | UserContextMenuCommandInteraction, + | UserContextMenuCommandInteraction + | MessageContextMenuCommandInteraction, options?: Parameters[0], ) => Effect.tryPromise({ @@ -191,7 +213,8 @@ export const interactionEditReply = ( interaction: | MessageComponentInteraction | ChatInputCommandInteraction - | UserContextMenuCommandInteraction, + | UserContextMenuCommandInteraction + | MessageContextMenuCommandInteraction, options: Parameters[0], ) => Effect.tryPromise({ @@ -203,7 +226,8 @@ export const interactionFollowUp = ( interaction: | MessageComponentInteraction | ChatInputCommandInteraction - | UserContextMenuCommandInteraction, + | UserContextMenuCommandInteraction + | MessageContextMenuCommandInteraction, options: Parameters[0], ) => Effect.tryPromise({ diff --git a/app/models/userThreads.ts b/app/models/userThreads.ts index 280fb358..6c721c10 100644 --- a/app/models/userThreads.ts +++ b/app/models/userThreads.ts @@ -10,12 +10,13 @@ import type { Selectable } from "kysely"; import { DatabaseService, type SqlError } from "#~/Database"; import type { DB } from "#~/db"; -import { DiscordApiError } from "#~/effects/errors"; +import { fetchChannel } from "#~/effects/discordSdk.ts"; +import { DiscordApiError, type NotFoundError } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; import { escalationControls } from "#~/helpers/escalate"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; -type ThreadError = DiscordApiError | SqlError; +type ThreadError = DiscordApiError | SqlError | NotFoundError; // Use Selectable to get the type that Kysely returns from queries export type UserThread = Selectable; @@ -170,21 +171,16 @@ const doGetOrCreateUserThread = (guild: Guild, user: User) => if (existingThread) { // Verify the thread still exists and is accessible - const thread = yield* Effect.tryPromise({ - try: () => guild.channels.fetch(existingThread.thread_id), - catch: (error) => error, - }).pipe( + + const thread = yield* fetchChannel(guild, existingThread.thread_id).pipe( Effect.map((channel) => (channel?.isThread() ? channel : null)), Effect.catchAll((error) => - Effect.gen(function* () { - yield* logEffect( - "warn", - "getOrCreateUserThread", - "Existing thread not accessible, will create new one", - { error: String(error) }, - ); - return null; - }), + logEffect( + "warn", + "getOrCreateUserThread", + "Existing thread not accessible, will create new one", + { error }, + ), ), ); @@ -194,17 +190,11 @@ const doGetOrCreateUserThread = (guild: Guild, user: User) => } // Create new thread and store in database - const { modLog: modLogId } = yield* Effect.tryPromise({ - try: () => fetchSettings(guild.id, [SETTINGS.modLog]), - catch: (error) => - new DiscordApiError({ operation: "fetchSettings", cause: error }), - }); + const { modLog: modLogId } = yield* fetchSettingsEffect(guild.id, [ + SETTINGS.modLog, + ]); - const modLog = yield* Effect.tryPromise({ - try: () => guild.channels.fetch(modLogId), - catch: (error) => - new DiscordApiError({ operation: "fetchModLogChannel", cause: error }), - }); + const modLog = yield* fetchChannel(guild, modLogId); if (!modLog || modLog.type !== ChannelType.GuildText) { return yield* Effect.fail( From 5304c51a887aca3b12b0d8c605db39d31a01ba10 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 29 Jan 2026 23:40:10 -0500 Subject: [PATCH 12/14] Simplify interaction handling logic and refine logging --- app/discord/gateway.ts | 161 +++++++++++++++++++---------------------- 1 file changed, 74 insertions(+), 87 deletions(-) diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index a1498f4e..39d5de98 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -18,6 +18,7 @@ import { isModalCommand, isSlashCommand, isUserContextCommand, + type AnyCommand, } from "#~/helpers/discord.ts"; import { botStats, shutdownMetrics } from "#~/helpers/metrics"; import { log, trackPerformance } from "#~/helpers/observability"; @@ -122,102 +123,88 @@ export default function init() { }); client.on(Events.InteractionCreate, (interaction) => { - log("info", "deployCommands", "Handling interaction", { + log("debug", "deployCommands", "Handling interaction", { type: interaction.type, id: interaction.id, }); + let config: AnyCommand | undefined = undefined; switch (interaction.type) { case InteractionType.ApplicationCommand: { - const config = matchCommand(interaction.commandName); - if (!config) return; - - // Effect commands handle themselves - just run and return - if (isEffectCommand(config)) { - void runEffect(config.handler(interaction as never)); - return; - } - - if ( - isMessageContextCommand(config) && - interaction.isMessageContextMenuCommand() - ) { - log( - "info", - "Message Context command received", - `${interaction.commandName} ${interaction.id} messageId: ${interaction.targetMessage.id}`, - ); - void config.handler(interaction); - return; - } - if ( - isUserContextCommand(config) && - interaction.isUserContextMenuCommand() - ) { - log( - "info", - "User Context command received", - `${interaction.commandName} ${interaction.id} userId: ${interaction.targetUser.id}`, - ); - void config.handler(interaction); - return; - } - if (isSlashCommand(config) && interaction.isChatInputCommand()) { - log( - "info", - "Slash command received", - `${interaction.commandName} ${interaction.id}`, - ); - void config.handler(interaction); - return; - } - throw new Error("Didn't find a handler for an interaction"); - } - - case InteractionType.MessageComponent: { - const config = matchCommand(interaction.customId); - if (!config) return; - - // Effect commands handle themselves - just run and return - if (isEffectCommand(config)) { - void runEffect(config.handler(interaction as never)); - return; - } - - if ( - isMessageComponentCommand(config) && - interaction.isMessageComponent() - ) { - log( - "info", - "Message component interaction received", - `${interaction.customId} ${interaction.id} messageId: ${interaction.message.id}`, - ); - void config.handler(interaction); - return; - } - return; + config = matchCommand(interaction.commandName); + break; } + case InteractionType.MessageComponent: case InteractionType.ModalSubmit: { - const config = matchCommand(interaction.customId); - if (!config) return; - - // Effect commands handle themselves - just run and return - if (isEffectCommand(config)) { - void runEffect(config.handler(interaction as never)); - return; - } - - if (isModalCommand(config) && interaction.isModalSubmit()) { - log( - "info", - "Modal submit received", - `${interaction.customId} ${interaction.id} messageId: ${interaction.message?.id ?? "null"}`, - ); - void config.handler(interaction); - } - return; + config = matchCommand(interaction.customId); + break; } } + + if (!config) { + log("debug", "deployCommands", "no matching command found"); + return; + } + log("debug", "deployCommands", "found matching command", { config }); + + // Effect commands handle themselves - just run and return + if (isEffectCommand(config)) { + void runEffect(config.handler(interaction as never)); + return; + } + + if ( + isMessageContextCommand(config) && + interaction.isMessageContextMenuCommand() + ) { + log( + "info", + "Message Context command received", + `${interaction.commandName} ${interaction.id} messageId: ${interaction.targetMessage.id}`, + ); + void config.handler(interaction); + return; + } + if ( + isUserContextCommand(config) && + interaction.isUserContextMenuCommand() + ) { + log( + "info", + "User Context command received", + `${interaction.commandName} ${interaction.id} userId: ${interaction.targetUser.id}`, + ); + void config.handler(interaction); + return; + } + if (isSlashCommand(config) && interaction.isChatInputCommand()) { + log( + "info", + "Slash command received", + `${interaction.commandName} ${interaction.id}`, + ); + void config.handler(interaction); + return; + } + // throw new Error("Didn't find a handler for an interaction"); + + if (isModalCommand(config) && interaction.isModalSubmit()) { + log( + "info", + "Modal submit received", + `${interaction.customId} ${interaction.id} messageId: ${interaction.message?.id ?? "null"}`, + ); + void config.handler(interaction); + } + + if (isMessageComponentCommand(config) && interaction.isMessageComponent()) { + log( + "info", + "Message component interaction received", + `${interaction.customId} ${interaction.id} messageId: ${interaction.message.id}`, + ); + void config.handler(interaction); + return; + } }); // client.on(Events.messageCreate, async (msg) => { From fac1c359a9486b05958b393345561d5339ab7bf2 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 30 Jan 2026 00:52:21 -0500 Subject: [PATCH 13/14] Complete switchover to Effect for Discord command handling --- app/commands/demo.ts | 15 ++--- app/commands/escalationControls.ts | 18 +++--- app/commands/force-ban.ts | 5 +- app/commands/report.ts | 5 +- app/commands/setup.ts | 5 +- app/commands/setupHoneypot.ts | 5 +- app/commands/setupReactjiChannel.ts | 5 +- app/commands/setupTickets.ts | 18 ++---- app/commands/track.ts | 10 ++- app/discord/deployCommands.server.ts | 29 +-------- app/discord/gateway.ts | 82 +++-------------------- app/helpers/discord.ts | 97 ++++++---------------------- 12 files changed, 65 insertions(+), 229 deletions(-) diff --git a/app/commands/demo.ts b/app/commands/demo.ts index c94c183b..2ec7b3f9 100644 --- a/app/commands/demo.ts +++ b/app/commands/demo.ts @@ -8,14 +8,13 @@ import { Effect } from "effect"; import { interactionReply } from "#~/effects/discordSdk.ts"; import type { - EffectMessageContextCommand, - EffectSlashCommand, - EffectUserContextCommand, + MessageContextCommand, + SlashCommand, + UserContextCommand, } from "#~/helpers/discord"; export const Command = [ { - type: "effect", command: new SlashCommandBuilder() .setName("demo") .setDescription("TODO: replace everything in here"), @@ -24,9 +23,8 @@ export const Command = [ flags: [MessageFlags.Ephemeral], content: "ok", }).pipe(Effect.catchAll(() => Effect.void)), - } satisfies EffectSlashCommand, + } satisfies SlashCommand, { - type: "effect", command: new ContextMenuCommandBuilder() .setName("demo") .setType(ApplicationCommandType.User), @@ -35,9 +33,8 @@ export const Command = [ flags: [MessageFlags.Ephemeral], content: "ok", }).pipe(Effect.catchAll(() => Effect.void)), - } satisfies EffectUserContextCommand, + } satisfies UserContextCommand, { - type: "effect", command: new ContextMenuCommandBuilder() .setName("demo") .setType(ApplicationCommandType.Message), @@ -46,5 +43,5 @@ export const Command = [ flags: [MessageFlags.Ephemeral], content: "ok", }).pipe(Effect.catchAll(() => Effect.void)), - } satisfies EffectMessageContextCommand, + } satisfies MessageContextCommand, ]; diff --git a/app/commands/escalationControls.ts b/app/commands/escalationControls.ts index 47f37f59..64c5f10e 100644 --- a/app/commands/escalationControls.ts +++ b/app/commands/escalationControls.ts @@ -1,6 +1,6 @@ import { InteractionType } from "discord.js"; -import { type EffectMessageComponentCommand } from "#~/helpers/discord"; +import { type MessageComponentCommand } from "#~/helpers/discord"; import { resolutions } from "#~/helpers/modResponse"; import { EscalationHandlers } from "./escalate/handlers"; @@ -12,18 +12,18 @@ const button = (name: string) => ({ const h = EscalationHandlers; -export const EscalationCommands: EffectMessageComponentCommand[] = [ - { type: "effect", command: button("escalate-escalate"), handler: h.escalate }, +export const EscalationCommands: MessageComponentCommand[] = [ + { command: button("escalate-escalate"), handler: h.escalate }, // Direct action commands (no voting) - { type: "effect", command: button("escalate-delete"), handler: h.delete }, - { type: "effect", command: button("escalate-kick"), handler: h.kick }, - { type: "effect", command: button("escalate-ban"), handler: h.ban }, - { type: "effect", command: button("escalate-restrict"), handler: h.restrict }, - { type: "effect", command: button("escalate-timeout"), handler: h.timeout }, + { command: button("escalate-delete"), handler: h.delete }, + { command: button("escalate-kick"), handler: h.kick }, + { command: button("escalate-ban"), handler: h.ban }, + { command: button("escalate-restrict"), handler: h.restrict }, + { command: button("escalate-timeout"), handler: h.timeout }, // Expedite handler - { type: "effect", command: button("expedite"), handler: h.expedite }, + { command: button("expedite"), handler: h.expedite }, // Create vote handlers for each resolution ...Object.values(resolutions).map((resolution) => ({ diff --git a/app/commands/force-ban.ts b/app/commands/force-ban.ts index d2ed0506..97b66926 100644 --- a/app/commands/force-ban.ts +++ b/app/commands/force-ban.ts @@ -8,11 +8,10 @@ import { Effect } from "effect"; import { interactionReply } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; -import type { EffectUserContextCommand } from "#~/helpers/discord"; +import type { UserContextCommand } from "#~/helpers/discord"; import { commandStats } from "#~/helpers/metrics"; export const Command = { - type: "effect", command: new ContextMenuCommandBuilder() .setName("Force Ban") .setType(ApplicationCommandType.User) @@ -100,4 +99,4 @@ export const Command = { }, }), ), -} satisfies EffectUserContextCommand; +} satisfies UserContextCommand; diff --git a/app/commands/report.ts b/app/commands/report.ts index f862d54d..e802e023 100644 --- a/app/commands/report.ts +++ b/app/commands/report.ts @@ -13,12 +13,11 @@ import { interactionEditReply, } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; -import type { EffectMessageContextCommand } from "#~/helpers/discord"; +import type { MessageContextCommand } from "#~/helpers/discord"; import { commandStats } from "#~/helpers/metrics"; import { ReportReasons } from "#~/models/reportedMessages.ts"; export const Command = { - type: "effect", command: new ContextMenuCommandBuilder() .setName("Report") .setType(ApplicationCommandType.Message) @@ -76,4 +75,4 @@ export const Command = { }, }), ), -} satisfies EffectMessageContextCommand; +} satisfies MessageContextCommand; diff --git a/app/commands/setup.ts b/app/commands/setup.ts index 0c885613..206cd112 100644 --- a/app/commands/setup.ts +++ b/app/commands/setup.ts @@ -3,12 +3,11 @@ import { Effect } from "effect"; import { interactionReply } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; -import type { EffectSlashCommand } from "#~/helpers/discord"; +import type { SlashCommand } from "#~/helpers/discord"; import { commandStats } from "#~/helpers/metrics"; import { registerGuild, setSettings, SETTINGS } from "#~/models/guilds.server"; export const Command = { - type: "effect", command: new SlashCommandBuilder() .setName("setup") .setDescription("Set up necessities for using the bot") @@ -120,4 +119,4 @@ ${err.toString()} }, }), ), -} satisfies EffectSlashCommand; +} satisfies SlashCommand; diff --git a/app/commands/setupHoneypot.ts b/app/commands/setupHoneypot.ts index b3babef7..9ad03240 100644 --- a/app/commands/setupHoneypot.ts +++ b/app/commands/setupHoneypot.ts @@ -10,7 +10,7 @@ import { Effect } from "effect"; import db from "#~/db.server.js"; import { interactionReply, sendMessage } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; -import type { EffectSlashCommand } from "#~/helpers/discord.js"; +import type { SlashCommand } from "#~/helpers/discord.js"; import { featureStats } from "#~/helpers/metrics"; const DEFAULT_MESSAGE_TEXT = @@ -18,7 +18,6 @@ const DEFAULT_MESSAGE_TEXT = export const Command = [ { - type: "effect", command: new SlashCommandBuilder() .setName("honeypot-setup") .addChannelOption((o) => { @@ -116,5 +115,5 @@ export const Command = [ }, }), ), - } satisfies EffectSlashCommand, + } satisfies SlashCommand, ]; diff --git a/app/commands/setupReactjiChannel.ts b/app/commands/setupReactjiChannel.ts index 9a936ce2..28c70f40 100644 --- a/app/commands/setupReactjiChannel.ts +++ b/app/commands/setupReactjiChannel.ts @@ -9,11 +9,10 @@ import { Effect } from "effect"; import db from "#~/db.server.js"; import { interactionReply } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; -import type { EffectSlashCommand } from "#~/helpers/discord"; +import type { SlashCommand } from "#~/helpers/discord"; import { featureStats } from "#~/helpers/metrics"; export const Command = { - type: "effect", command: new SlashCommandBuilder() .setName("setup-reactji-channel") .addStringOption((o) => { @@ -130,4 +129,4 @@ export const Command = { }, }), ), -} satisfies EffectSlashCommand; +} satisfies SlashCommand; diff --git a/app/commands/setupTickets.ts b/app/commands/setupTickets.ts index d3220e5a..bc5b0567 100644 --- a/app/commands/setupTickets.ts +++ b/app/commands/setupTickets.ts @@ -26,9 +26,9 @@ import { import { logEffect } from "#~/effects/observability.ts"; import { quoteMessageContent, - type EffectMessageComponentCommand, - type EffectModalCommand, - type EffectSlashCommand, + type MessageComponentCommand, + type ModalCommand, + type SlashCommand, } from "#~/helpers/discord"; import { featureStats } from "#~/helpers/metrics"; import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; @@ -37,7 +37,6 @@ const DEFAULT_BUTTON_TEXT = "Open a private ticket with the moderators"; export const Command = [ { - type: "effect", command: new SlashCommandBuilder() .setName("tickets-channel") .addRoleOption((o) => { @@ -152,10 +151,9 @@ export const Command = [ }, }), ), - } satisfies EffectSlashCommand, + } satisfies SlashCommand, { - type: "effect", command: { type: InteractionType.MessageComponent, name: "open-ticket" }, handler: (interaction) => Effect.gen(function* () { @@ -184,10 +182,9 @@ export const Command = [ }, }), ), - } satisfies EffectMessageComponentCommand, + } satisfies MessageComponentCommand, { - type: "effect", command: { type: InteractionType.ModalSubmit, name: "modal-open-ticket" }, handler: (interaction) => Effect.gen(function* () { @@ -322,10 +319,9 @@ export const Command = [ }, }), ), - } satisfies EffectModalCommand, + } satisfies ModalCommand, { - type: "effect", command: { type: InteractionType.MessageComponent, name: "close-ticket" }, handler: (interaction) => Effect.gen(function* () { @@ -400,5 +396,5 @@ export const Command = [ }, }), ), - } satisfies EffectMessageComponentCommand, + } satisfies MessageComponentCommand, ]; diff --git a/app/commands/track.ts b/app/commands/track.ts index 2fe94321..d63f3305 100644 --- a/app/commands/track.ts +++ b/app/commands/track.ts @@ -24,8 +24,8 @@ import { } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; import type { - EffectMessageComponentCommand, - EffectMessageContextCommand, + MessageComponentCommand, + MessageContextCommand, } from "#~/helpers/discord"; import { featureStats } from "#~/helpers/metrics"; import { @@ -36,7 +36,6 @@ import { export const Command = [ { - type: "effect", command: new ContextMenuCommandBuilder() .setName("Track") .setType(ApplicationCommandType.Message) @@ -90,9 +89,8 @@ export const Command = [ ]), ), ), - } satisfies EffectMessageContextCommand, + } satisfies MessageContextCommand, { - type: "effect", command: { type: InteractionType.MessageComponent, name: "delete-tracked" }, handler: (interaction) => Effect.gen(function* () { @@ -158,5 +156,5 @@ export const Command = [ }), ), ), - } satisfies EffectMessageComponentCommand, + } satisfies MessageComponentCommand, ]; diff --git a/app/discord/deployCommands.server.ts b/app/discord/deployCommands.server.ts index 2951fd53..9ae7529a 100644 --- a/app/discord/deployCommands.server.ts +++ b/app/discord/deployCommands.server.ts @@ -9,7 +9,6 @@ import { import { ssrDiscordSdk } from "#~/discord/api"; import { - isEffectCommand, isMessageContextCommand, isSlashCommand, isUserContextCommand, @@ -17,7 +16,6 @@ import { } from "#~/helpers/discord"; import { calculateChangedCommands } from "#~/helpers/discordCommands"; import { applicationId, isProd } from "#~/helpers/env.server"; -import { log, trackPerformance } from "#~/helpers/observability.js"; /** * deployCommands notifies Discord of the latest commands to use and registers @@ -179,38 +177,15 @@ export const deployTestCommands = async ( ); }; -const withPerf = (config: T): T => { - // Effect commands handle their own spans via Effect.withSpan - if (isEffectCommand(config)) { - return config; - } - - const { command, handler } = config; - return { - command, - handler: (interaction: Parameters[0]) => { - void trackPerformance(`withPerf HoF ${command.name}`, async () => { - try { - // @ts-expect-error Unclear why this isn't working but it seems fine - await handler(interaction); - } catch (e) { - log("debug", `perf`, "rethrowing error", { error: e }); - throw e; - } - }); - }, - } as T; -}; - const commands = new Map(); export const registerCommand = (config: AnyCommand | AnyCommand[]) => { if (Array.isArray(config)) { config.forEach((c) => { - commands.set(c.command.name, withPerf(c)); + commands.set(c.command.name, c); }); return; } - commands.set(config.command.name, withPerf(config)); + commands.set(config.command.name, config); }; export const matchCommand = (customId: string) => { const config = commands.get(customId); diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index 39d5de98..5a1ec851 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -11,15 +11,7 @@ import { startEscalationResolver } from "#~/discord/escalationResolver"; import onboardGuild from "#~/discord/onboardGuild"; import { startReactjiChanneler } from "#~/discord/reactjiChanneler"; import { runEffect } from "#~/effects/runtime"; -import { - isEffectCommand, - isMessageComponentCommand, - isMessageContextCommand, - isModalCommand, - isSlashCommand, - isUserContextCommand, - type AnyCommand, -} from "#~/helpers/discord.ts"; +import { type AnyCommand } from "#~/helpers/discord.ts"; import { botStats, shutdownMetrics } from "#~/helpers/metrics"; import { log, trackPerformance } from "#~/helpers/observability"; import Sentry from "#~/helpers/sentry.server"; @@ -126,6 +118,12 @@ export default function init() { log("debug", "deployCommands", "Handling interaction", { type: interaction.type, id: interaction.id, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + commandName: interaction.commandName, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + customId: interaction.customId, }); let config: AnyCommand | undefined = undefined; switch (interaction.type) { @@ -146,73 +144,9 @@ export default function init() { } log("debug", "deployCommands", "found matching command", { config }); - // Effect commands handle themselves - just run and return - if (isEffectCommand(config)) { - void runEffect(config.handler(interaction as never)); - return; - } - - if ( - isMessageContextCommand(config) && - interaction.isMessageContextMenuCommand() - ) { - log( - "info", - "Message Context command received", - `${interaction.commandName} ${interaction.id} messageId: ${interaction.targetMessage.id}`, - ); - void config.handler(interaction); - return; - } - if ( - isUserContextCommand(config) && - interaction.isUserContextMenuCommand() - ) { - log( - "info", - "User Context command received", - `${interaction.commandName} ${interaction.id} userId: ${interaction.targetUser.id}`, - ); - void config.handler(interaction); - return; - } - if (isSlashCommand(config) && interaction.isChatInputCommand()) { - log( - "info", - "Slash command received", - `${interaction.commandName} ${interaction.id}`, - ); - void config.handler(interaction); - return; - } - // throw new Error("Didn't find a handler for an interaction"); - - if (isModalCommand(config) && interaction.isModalSubmit()) { - log( - "info", - "Modal submit received", - `${interaction.customId} ${interaction.id} messageId: ${interaction.message?.id ?? "null"}`, - ); - void config.handler(interaction); - } - - if (isMessageComponentCommand(config) && interaction.isMessageComponent()) { - log( - "info", - "Message component interaction received", - `${interaction.customId} ${interaction.id} messageId: ${interaction.message.id}`, - ); - void config.handler(interaction); - return; - } + void runEffect(config.handler(interaction as never)); }); - // 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/helpers/discord.ts b/app/helpers/discord.ts index e5a0b51b..a6abb54c 100644 --- a/app/helpers/discord.ts +++ b/app/helpers/discord.ts @@ -1,13 +1,13 @@ import { ApplicationCommandType, ContextMenuCommandBuilder, - InteractionType, SlashCommandBuilder, type APIEmbed, type ChatInputCommandInteraction, type Collection, type Guild, type GuildMember, + type InteractionType, type Message, type MessageComponentInteraction, type MessageContextMenuCommandInteraction, @@ -23,7 +23,7 @@ import { partition } from "lodash-es"; import prettyBytes from "pretty-bytes"; import { resolveMessagePartial } from "#~/effects/discordSdk"; -import { type DiscordApiError, NotFoundError } from "#~/effects/errors.ts"; +import { NotFoundError, type DiscordApiError } from "#~/effects/errors.ts"; import { getChars, getWords, @@ -145,110 +145,51 @@ ${poll.answers.map((a) => `> - ${a.text}`).join("\n")}`; }; // -// Types and type helpers for command configs -// -export type AnyCommand = - | MessageContextCommand - | UserContextCommand - | SlashCommand - | MessageComponentCommand - | ModalCommand - | AnyEffectCommand; - -export interface MessageContextCommand { - command: ContextMenuCommandBuilder; - handler: (interaction: MessageContextMenuCommandInteraction) => Promise; -} export const isMessageContextCommand = ( config: AnyCommand, ): config is MessageContextCommand => config.command instanceof ContextMenuCommandBuilder && config.command.type === ApplicationCommandType.Message; - -export interface UserContextCommand { - command: ContextMenuCommandBuilder; - handler: (interaction: UserContextMenuCommandInteraction) => Promise; -} export const isUserContextCommand = ( config: AnyCommand, ): config is UserContextCommand => config.command instanceof ContextMenuCommandBuilder && config.command.type === ApplicationCommandType.User; - -export interface SlashCommand { - command: SlashCommandBuilder; - handler: (interaction: ChatInputCommandInteraction) => Promise; -} export const isSlashCommand = (config: AnyCommand): config is SlashCommand => config.command instanceof SlashCommandBuilder; - -export interface MessageComponentCommand { - command: { type: InteractionType.MessageComponent; name: string }; - handler: (interaction: MessageComponentInteraction) => Promise; -} -export const isMessageComponentCommand = ( - config: AnyCommand, -): config is MessageComponentCommand => - "type" in config.command && - config.command.type === InteractionType.MessageComponent; - -export interface ModalCommand { - command: { type: InteractionType.ModalSubmit; name: string }; - handler: (interaction: ModalSubmitInteraction) => Promise; -} -export const isModalCommand = (config: AnyCommand): config is ModalCommand => - "type" in config.command && - config.command.type === InteractionType.ModalSubmit; - -// // Effect-based command types // Handlers must be fully self-contained: E = never, R = never, A = void // -export type EffectHandler = ( - interaction: I, -) => Effect.Effect; +export type Handler = (interaction: I) => Effect.Effect; -export interface EffectSlashCommand { - type: "effect"; +export interface SlashCommand { command: SlashCommandBuilder; - handler: EffectHandler; + handler: Handler; } - -export interface EffectMessageComponentCommand { - type: "effect"; +export interface MessageComponentCommand { command: { type: InteractionType.MessageComponent; name: string }; - handler: EffectHandler; + handler: Handler; } - -export interface EffectUserContextCommand { - type: "effect"; +export interface UserContextCommand { command: ContextMenuCommandBuilder; - handler: EffectHandler; + handler: Handler; } - -export interface EffectMessageContextCommand { - type: "effect"; +export interface MessageContextCommand { command: ContextMenuCommandBuilder; - handler: EffectHandler; + handler: Handler; } - -export interface EffectModalCommand { - type: "effect"; +export interface ModalCommand { command: { type: InteractionType.ModalSubmit; name: string }; - handler: EffectHandler; + handler: Handler; } -export type AnyEffectCommand = - | EffectSlashCommand - | EffectMessageComponentCommand - | EffectUserContextCommand - | EffectMessageContextCommand - | EffectModalCommand; - -export const isEffectCommand = ( - config: AnyCommand, -): config is AnyEffectCommand => "type" in config && config.type === "effect"; +export type AnyCommand = + | SlashCommand + | MessageComponentCommand + | UserContextCommand + | MessageContextCommand + | ModalCommand; export interface CodeStats { chars: number; From 211ae79ba81165ca988bef5068e642e18f39dca7 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 30 Jan 2026 01:03:58 -0500 Subject: [PATCH 14/14] Suppress debug logs in production --- app/effects/runtime.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/app/effects/runtime.ts b/app/effects/runtime.ts index 5ab80380..481677fa 100644 --- a/app/effects/runtime.ts +++ b/app/effects/runtime.ts @@ -1,4 +1,7 @@ -import { Effect, Layer, Logger } from "effect"; +import { Effect, Layer, Logger, LogLevel } from "effect"; + +import { isProd } from "#~/helpers/env.server.js"; +import { log } from "#~/helpers/observability.js"; import { TracingLive } from "./tracing.js"; @@ -25,9 +28,23 @@ const RuntimeLive = Layer.merge(TracingLive, Logger.json); * Automatically provides tracing (Sentry) and logging (JSON to stdout). * Throws if the Effect fails. */ -export const runEffect = ( +export const runEffect = async ( effect: Effect.Effect, -): Promise => Effect.runPromise(effect.pipe(Effect.provide(RuntimeLive))); +): Promise => { + try { + const program = effect.pipe(Effect.provide(RuntimeLive)); + return Effect.runPromise( + isProd() + ? program.pipe(Logger.withMinimumLogLevel(LogLevel.Info)) + : program, + ); + } catch (error) { + log("error", "runtime", "Caught an error while executing Effect", { + error, + }); + throw error; + } +}; /** * Run an Effect and return a Promise that resolves with an Exit value.