diff --git a/app/commands/demo.ts b/app/commands/demo.ts index 719b5258..2ec7b3f9 100644 --- a/app/commands/demo.ts +++ b/app/commands/demo.ts @@ -1,24 +1,47 @@ 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 { interactionReply } from "#~/effects/discordSdk.ts"; +import type { + MessageContextCommand, + SlashCommand, + UserContextCommand, +} 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 = [ + { + command: new SlashCommandBuilder() + .setName("demo") + .setDescription("TODO: replace everything in here"), + handler: (interaction) => + interactionReply(interaction, { + flags: [MessageFlags.Ephemeral], + content: "ok", + }).pipe(Effect.catchAll(() => Effect.void)), + } satisfies SlashCommand, + { + command: new ContextMenuCommandBuilder() + .setName("demo") + .setType(ApplicationCommandType.User), + handler: (interaction) => + interactionReply(interaction, { + flags: [MessageFlags.Ephemeral], + content: "ok", + }).pipe(Effect.catchAll(() => Effect.void)), + } satisfies UserContextCommand, + { + command: new ContextMenuCommandBuilder() + .setName("demo") + .setType(ApplicationCommandType.Message), + handler: (interaction) => + interactionReply(interaction, { + flags: [MessageFlags.Ephemeral], + content: "ok", + }).pipe(Effect.catchAll(() => Effect.void)), + } satisfies MessageContextCommand, +]; diff --git a/app/commands/escalate/directActions.ts b/app/commands/escalate/directActions.ts index 97285e40..11187fe2 100644 --- a/app/commands/escalate/directActions.ts +++ b/app/commands/escalate/directActions.ts @@ -4,12 +4,13 @@ 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 { deleteAllReportedForUserEffect } from "#~/models/reportedMessages"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; +import { deleteAllReportedForUser } from "#~/models/reportedMessages"; export interface DeleteMessagesResult { deleted: number; @@ -21,19 +22,13 @@ 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!; // 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( @@ -46,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, @@ -79,20 +71,15 @@ 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!; // 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 +92,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", { @@ -141,20 +124,15 @@ 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!; // 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 +145,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", { @@ -203,20 +176,15 @@ 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!; // 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 +197,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 +208,7 @@ export const restrictUserEffect = (interaction: MessageComponentInteraction) => catch: (error) => new DiscordApiError({ operation: "applyRestriction", - discordError: error, + cause: error, }), }); @@ -268,20 +232,15 @@ 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!; // 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 +253,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..f366e965 100644 --- a/app/commands/escalate/escalationResolver.ts +++ b/app/commands/escalate/escalationResolver.ts @@ -8,14 +8,23 @@ import { } from "discord.js"; import { Effect } from "effect"; -import { DiscordApiError } from "#~/effects/errors"; +import { DatabaseLayer } from "#~/Database.ts"; +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"; @@ -57,19 +66,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( @@ -87,11 +83,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) { @@ -100,57 +92,20 @@ 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, - }, - ); - - // 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))); + }), + ]).pipe(Effect.withConcurrency("unbounded")); - 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 [reportedMember, voteMessage] = yield* Effect.all([ + fetchMemberOrNull(guild, escalation.reported_user_id), + fetchMessage(channel, escalation.vote_message_id), + ]); // Calculate timing info const now = Math.floor(Date.now() / 1000); @@ -164,109 +119,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* Effect.tryPromise({ - try: () => - voteMessage.edit({ components: getDisabledButtons(voteMessage) }), - catch: (error) => - new DiscordApiError({ - operation: "editVoteMessage", - discordError: error, - }), - }); - - // 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* 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* Effect.tryPromise({ - try: () => - voteMessage.edit({ components: getDisabledButtons(voteMessage) }), - catch: (error) => - new DiscordApiError({ - operation: "editVoteMessage", - discordError: error, - }), - }); - - // 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* 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 }, ), - ); - - yield* logEffect( - "info", - "EscalationResolver", - "Successfully auto-resolved escalation", - { ...logBag, resolution }, - ); + ]).pipe(Effect.withConcurrency("unbounded")); return { resolution, userGone: false }; }).pipe( @@ -274,6 +160,7 @@ export const processEscalationEffect = ( attributes: { escalationId: escalation.id, guildId: escalation.guild_id, + reportedUserId: escalation.reported_user_id, }, }), ); @@ -287,57 +174,38 @@ 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.provide(DatabaseLayer), 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 }, + ), ), - ); - } + ), + ); + + 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 }; diff --git a/app/commands/escalate/expedite.ts b/app/commands/escalate/expedite.ts index 602e28eb..044a2f7a 100644 --- a/app/commands/escalate/expedite.ts +++ b/app/commands/escalate/expedite.ts @@ -3,14 +3,14 @@ import { Effect } from "effect"; import { AlreadyResolvedError, - DiscordApiError, NoLeaderError, NotAuthorizedError, + NotFoundError, } from "#~/effects/errors"; 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"; @@ -28,20 +28,20 @@ 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!; 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( @@ -82,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", discordError: 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/handlers.ts b/app/commands/escalate/handlers.ts index a56bf6e1..12c9c00f 100644 --- a/app/commands/escalate/handlers.ts +++ b/app/commands/escalate/handlers.ts @@ -5,26 +5,33 @@ 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"; +import { + editMessage, + interactionDeferReply, + interactionEditReply, + interactionFollowUp, + interactionReply, + interactionUpdate, +} from "#~/effects/discordSdk.ts"; +import { logEffect } from "#~/effects/observability.ts"; import { humanReadableResolutions, type Resolution, } from "#~/helpers/modResponse"; -import { log } from "#~/helpers/observability"; -import { getFailure, runEscalationEffect } from "."; import { - banUserEffect, - deleteMessagesEffect, - kickUserEffect, - restrictUserEffect, - timeoutUserEffect, + banUser, + deleteMessages, + kickUser, + restrictUser, + timeoutUser, } from "./directActions"; import { createEscalationEffect, upgradeToMajorityEffect } from "./escalate"; import { expediteEffect } from "./expedite"; +import { EscalationServiceLive } from "./service"; import { buildConfirmedMessageContent, buildVoteButtons, @@ -33,348 +40,372 @@ import { } from "./strings"; import { voteEffect } from "./vote"; -const deleteMessages = async (interaction: MessageComponentInteraction) => { - await 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 exit = await runEffectExit(kickUserEffect(interaction)); - 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 banUser = async (interaction: MessageComponentInteraction) => { - const reportedUserId = interaction.customId.split("|")[1]; - - const exit = await runEffectExit(banUserEffect(interaction)); - 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 restrictUser = async (interaction: MessageComponentInteraction) => { - const reportedUserId = interaction.customId.split("|")[1]; - - const exit = await runEffectExit(restrictUserEffect(interaction)); - 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 timeoutUser = async (interaction: MessageComponentInteraction) => { - const reportedUserId = interaction.customId.split("|")[1]; - - const exit = await runEffectExit(timeoutUserEffect(interaction)); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Error timing out user", { error }); +const vote = + (resolution: Resolution) => (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const { + escalation, + tally, + modRoleId, + features, + votingStrategy, + earlyResolution, + } = yield* voteEffect(resolution)(interaction); + + // Check if early resolution triggered with clear winner + if (earlyResolution && !tally.isTied && tally.leader) { + yield* interactionUpdate(interaction, { + content: buildConfirmedMessageContent( + escalation, + tally.leader, + tally, + ), + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`expedite|${escalation.id}`) + .setLabel("Expedite") + .setStyle(ButtonStyle.Primary), + ), + ], + }); + return; + } - if (error?._tag === "NotAuthorizedError") { - await interaction.reply({ - content: "Insufficient permissions", - flags: [MessageFlags.Ephemeral], + // Update the message with new vote state + yield* interactionUpdate(interaction, { + content: buildVoteMessageContent( + modRoleId ?? "", + votingStrategy, + escalation, + tally, + ), + components: buildVoteButtons( + features, + votingStrategy, + escalation, + tally, + earlyResolution, + ), }); - return; - } - - await interaction.reply({ - content: "Failed to timeout user", - flags: [MessageFlags.Ephemeral], - }); - return; - } - - const result = exit.value; - await interaction.reply( - `<@${reportedUserId}> timed out by ${result.actionBy}`, - ); -}; - -const vote = (resolution: Resolution) => - async function handleVote( - interaction: MessageComponentInteraction, - ): Promise { - const exit = await runEscalationEffect(voteEffect(resolution)(interaction)); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Error voting", { error, resolution }); - - if (error?._tag === "NotAuthorizedError") { - await interaction.reply({ + }).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, { content: "Only moderators can vote on escalations.", flags: [MessageFlags.Ephemeral], - }); - return; - } - if (error?._tag === "EscalationNotFoundError") { - await interaction.reply({ + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchTag("NotFoundError", () => + interactionReply(interaction, { content: "Escalation not found.", flags: [MessageFlags.Ephemeral], - }); - return; - } - if (error?._tag === "AlreadyResolvedError") { - await interaction.reply({ + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchTag("AlreadyResolvedError", () => + interactionReply(interaction, { 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; - } - - 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; - } - - // Update the message with new vote state - await interaction.update({ - content: buildVoteMessageContent( - modRoleId ?? "", - votingStrategy, - escalation, - tally, + }).pipe(Effect.catchAll(() => Effect.void)), ), - components: buildVoteButtons( - features, - votingStrategy, - escalation, - tally, - earlyResolution, + Effect.catchAll((error) => + 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)), ), - }); - }; + ); -const expedite = async ( - interaction: MessageComponentInteraction, -): Promise => { - await interaction.deferUpdate(); +const expedite = (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + yield* interactionDeferReply(interaction); - const exit = await runEscalationEffect(expediteEffect(interaction)); - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Expedite failed", { error }); + const result = yield* expediteEffect(interaction); - if (error?._tag === "NotAuthorizedError") { - await interaction.followUp({ + const expediteNote = `\nResolved early by <@${interaction.user.id}> at `; + + yield* editMessage(interaction.message, { + content: `**${humanReadableResolutions[result.resolution]}** ✅ <@${result.escalation.reported_user_id}>${expediteNote} +${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, { content: "Only moderators can expedite resolutions.", flags: [MessageFlags.Ephemeral], - }); - return; - } - if (error?._tag === "EscalationNotFoundError") { - await interaction.followUp({ + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchTag("NotFoundError", () => + interactionFollowUp(interaction, { content: "Escalation not found.", flags: [MessageFlags.Ephemeral], - }); - return; - } - if (error?._tag === "AlreadyResolvedError") { - await interaction.followUp({ + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchTag("AlreadyResolvedError", () => + interactionFollowUp(interaction, { content: "This escalation has already been resolved.", flags: [MessageFlags.Ephemeral], - }); - return; - } - if (error?._tag === "NoLeaderError") { - await interaction.followUp({ + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchTag("NoLeaderError", () => + interactionFollowUp(interaction, { 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 - -const escalate = async (interaction: MessageComponentInteraction) => { - await interaction.deferReply({ flags: ["Ephemeral"] }); - - const [_, reportedUserId, level = "0", previousEscalationId = ""] = - interaction.customId.split("|"); - - const escalationId = previousEscalationId || crypto.randomUUID(); - log("info", "EscalationHandlers", "Handling escalation", { - reportedUserId, - escalationId, - level, - }); - - if (Number(level) === 0) { - // Create new escalation - const exit = await runEscalationEffect( - createEscalationEffect(interaction, reportedUserId, escalationId), - ); - - if (exit._tag === "Failure") { - const error = getFailure(exit.cause); - log("error", "EscalationHandlers", "Error creating escalation vote", { + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchAll((error) => + logEffect("error", "EscalationHandlers", "Expedite failed", { error, - }); - await interaction.editReply({ - content: "Failed to create escalation vote", - }); - return; - } + }).pipe(() => + interactionFollowUp(interaction, { + content: "Something went wrong while executing the resolution.", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + ), + ); - await interaction.editReply("Escalation started"); - } else { - // Upgrade to majority voting - const exit = await runEscalationEffect( - upgradeToMajorityEffect(interaction, escalationId), - ); +const escalate = (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + yield* interactionDeferReply(interaction, { 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 === "EscalationNotFoundError") { - 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" }); + if (Number(level) === 0) { + // Create new escalation + yield* createEscalationEffect(interaction, reportedUserId, escalationId); + yield* interactionEditReply(interaction, "Escalation started"); + } else { + // Upgrade to majority voting + yield* upgradeToMajorityEffect(interaction, escalationId); + yield* interactionEditReply( + interaction, + "Escalation upgraded to majority voting", + ); return; } - - await interaction.editReply("Escalation upgraded to majority voting"); - } -}; + }).pipe( + Effect.withSpan("escalation-escalate", { + attributes: { guildId: interaction.guildId, userId: interaction.user.id }, + }), + 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) => + logEffect("error", "EscalationHandlers", "Error handling escalation", { + error, + }).pipe(() => + interactionEditReply(interaction, { + content: "Failed to process escalation", + }).pipe(Effect.catchAll(() => Effect.void)), + ), + ), + ); 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* interactionDeferReply(interaction); + + const result = yield* deleteMessages(interaction); + + yield* interactionEditReply( + interaction, + `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, { + content: "Insufficient permissions", + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchAll((error) => + logEffect("error", "EscalationHandlers", "Error deleting messages", { + error, + }).pipe(() => + interactionEditReply(interaction, { + content: "Failed to delete messages", + }).pipe(Effect.catchAll(() => Effect.void)), + ), + ), + ), + kick: (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const reportedUserId = interaction.customId.split("|")[1]; + const result = yield* kickUser(interaction); + + 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) => + 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: { + 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* interactionReply( + interaction, + `<@${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, { + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchAll((error) => + logEffect("error", "EscalationHandlers", "Error banning user", { + error, + }).pipe(() => + interactionReply(interaction, { + content: "Failed to ban user", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + ), + ), + restrict: (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const reportedUserId = interaction.customId.split("|")[1]; + + const result = yield* restrictUser(interaction); + + yield* interactionReply( + interaction, + `<@${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, { + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchAll((error) => + logEffect("error", "EscalationHandlers", "Error restricting user", { + error, + }).pipe(() => + interactionReply(interaction, { + content: "Failed to restrict user", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + ), + ), + timeout: (interaction: MessageComponentInteraction) => + Effect.gen(function* () { + const reportedUserId = interaction.customId.split("|")[1]; + + const result = yield* timeoutUser(interaction); + + yield* interactionReply( + interaction, + `<@${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, { + content: "Insufficient permissions", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + Effect.catchAll((error) => + logEffect("error", "EscalationHandlers", "Error timing out user", { + error, + }).pipe(() => + interactionReply(interaction, { + content: "Failed to timeout user", + flags: [MessageFlags.Ephemeral], + }).pipe(Effect.catchAll(() => Effect.void)), + ), + ), + ), // Voting handlers expedite, 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..e1848520 100644 --- a/app/commands/escalate/service.ts +++ b/app/commands/escalate/service.ts @@ -4,9 +4,10 @@ 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, - EscalationNotFoundError, + NotFoundError, ResolutionExecutionError, } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; @@ -35,70 +36,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; + ) => 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< - void, - EscalationNotFoundError | AlreadyResolvedError | SqlError - >; - - /** - * Update the voting strategy for an escalation. - */ + ) => Effect.Effect; + 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, @@ -138,8 +108,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, }); @@ -168,7 +136,7 @@ export const EscalationServiceLive = Layer.effect( if (!escalation) { return yield* Effect.fail( - new EscalationNotFoundError({ escalationId: id }), + new NotFoundError({ id, resource: "escalation" }), ); } @@ -200,11 +168,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 }; @@ -258,7 +222,7 @@ export const EscalationServiceLive = Layer.effect( if (!escalation) { return yield* Effect.fail( - new EscalationNotFoundError({ escalationId: id }), + new NotFoundError({ id, resource: "escalation" }), ); } @@ -279,10 +243,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 }, @@ -300,10 +261,6 @@ export const EscalationServiceLive = Layer.effect( "info", "EscalationService", "Updated voting strategy", - { - escalationId: id, - strategy, - }, ); }).pipe( Effect.withSpan("updateEscalationStrategy", { @@ -322,14 +279,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 }, }), ), @@ -345,9 +298,7 @@ export const EscalationServiceLive = Layer.effect( "debug", "EscalationService", "Found due escalations", - { - count: escalations.length, - }, + { count: escalations.length }, ); return escalations; @@ -367,20 +318,16 @@ 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( "debug", "EscalationService", "Member not found, skipping action", - { - escalationId: escalation.id, - reportedUserId: escalation.reported_user_id, - }, ); return; } @@ -413,11 +360,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/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/escalationControls.ts b/app/commands/escalationControls.ts index 6e717774..64c5f10e 100644 --- a/app/commands/escalationControls.ts +++ b/app/commands/escalationControls.ts @@ -27,6 +27,7 @@ export const EscalationCommands: MessageComponentCommand[] = [ // 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..97b66926 100644 --- a/app/commands/force-ban.ts +++ b/app/commands/force-ban.ts @@ -3,95 +3,100 @@ import { ContextMenuCommandBuilder, MessageFlags, PermissionFlagsBits, - type UserContextMenuCommandInteraction, } from "discord.js"; +import { Effect } from "effect"; +import { interactionReply } from "#~/effects/discordSdk.ts"; +import { logEffect } from "#~/effects/observability.ts"; +import type { UserContextCommand } 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); +export const Command = { + command: new ContextMenuCommandBuilder() + .setName("Force Ban") + .setType(ApplicationCommandType.User) + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + handler: (interaction) => + Effect.gen(function* () { + const { targetUser, guild, user } = interaction; -const handler = async (interaction: UserContextMenuCommandInteraction) => { - await trackPerformance( - "forceBanCommand", - async () => { - const { targetUser } = interaction; - - log("info", "Commands", "Force ban command executed", { + 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({ + yield* interactionReply(interaction, { 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", - }); - - commandStats.commandExecuted(interaction, "force-ban", true); + 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", + }); - await interaction.reply({ - flags: [MessageFlags.Ephemeral], - content: "This member has been banned", - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); + commandStats.commandExecuted(interaction, "force-ban", true); - log("error", "Commands", "Force ban failed", { + yield* interactionReply(interaction, { + flags: [MessageFlags.Ephemeral], + content: "This member has been banned", + }); + }).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* 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", { + 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 UserContextCommand; diff --git a/app/commands/report.ts b/app/commands/report.ts index d06d15c0..e802e023 100644 --- a/app/commands/report.ts +++ b/app/commands/report.ts @@ -3,87 +3,76 @@ 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 { + interactionDeferReply, + interactionEditReply, +} from "#~/effects/discordSdk.ts"; +import { logEffect } from "#~/effects/observability.ts"; +import type { MessageContextCommand } 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 = { + 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* interactionDeferReply(interaction, { + 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({ - content: "This message has been reported anonymously", - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); + yield* interactionEditReply(interaction, { + content: "This message has been reported anonymously", + }); + }).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* interactionEditReply(interaction, { + 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 MessageContextCommand; diff --git a/app/commands/report/automodLog.ts b/app/commands/report/automodLog.ts index 25084811..466d9618 100644 --- a/app/commands/report/automodLog.ts +++ b/app/commands/report/automodLog.ts @@ -1,12 +1,9 @@ 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 { @@ -20,22 +17,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", @@ -52,58 +50,36 @@ 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"; - 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* 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 }, }), ); - -/** - * 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/constructLog.ts b/app/commands/report/constructLog.ts index 8d39d7fa..fd309286 100644 --- a/app/commands/report/constructLog.ts +++ b/app/commands/report/constructLog.ts @@ -9,10 +9,10 @@ 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 = { +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 = "", @@ -38,7 +32,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,28 +40,21 @@ 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"), }), ); } - const { content: report } = makeReportMessage(lastReport); - // Add indicator if this is forwarded content const forwardNote = isForwardedMessage(message) ? " (forwarded)" : ""; const preface = `${constructDiscordLink(message)} by <@${author.id}> (${ @@ -76,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/modActionLog.ts b/app/commands/report/modActionLog.ts index c9aaf4d0..6cadbef5 100644 --- a/app/commands/report/modActionLog.ts +++ b/app/commands/report/modActionLog.ts @@ -1,12 +1,10 @@ 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 +62,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 +91,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: { @@ -130,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/report/modActionLogger.ts b/app/commands/report/modActionLogger.ts index dad30b68..c2ea781d 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, @@ -16,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"; @@ -24,26 +24,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 +249,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, @@ -305,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 d7827b9e..dffcd8e8 100644 --- a/app/commands/report/userLog.ts +++ b/app/commands/report/userLog.ts @@ -11,7 +11,8 @@ import { type DatabaseService, type SqlError, } from "#~/Database"; -import { DiscordApiError } from "#~/effects/errors"; +import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk.ts"; +import { DiscordApiError, type NotFoundError } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; import { runEffect } from "#~/effects/runtime"; import { @@ -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, @@ -35,7 +36,7 @@ import { getOrCreateUserThread } from "#~/models/userThreads.ts"; import { constructLog, isForwardedMessage, - makeReportMessage, + ReadableReasons, } from "./constructLog"; const getMessageContent = (message: Message): string => { @@ -69,7 +70,7 @@ export function logUserMessage({ allReportedMessages: Report[]; reportId: string; }, - DiscordApiError | SqlError, + DiscordApiError | SqlError | NotFoundError, DatabaseService > { return Effect.gen(function* () { @@ -78,7 +79,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"), }), ); } @@ -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", - discordError: error, - }), - }), + fetchSettingsEffect(guild.id, [SETTINGS.modLog]), constructLog({ extra, logs: [{ message, reason, staff }], @@ -118,11 +112,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, ); @@ -136,7 +126,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "logUserMessage existing", - discordError: error, + cause: error, }), }); @@ -185,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", - discordError: 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({ @@ -226,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", discordError: 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,23 +220,17 @@ export function logUserMessage({ const truncatedMsg = singleLine.length > 80 ? `${singleLine.slice(0, 80)}…` : singleLine; - 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)})`, - }); - }, - catch: (error) => - new DiscordApiError({ - operation: "logUserMessage", - discordError: error, - }), + const stats = yield* getMessageStats(message).pipe( + Effect.catchAll(() => Effect.succeed(undefined)), + ); + + 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 e5300708..206cd112 100644 --- a/app/commands/setup.ts +++ b/app/commands/setup.ts @@ -1,112 +1,122 @@ -import { - PermissionFlagsBits, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; +import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; +import { Effect } from "effect"; +import { interactionReply } from "#~/effects/discordSdk.ts"; +import { logEffect } from "#~/effects/observability.ts"; +import type { SlashCommand } 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 = { + 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* interactionReply(interaction, "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* interactionReply( + interaction, + `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 SlashCommand; diff --git a/app/commands/setupHoneypot.ts b/app/commands/setupHoneypot.ts index 80f4565f..9ad03240 100644 --- a/app/commands/setupHoneypot.ts +++ b/app/commands/setupHoneypot.ts @@ -3,17 +3,19 @@ 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 { interactionReply, sendMessage } from "#~/effects/discordSdk.ts"; +import { logEffect } from "#~/effects/observability.ts"; +import type { SlashCommand } 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 = [ { command: new SlashCommandBuilder() @@ -36,37 +38,47 @@ 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* interactionReply(interaction, { + content: `You must provide a channel!`, + }); + return; + } + + if (honeypotChannel.type !== ChannelType.GuildText) { + yield* interactionReply(interaction, { + 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* sendMessage(castedChannel, messageText); featureStats.honeypotSetup( interaction.guildId, interaction.user.id, @@ -74,19 +86,34 @@ export const Command = [ ); } - await interaction.reply({ + yield* interactionReply(interaction, { 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[]; + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect( + "error", + "HoneypotSetup", + "Error during honeypot action", + { + error: String(error), + }, + ); + + yield* interactionReply(interaction, { + 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 SlashCommand, +]; diff --git a/app/commands/setupReactjiChannel.ts b/app/commands/setupReactjiChannel.ts index 96f7bca2..28c70f40 100644 --- a/app/commands/setupReactjiChannel.ts +++ b/app/commands/setupReactjiChannel.ts @@ -3,11 +3,13 @@ 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 { interactionReply } from "#~/effects/discordSdk.ts"; +import { logEffect } from "#~/effects/observability.ts"; +import type { SlashCommand } from "#~/helpers/discord"; import { featureStats } from "#~/helpers/metrics"; export const Command = { @@ -37,56 +39,58 @@ 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* interactionReply(interaction, { + 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* interactionReply(interaction, { + 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 +101,32 @@ export const Command = { const thresholdText = threshold === 1 ? "" : ` (after ${threshold} reactions)`; - await interaction.reply({ + yield* interactionReply(interaction, { 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; + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect( + "error", + "Commands", + "Error configuring reactji channeler", + { error: String(error) }, + ); + + yield* interactionReply(interaction, { + 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 SlashCommand; diff --git a/app/commands/setupTickets.ts b/app/commands/setupTickets.ts index 2d5c27dc..bc5b0567 100644 --- a/app/commands/setupTickets.ts +++ b/app/commands/setupTickets.ts @@ -12,20 +12,26 @@ import { PermissionFlagsBits, SlashCommandBuilder, TextInputBuilder, - type ChatInputCommandInteraction, } 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, - type AnyCommand, type MessageComponentCommand, type ModalCommand, type SlashCommand, } 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"; @@ -62,23 +68,26 @@ export const Command = [ PermissionFlagsBits.Administrator, ) as SlashCommandBuilder, - handler: async (interaction: ChatInputCommandInteraction) => { - if (!interaction.guild) throw new Error("Interaction has no guild"); + 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; + 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; - } + if (ticketChannel && ticketChannel.type !== ChannelType.GuildText) { + yield* interactionReply(interaction, { + content: `The channel configured must be a text channel! Tickets will be created as private threads.`, + }); + return; + } - try { - const interactionResponse = await interaction.reply({ + const interactionResponse = yield* interactionReply(interaction, { components: [ { type: ComponentType.ActionRow, @@ -93,200 +102,299 @@ export const Command = [ }, ], }); - const producedMessage = await interactionResponse.fetch(); + + const producedMessage = yield* Effect.tryPromise(() => + interactionResponse.fetch(), + ); let roleId = pingableRole?.id; if (!roleId) { - const { [SETTINGS.moderator]: mod } = await fetchSettings( + const { [SETTINGS.moderator]: mod } = yield* fetchSettingsEffect( 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.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect( + "error", + "TicketsSetup", + "Error setting up tickets", + { error }, + ); + }), + ), + Effect.withSpan("ticketsChannelCommand", { + attributes: { + guildId: interaction.guildId, + userId: interaction.user.id, + }, + }), + ), + } satisfies SlashCommand, + { 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 MessageComponentCommand, + { 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* interactionReply(interaction, { + 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* fetchSettingsEffect( + 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* fetchChannel(interaction.guild, config.channel_id) + : channel; + + if ( + !ticketsChannel?.isTextBased() || + ticketsChannel.type !== ChannelType.GuildText + ) { + yield* interactionReply( + interaction, + "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, - { - 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), + + yield* sendMessage(thread, { + content: `<@${user.id}>, this is a private space only visible to you and the <@&${config.role_id}> role.`, + }); + + yield* sendMessage( + thread, + `${user.displayName} said:\n${quoteMessageContent(concern)}`, ); - await interaction.reply({ - content: "Something went wrong", + + 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* interactionReply(interaction, { + content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`, 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: {}, + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect( + "error", + "TicketsModal", + "Error creating ticket from modal", + { error }, + ); + + yield* interactionReply(interaction, { + 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, }, }), - interaction.reply({ - content: `The ticket was closed by <@${interactionUserId}>`, - allowedMentions: {}, + ), + } satisfies ModalCommand, + + { + command: { type: InteractionType.MessageComponent, name: "close-ticket" }, + 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* interactionReply(interaction, { + content: "Something went wrong", + flags: [MessageFlags.Ephemeral], + }); + return; + } + + const { [SETTINGS.modLog]: modLog } = yield* fetchSettingsEffect( + interaction.guild.id, + [SETTINGS.modLog], + ); + + 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: {}, + }, + }), + ), + interactionReply(interaction, { + content: `The ticket was closed by <@${interactionUserId}>`, + allowedMentions: {}, + }), + ]); + + featureStats.ticketClosed( + interaction.guild.id, + interactionUserId, + ticketOpenerUserId, + !!feedback?.trim(), + ); + }).pipe( + Effect.provide(DatabaseLayer), + Effect.catchAll((error) => + Effect.gen(function* () { + yield* logEffect("error", "TicketsClose", "Error closing ticket", { + error, + }); + + yield* interactionReply(interaction, { + 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, + }, }), - ]); - - featureStats.ticketClosed( - interaction.guild.id, - interactionUserId, - ticketOpenerUserId, - !!feedback?.trim(), - ); - - return; - }, - } as MessageComponentCommand, -] as AnyCommand[]; + ), + } satisfies MessageComponentCommand, +]; diff --git a/app/commands/track.ts b/app/commands/track.ts index 71febabd..d63f3305 100644 --- a/app/commands/track.ts +++ b/app/commands/track.ts @@ -10,13 +10,22 @@ 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 { + deleteMessage, + fetchChannelFromClient, + fetchMessage, + interactionDeferReply, + interactionEditReply, + interactionUpdate, + messageReply, +} 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 { @@ -27,7 +36,6 @@ import { export const Command = [ { - type: "effect", command: new ContextMenuCommandBuilder() .setName("Track") .setType(ApplicationCommandType.Message) @@ -35,9 +43,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; @@ -49,44 +57,40 @@ 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({ - 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, + } satisfies MessageContextCommand, { - type: "effect", command: { type: InteractionType.MessageComponent, name: "delete-tracked" }, handler: (interaction) => Effect.gen(function* () { @@ -97,53 +101,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* () { @@ -151,18 +147,14 @@ 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)); }), ), ), - } satisfies EffectMessageComponentCommand, + } satisfies MessageComponentCommand, ]; 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/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/escalationResolver.ts b/app/discord/escalationResolver.ts index d94dca2a..81006e7b 100644 --- a/app/discord/escalationResolver.ts +++ b/app/discord/escalationResolver.ts @@ -1,7 +1,10 @@ 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 { runEffectExit } from "#~/effects/runtime.ts"; import { log } from "#~/helpers/observability"; import { scheduleTask } from "#~/helpers/schedule"; @@ -11,7 +14,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(EscalationServiceLive)), + ), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index a1498f4e..5a1ec851 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -11,14 +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, -} 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"; @@ -122,109 +115,37 @@ 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, + // 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) { 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; } } - }); - // client.on(Events.messageCreate, async (msg) => { - // if (msg.author?.id === client.user?.id) return; + if (!config) { + log("debug", "deployCommands", "no matching command found"); + return; + } + log("debug", "deployCommands", "found matching command", { config }); - // // - // }); + void runEffect(config.handler(interaction as never)); + }); const errorHandler = (error: unknown) => { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/app/effects/discordSdk.ts b/app/effects/discordSdk.ts new file mode 100644 index 00000000..24037512 --- /dev/null +++ b/app/effects/discordSdk.ts @@ -0,0 +1,247 @@ +/** + * 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 { + ChatInputCommandInteraction, + Client, + Guild, + GuildMember, + GuildTextBasedChannel, + Message, + MessageComponentInteraction, + MessageContextMenuCommandInteraction, + ModalSubmitInteraction, + PartialMessage, + ThreadChannel, + User, + UserContextMenuCommandInteraction, +} 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 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], +) => + 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 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, + 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); + +export const interactionReply = ( + interaction: + | MessageComponentInteraction + | ModalSubmitInteraction + | ChatInputCommandInteraction + | UserContextMenuCommandInteraction + | MessageContextMenuCommandInteraction, + options: Parameters[0], +) => + Effect.tryPromise({ + try: () => interaction.reply(options), + catch: (error) => + new DiscordApiError({ operation: "interactionReply", cause: error }), + }); +export const interactionDeferReply = ( + interaction: + | MessageComponentInteraction + | ChatInputCommandInteraction + | UserContextMenuCommandInteraction + | MessageContextMenuCommandInteraction, + options?: Parameters[0], +) => + Effect.tryPromise({ + try: () => interaction.deferReply(options), + catch: (error) => + new DiscordApiError({ operation: "interactionDeferReply", cause: error }), + }); +export const interactionEditReply = ( + interaction: + | MessageComponentInteraction + | ChatInputCommandInteraction + | UserContextMenuCommandInteraction + | MessageContextMenuCommandInteraction, + options: Parameters[0], +) => + Effect.tryPromise({ + try: () => interaction.editReply(options), + catch: (error) => + new DiscordApiError({ operation: "interactionEditReply", cause: error }), + }); +export const interactionFollowUp = ( + interaction: + | MessageComponentInteraction + | ChatInputCommandInteraction + | UserContextMenuCommandInteraction + | MessageContextMenuCommandInteraction, + 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 }), + }); 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/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. diff --git a/app/helpers/discord.ts b/app/helpers/discord.ts index ff03109c..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, @@ -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 { NotFoundError, type DiscordApiError } 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"]; @@ -146,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; @@ -257,84 +197,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..b43b4b47 100644 --- a/app/models/guilds.server.ts +++ b/app/models/guilds.server.ts @@ -1,4 +1,8 @@ +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"]; @@ -114,3 +118,27 @@ 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][]; + if (result.length === 0) { + return yield* Effect.fail( + new NotFoundError({ id: guildId, resource: "guild" }), + ); + } + return Object.fromEntries(result) as Pick; + }); 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), - ), - ); 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..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; @@ -91,7 +92,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 }), }); /** @@ -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,29 +190,17 @@ 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", - discordError: 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", - discordError: error, - }), - }); + const modLog = yield* fetchChannel(guild, modLogId); 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 +211,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 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` |