diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100755 index 00000000..6edd2d2c --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Only run when switching branches (not checking out files) +# $3 is 1 for branch checkout, 0 for file checkout +if [ "$3" != "1" ]; then + exit 0 +fi + +# Check if package-lock.json changed between old and new HEAD +if git diff --name-only "$1" "$2" | grep -q "^package-lock.json$"; then + echo "📦 Dependencies changed. Running npm install..." + npm install +fi diff --git a/.husky/post-merge b/.husky/post-merge index 3d3c2bc9..8f32a899 100755 --- a/.husky/post-merge +++ b/.husky/post-merge @@ -1,12 +1,7 @@ #!/usr/bin/env bash -# MIT © Sindre Sorhus - sindresorhus.com -# https://gist.github.com/sindresorhus/7996717 changed_files="$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)" -check_run() { - echo "$changed_files" | grep --quiet "$1" && eval "$2" -} - -check_run package-lock.json 'echo "Deps have changed, run \`npm i\`"' -check_run migrations/.* 'echo "Migrations have changed, run \`npm run start:migrate\`"' -check_run seeds/.* 'echo "Seeds have changed, run \`npm run kysely:seed\`"' +if echo "$changed_files" | grep -q "^package-lock.json$"; then + echo "📦 Dependencies changed. Running npm install..." + npm install +fi diff --git a/.husky/pre-commit b/.husky/pre-commit index f841cf21..71b770f0 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,8 @@ #!/usr/bin/env sh +branch="$(git rev-parse --abbrev-ref HEAD)" +if [ "$branch" = "main" ]; then + echo "❌ Direct commits to main are not allowed. Please use a feature branch." + exit 1 +fi + npx lint-staged diff --git a/app/Database.ts b/app/Database.ts index f953a46c..b73f9504 100644 --- a/app/Database.ts +++ b/app/Database.ts @@ -1,12 +1,15 @@ -import { Context, Layer } from "effect"; +import { Context, Effect, Layer } from "effect"; +import { SqlClient } from "@effect/sql"; import * as Sqlite from "@effect/sql-kysely/Sqlite"; import { SqliteClient } from "@effect/sql-sqlite-node"; import { ResultLengthMismatch, SqlError } from "@effect/sql/SqlError"; import type { DB } from "./db"; +import { DatabaseCorruptionError } from "./effects/errors"; import { databaseUrl } from "./helpers/env.server"; import { log } from "./helpers/observability"; +import { scheduleTask } from "./helpers/schedule"; // Re-export SQL errors for consumers export { SqlError, ResultLengthMismatch }; @@ -35,3 +38,77 @@ const KyselyLive = Layer.effect(DatabaseService, Sqlite.make()).pipe( export const DatabaseLayer = Layer.mergeAll(SqliteLive, KyselyLive); log("info", "Database", `Database configured at ${databaseUrl}`); + +// --- Integrity Check --- + +const TWELVE_HOURS = 12 * 60 * 60 * 1000; +const DISCORD_WEBHOOK_URL = + "https://discord.com/api/webhooks/1465386069653590200/0J6l5c7IGHc6NjIrA20ysnGarhv2VHI3SnmgjopV-NV5WAgIGCc_gvI41GaZGVUV9NAH"; + +const sendWebhookAlert = (message: string) => + Effect.tryPromise({ + try: () => + fetch(DISCORD_WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: message }), + }), + catch: (e) => e, + }).pipe( + Effect.tapError((e) => + Effect.sync(() => + log("error", "IntegrityCheck", "Failed to send webhook alert", { + error: String(e), + }), + ), + ), + Effect.ignore, // Don't fail the whole check if webhook fails + ); + +/** Run SQLite integrity check using the existing database connection */ +export const runIntegrityCheck = Effect.gen(function* () { + log("info", "IntegrityCheck", "Running scheduled integrity check", {}); + + const sql = yield* SqlClient.SqlClient; + const result = yield* sql.unsafe<{ integrity_check: string }>( + "PRAGMA integrity_check", + ); + + if (result[0]?.integrity_check === "ok") { + log("info", "IntegrityCheck", "Database integrity check passed", {}); + return "ok" as const; + } + + const errors = result.map((r) => r.integrity_check).join("\n"); + log("error", "IntegrityCheck", "Database corruption detected!", { errors }); + + yield* sendWebhookAlert( + `🚨 **Database Corruption Detected**\n\`\`\`\n${errors.slice(0, 1800)}\n\`\`\``, + ); + + return yield* new DatabaseCorruptionError({ errors }); +}).pipe( + Effect.catchTag("SqlError", (e) => + Effect.gen(function* () { + log("error", "IntegrityCheck", "Integrity check failed to run", { + error: e.message, + }); + yield* sendWebhookAlert( + `🚨 **Database Integrity Check Failed**\n\`\`\`\n${e.message}\n\`\`\``, + ); + return yield* e; + }), + ), + Effect.withSpan("runIntegrityCheck"), +); + +/** Start the twice-daily integrity check scheduler */ +export function startIntegrityCheck() { + return scheduleTask("IntegrityCheck", TWELVE_HOURS, () => { + Effect.runPromise( + runIntegrityCheck.pipe(Effect.provide(DatabaseLayer)), + ).catch(() => { + // Errors already logged and webhook sent + }); + }); +} diff --git a/app/commands/escalate/directActions.ts b/app/commands/escalate/directActions.ts index 97285e40..c3e4e22c 100644 --- a/app/commands/escalate/directActions.ts +++ b/app/commands/escalate/directActions.ts @@ -4,11 +4,12 @@ import { } from "discord.js"; import { Effect } from "effect"; +import { fetchMember } from "#~/effects/discordSdk"; import { DiscordApiError, NotAuthorizedError } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; import { hasModRole } from "#~/helpers/discord"; import { applyRestriction, ban, kick, timeout } from "#~/models/discord.server"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { deleteAllReportedForUserEffect } from "#~/models/reportedMessages"; export interface DeleteMessagesResult { @@ -29,11 +30,7 @@ export const deleteMessagesEffect = ( const guildId = interaction.guildId!; // Check permissions - const member = yield* Effect.tryPromise({ - try: () => interaction.guild!.members.fetch(interaction.user.id), - catch: (error) => - new DiscordApiError({ operation: "fetchMember", discordError: error }), - }); + const member = yield* fetchMember(interaction.guild!, interaction.user.id); if (!member.permissions.has(PermissionsBitField.Flags.ManageMessages)) { return yield* Effect.fail( @@ -85,14 +82,9 @@ export const kickUserEffect = (interaction: MessageComponentInteraction) => const guildId = interaction.guildId!; // Get settings and check permissions - const { moderator: modRoleId } = yield* Effect.tryPromise({ - try: () => fetchSettings(guildId, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId } = yield* fetchSettingsEffect(guildId, [ + SETTINGS.moderator, + ]); if (!hasModRole(interaction, modRoleId)) { return yield* Effect.fail( @@ -105,20 +97,16 @@ export const kickUserEffect = (interaction: MessageComponentInteraction) => } // Fetch the reported member - const reportedMember = yield* Effect.tryPromise({ - try: () => interaction.guild!.members.fetch(reportedUserId), - catch: (error) => - new DiscordApiError({ - operation: "fetchReportedMember", - discordError: error, - }), - }); + const reportedMember = yield* fetchMember( + interaction.guild!, + reportedUserId, + ); // Execute kick yield* Effect.tryPromise({ try: () => kick(reportedMember, "single moderator decision"), catch: (error) => - new DiscordApiError({ operation: "kick", discordError: error }), + new DiscordApiError({ operation: "kick", cause: error }), }); yield* logEffect("info", "DirectActions", "Kicked user", { @@ -147,14 +135,9 @@ export const banUserEffect = (interaction: MessageComponentInteraction) => const guildId = interaction.guildId!; // Get settings and check permissions - const { moderator: modRoleId } = yield* Effect.tryPromise({ - try: () => fetchSettings(guildId, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId } = yield* fetchSettingsEffect(guildId, [ + SETTINGS.moderator, + ]); if (!hasModRole(interaction, modRoleId)) { return yield* Effect.fail( @@ -167,20 +150,15 @@ export const banUserEffect = (interaction: MessageComponentInteraction) => } // Fetch the reported member - const reportedMember = yield* Effect.tryPromise({ - try: () => interaction.guild!.members.fetch(reportedUserId), - catch: (error) => - new DiscordApiError({ - operation: "fetchReportedMember", - discordError: error, - }), - }); + const reportedMember = yield* fetchMember( + interaction.guild!, + reportedUserId, + ); // Execute ban yield* Effect.tryPromise({ try: () => ban(reportedMember, "single moderator decision"), - catch: (error) => - new DiscordApiError({ operation: "ban", discordError: error }), + catch: (error) => new DiscordApiError({ operation: "ban", cause: error }), }); yield* logEffect("info", "DirectActions", "Banned user", { @@ -209,14 +187,9 @@ export const restrictUserEffect = (interaction: MessageComponentInteraction) => const guildId = interaction.guildId!; // Get settings and check permissions - const { moderator: modRoleId } = yield* Effect.tryPromise({ - try: () => fetchSettings(guildId, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId } = yield* fetchSettingsEffect(guildId, [ + SETTINGS.moderator, + ]); if (!hasModRole(interaction, modRoleId)) { return yield* Effect.fail( @@ -229,14 +202,10 @@ export const restrictUserEffect = (interaction: MessageComponentInteraction) => } // Fetch the reported member - const reportedMember = yield* Effect.tryPromise({ - try: () => interaction.guild!.members.fetch(reportedUserId), - catch: (error) => - new DiscordApiError({ - operation: "fetchReportedMember", - discordError: error, - }), - }); + const reportedMember = yield* fetchMember( + interaction.guild!, + reportedUserId, + ); // Execute restriction yield* Effect.tryPromise({ @@ -244,7 +213,7 @@ export const restrictUserEffect = (interaction: MessageComponentInteraction) => catch: (error) => new DiscordApiError({ operation: "applyRestriction", - discordError: error, + cause: error, }), }); @@ -274,14 +243,9 @@ export const timeoutUserEffect = (interaction: MessageComponentInteraction) => const guildId = interaction.guildId!; // Get settings and check permissions - const { moderator: modRoleId } = yield* Effect.tryPromise({ - try: () => fetchSettings(guildId, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId } = yield* fetchSettingsEffect(guildId, [ + SETTINGS.moderator, + ]); if (!hasModRole(interaction, modRoleId)) { return yield* Effect.fail( @@ -294,20 +258,16 @@ export const timeoutUserEffect = (interaction: MessageComponentInteraction) => } // Fetch the reported member - const reportedMember = yield* Effect.tryPromise({ - try: () => interaction.guild!.members.fetch(reportedUserId), - catch: (error) => - new DiscordApiError({ - operation: "fetchReportedMember", - discordError: error, - }), - }); + const reportedMember = yield* fetchMember( + interaction.guild!, + reportedUserId, + ); // Execute timeout yield* Effect.tryPromise({ try: () => timeout(reportedMember, "single moderator decision"), catch: (error) => - new DiscordApiError({ operation: "timeout", discordError: error }), + new DiscordApiError({ operation: "timeout", cause: error }), }); yield* logEffect("info", "DirectActions", "Timed out user", { diff --git a/app/commands/escalate/escalate.ts b/app/commands/escalate/escalate.ts index 4989022d..22dc8688 100644 --- a/app/commands/escalate/escalate.ts +++ b/app/commands/escalate/escalate.ts @@ -2,6 +2,13 @@ import type { MessageComponentInteraction, ThreadChannel } from "discord.js"; import { Effect } from "effect"; import { client } from "#~/discord/client.server"; +import { + editMessage, + fetchChannel, + fetchGuild, + fetchMessage, + sendMessage, +} from "#~/effects/discordSdk"; import { DiscordApiError } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; import { calculateScheduledFor } from "#~/helpers/escalationVotes"; @@ -9,7 +16,7 @@ import type { Features } from "#~/helpers/featuresFlags"; import { votingStrategies, type Resolution } from "#~/helpers/modResponse"; import { DEFAULT_QUORUM, - fetchSettings, + fetchSettingsEffect, SETTINGS, } from "#~/models/guilds.server"; @@ -38,38 +45,24 @@ export const createEscalationEffect = ( const features: Features[] = []; // Get settings - const { moderator: modRoleId, restricted } = yield* Effect.tryPromise({ - try: () => - fetchSettings(guildId, [SETTINGS.moderator, SETTINGS.restricted]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId, restricted } = yield* fetchSettingsEffect( + guildId, + [SETTINGS.moderator, SETTINGS.restricted], + ); if (restricted) { features.push("restrict"); } // Fetch guild and channel - const guild = yield* Effect.tryPromise({ - try: () => client.guilds.fetch(guildId), - catch: (error) => - new DiscordApiError({ operation: "fetchGuild", discordError: error }), - }); - - const channel = yield* Effect.tryPromise({ - try: () => guild.channels.fetch(interaction.channelId), - catch: (error) => - new DiscordApiError({ operation: "fetchChannel", discordError: error }), - }); + const guild = yield* fetchGuild(client, guildId); + const channel = yield* fetchChannel(guild, interaction.channelId); if (!channel || !("send" in channel)) { return yield* Effect.fail( new DiscordApiError({ operation: "validateChannel", - discordError: new Error("Invalid channel - cannot send messages"), + cause: new Error("Invalid channel - cannot send messages"), }), ); } @@ -104,28 +97,20 @@ export const createEscalationEffect = ( }; // Send vote message to get its ID - const voteMessage = yield* Effect.tryPromise({ - try: () => - (channel as ThreadChannel).send({ - content: buildVoteMessageContent( - modRoleId, - votingStrategy, - tempEscalation, - emptyTally, - ), - components: buildVoteButtons( - features, - votingStrategy, - tempEscalation, - emptyTally, - false, - ), - }), - catch: (error) => - new DiscordApiError({ - operation: "sendVoteMessage", - discordError: error, - }), + const voteMessage = yield* sendMessage(channel as ThreadChannel, { + content: buildVoteMessageContent( + modRoleId, + votingStrategy, + tempEscalation, + emptyTally, + ), + components: buildVoteButtons( + features, + votingStrategy, + tempEscalation, + emptyTally, + false, + ), }); // Create escalation record with the correct message ID @@ -178,32 +163,21 @@ export const upgradeToMajorityEffect = ( const features: Features[] = []; // Get settings - const { moderator: modRoleId, restricted } = yield* Effect.tryPromise({ - try: () => - fetchSettings(guildId, [SETTINGS.moderator, SETTINGS.restricted]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId, restricted } = yield* fetchSettingsEffect( + guildId, + [SETTINGS.moderator, SETTINGS.restricted], + ); if (restricted) { features.push("restrict"); } // Fetch guild and channel - const guild = yield* Effect.tryPromise({ - try: () => client.guilds.fetch(guildId), - catch: (error) => - new DiscordApiError({ operation: "fetchGuild", discordError: error }), - }); - - const channel = yield* Effect.tryPromise({ - try: () => guild.channels.fetch(interaction.channelId), - catch: (error) => - new DiscordApiError({ operation: "fetchChannel", discordError: error }), - }) as Effect.Effect; + const guild = yield* fetchGuild(client, guildId); + const channel = (yield* fetchChannel( + guild, + interaction.channelId, + )) as ThreadChannel; const votingStrategy = votingStrategies.majority; @@ -211,14 +185,10 @@ export const upgradeToMajorityEffect = ( const escalation = yield* escalationService.getEscalation(escalationId); // Fetch the vote message - const voteMessage = yield* Effect.tryPromise({ - try: () => channel.messages.fetch(escalation.vote_message_id), - catch: (error) => - new DiscordApiError({ - operation: "fetchVoteMessage", - discordError: error, - }), - }); + const voteMessage = yield* fetchMessage( + channel, + escalation.vote_message_id, + ); // Get current votes to display const votes = yield* escalationService.getVotesForEscalation(escalationId); @@ -240,28 +210,20 @@ export const upgradeToMajorityEffect = ( }; // Update the vote message - yield* Effect.tryPromise({ - try: () => - voteMessage.edit({ - content: buildVoteMessageContent( - modRoleId, - votingStrategy, - updatedEscalation, - tally, - ), - components: buildVoteButtons( - features, - votingStrategy, - escalation, - tally, - false, // Never in early resolution state when upgrading to majority - ), - }), - catch: (error) => - new DiscordApiError({ - operation: "editVoteMessage", - discordError: error, - }), + yield* editMessage(voteMessage, { + content: buildVoteMessageContent( + modRoleId, + votingStrategy, + updatedEscalation, + tally, + ), + components: buildVoteButtons( + features, + votingStrategy, + escalation, + tally, + false, // Never in early resolution state when upgrading to majority + ), }); // Update the escalation's voting strategy and scheduled_for diff --git a/app/commands/escalate/escalationResolver.ts b/app/commands/escalate/escalationResolver.ts index fb0876ed..cfc44bf8 100644 --- a/app/commands/escalate/escalationResolver.ts +++ b/app/commands/escalate/escalationResolver.ts @@ -8,14 +8,22 @@ import { } from "discord.js"; import { Effect } from "effect"; -import { DiscordApiError } from "#~/effects/errors"; +import { + editMessage, + fetchChannelFromClient, + fetchGuild, + fetchMemberOrNull, + fetchMessage, + fetchUserOrNull, + replyAndForwardSafe, +} from "#~/effects/discordSdk"; import { logEffect } from "#~/effects/observability"; import { humanReadableResolutions, resolutions, type Resolution, } from "#~/helpers/modResponse"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { EscalationService, type Escalation } from "./service"; import { tallyVotes } from "./voting"; @@ -111,46 +119,27 @@ export const processEscalationEffect = ( ); // Fetch Discord resources - const { modLog } = yield* Effect.tryPromise({ - try: () => fetchSettings(escalation.guild_id, [SETTINGS.modLog]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); - - const guild = yield* Effect.tryPromise({ - try: () => client.guilds.fetch(escalation.guild_id), - catch: (error) => - new DiscordApiError({ operation: "fetchGuild", discordError: error }), - }); - - const channel = yield* Effect.tryPromise({ - try: () => - client.channels.fetch(escalation.thread_id) as Promise, - catch: (error) => - new DiscordApiError({ operation: "fetchChannel", discordError: error }), - }); - - const reportedUser = yield* Effect.tryPromise({ - try: () => client.users.fetch(escalation.reported_user_id), - catch: () => null, - }).pipe(Effect.catchAll(() => Effect.succeed(null))); - - const voteMessage = yield* Effect.tryPromise({ - try: () => channel.messages.fetch(escalation.vote_message_id), - catch: (error) => - new DiscordApiError({ - operation: "fetchVoteMessage", - discordError: error, - }), - }); - - const reportedMember = yield* Effect.tryPromise({ - try: () => guild.members.fetch(escalation.reported_user_id), - catch: () => null, - }).pipe(Effect.catchAll(() => Effect.succeed(null))); + const { modLog } = yield* fetchSettingsEffect(escalation.guild_id, [ + SETTINGS.modLog, + ]); + + const guild = yield* fetchGuild(client, escalation.guild_id); + const channel = yield* fetchChannelFromClient( + client, + escalation.thread_id, + ); + const reportedUser = yield* fetchUserOrNull( + client, + escalation.reported_user_id, + ); + const voteMessage = yield* fetchMessage( + channel, + escalation.vote_message_id, + ); + const reportedMember = yield* fetchMemberOrNull( + guild, + escalation.reported_user_id, + ); // Calculate timing info const now = Math.floor(Date.now() / 1000); @@ -185,37 +174,16 @@ export const processEscalationEffect = ( resolutions.track, ); - yield* Effect.tryPromise({ - try: () => - voteMessage.edit({ components: getDisabledButtons(voteMessage) }), - catch: (error) => - new DiscordApiError({ - operation: "editVoteMessage", - discordError: error, - }), + yield* editMessage(voteMessage, { + components: getDisabledButtons(voteMessage), }); // Try to reply and forward - but don't fail if it doesn't work - yield* Effect.tryPromise({ - try: async () => { - const notice = await voteMessage.reply({ - content: `${noticeText}\n${timing} (${reason})`, - }); - await notice.forward(modLog); - }, - catch: () => null, - }).pipe( - Effect.catchAll((error) => - logEffect( - "warn", - "EscalationResolver", - "Could not update vote message", - { - ...logBag, - error, - }, - ), - ), + yield* replyAndForwardSafe( + voteMessage, + `${noticeText}\n${timing} (${reason})`, + modLog, + "EscalationResolver", ); return { resolution: resolutions.track, userGone: true }; @@ -228,37 +196,16 @@ export const processEscalationEffect = ( yield* escalationService.resolveEscalation(escalation.id, resolution); // Update Discord message - yield* Effect.tryPromise({ - try: () => - voteMessage.edit({ components: getDisabledButtons(voteMessage) }), - catch: (error) => - new DiscordApiError({ - operation: "editVoteMessage", - discordError: error, - }), + yield* editMessage(voteMessage, { + components: getDisabledButtons(voteMessage), }); // Try to reply and forward - but don't fail if it doesn't work - yield* Effect.tryPromise({ - try: async () => { - const notice = await voteMessage.reply({ - content: `${noticeText}\n${timing}`, - }); - await notice.forward(modLog); - }, - catch: () => null, - }).pipe( - Effect.catchAll((error) => - logEffect( - "warn", - "EscalationResolver", - "Could not update vote message", - { - ...logBag, - error, - }, - ), - ), + yield* replyAndForwardSafe( + voteMessage, + `${noticeText}\n${timing}`, + modLog, + "EscalationResolver", ); yield* logEffect( diff --git a/app/commands/escalate/expedite.ts b/app/commands/escalate/expedite.ts index 602e28eb..b279984b 100644 --- a/app/commands/escalate/expedite.ts +++ b/app/commands/escalate/expedite.ts @@ -10,7 +10,7 @@ import { import { logEffect } from "#~/effects/observability"; import { hasModRole } from "#~/helpers/discord"; import type { Resolution } from "#~/helpers/modResponse"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { EscalationService, type Escalation } from "./service"; import { tallyVotes, type VoteTally } from "./voting"; @@ -34,14 +34,9 @@ export const expediteEffect = (interaction: MessageComponentInteraction) => const expeditedBy = interaction.user.id; // Get settings and check mod role - const { moderator: modRoleId } = yield* Effect.tryPromise({ - try: () => fetchSettings(guildId, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId } = yield* fetchSettingsEffect(guildId, [ + SETTINGS.moderator, + ]); if (!hasModRole(interaction, modRoleId)) { return yield* Effect.fail( @@ -86,7 +81,7 @@ export const expediteEffect = (interaction: MessageComponentInteraction) => const guild = yield* Effect.tryPromise({ try: () => interaction.guild!.fetch(), catch: (error) => - new DiscordApiError({ operation: "fetchGuild", discordError: error }), + new DiscordApiError({ operation: "fetchGuild", cause: error }), }); // Execute the resolution diff --git a/app/commands/escalate/handlers.ts b/app/commands/escalate/handlers.ts index a56bf6e1..a3b38358 100644 --- a/app/commands/escalate/handlers.ts +++ b/app/commands/escalate/handlers.ts @@ -5,7 +5,7 @@ import { MessageFlags, type MessageComponentInteraction, } from "discord.js"; -import { Effect } from "effect"; +import { Effect, Layer } from "effect"; import { DatabaseLayer } from "#~/Database.ts"; import { runEffectExit } from "#~/effects/runtime.ts"; @@ -15,7 +15,7 @@ import { } from "#~/helpers/modResponse"; import { log } from "#~/helpers/observability"; -import { getFailure, runEscalationEffect } from "."; +import { getFailure } from "."; import { banUserEffect, deleteMessagesEffect, @@ -25,6 +25,7 @@ import { } from "./directActions"; import { createEscalationEffect, upgradeToMajorityEffect } from "./escalate"; import { expediteEffect } from "./expedite"; +import { EscalationServiceLive } from "./service"; import { buildConfirmedMessageContent, buildVoteButtons, @@ -61,7 +62,9 @@ const deleteMessages = async (interaction: MessageComponentInteraction) => { const kickUser = async (interaction: MessageComponentInteraction) => { const reportedUserId = interaction.customId.split("|")[1]; - const exit = await runEffectExit(kickUserEffect(interaction)); + const exit = await runEffectExit( + kickUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Error kicking user", { error }); @@ -88,7 +91,9 @@ const kickUser = async (interaction: MessageComponentInteraction) => { const banUser = async (interaction: MessageComponentInteraction) => { const reportedUserId = interaction.customId.split("|")[1]; - const exit = await runEffectExit(banUserEffect(interaction)); + const exit = await runEffectExit( + banUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Error banning user", { error }); @@ -115,7 +120,9 @@ const banUser = async (interaction: MessageComponentInteraction) => { const restrictUser = async (interaction: MessageComponentInteraction) => { const reportedUserId = interaction.customId.split("|")[1]; - const exit = await runEffectExit(restrictUserEffect(interaction)); + const exit = await runEffectExit( + restrictUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Error restricting user", { error }); @@ -144,7 +151,9 @@ const restrictUser = async (interaction: MessageComponentInteraction) => { const timeoutUser = async (interaction: MessageComponentInteraction) => { const reportedUserId = interaction.customId.split("|")[1]; - const exit = await runEffectExit(timeoutUserEffect(interaction)); + const exit = await runEffectExit( + timeoutUserEffect(interaction).pipe(Effect.provide(DatabaseLayer)), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Error timing out user", { error }); @@ -174,7 +183,11 @@ const vote = (resolution: Resolution) => async function handleVote( interaction: MessageComponentInteraction, ): Promise { - const exit = await runEscalationEffect(voteEffect(resolution)(interaction)); + const exit = await runEffectExit( + voteEffect(resolution)(interaction).pipe( + Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + ), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Error voting", { error, resolution }); @@ -186,7 +199,7 @@ const vote = (resolution: Resolution) => }); return; } - if (error?._tag === "EscalationNotFoundError") { + if (error?._tag === "NotFoundError") { await interaction.reply({ content: "Escalation not found.", flags: [MessageFlags.Ephemeral], @@ -257,7 +270,11 @@ const expedite = async ( ): Promise => { await interaction.deferUpdate(); - const exit = await runEscalationEffect(expediteEffect(interaction)); + const exit = await runEffectExit( + expediteEffect(interaction).pipe( + Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + ), + ); if (exit._tag === "Failure") { const error = getFailure(exit.cause); log("error", "EscalationHandlers", "Expedite failed", { error }); @@ -269,7 +286,7 @@ const expedite = async ( }); return; } - if (error?._tag === "EscalationNotFoundError") { + if (error?._tag === "NotFoundError") { await interaction.followUp({ content: "Escalation not found.", flags: [MessageFlags.Ephemeral], @@ -325,8 +342,10 @@ const escalate = async (interaction: MessageComponentInteraction) => { if (Number(level) === 0) { // Create new escalation - const exit = await runEscalationEffect( - createEscalationEffect(interaction, reportedUserId, escalationId), + const exit = await runEffectExit( + createEscalationEffect(interaction, reportedUserId, escalationId).pipe( + Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + ), ); if (exit._tag === "Failure") { @@ -343,8 +362,10 @@ const escalate = async (interaction: MessageComponentInteraction) => { await interaction.editReply("Escalation started"); } else { // Upgrade to majority voting - const exit = await runEscalationEffect( - upgradeToMajorityEffect(interaction, escalationId), + const exit = await runEffectExit( + upgradeToMajorityEffect(interaction, escalationId).pipe( + Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)), + ), ); if (exit._tag === "Failure") { @@ -353,7 +374,7 @@ const escalate = async (interaction: MessageComponentInteraction) => { error, }); - if (error?._tag === "EscalationNotFoundError") { + if (error?._tag === "NotFoundError") { await interaction.editReply({ content: "Failed to re-escalate, couldn't find escalation", }); diff --git a/app/commands/escalate/index.ts b/app/commands/escalate/index.ts index 2873056b..332b8d08 100644 --- a/app/commands/escalate/index.ts +++ b/app/commands/escalate/index.ts @@ -1,17 +1,4 @@ -import { Cause, Effect } from "effect"; - -import { runEffectExit } from "#~/effects/runtime"; - -import { EscalationServiceLive, type EscalationService } from "./service"; - -/** - * Run an Effect that requires EscalationService. - * Provides the service and all its dependencies automatically. - * Returns an Exit for error handling in handlers. - */ -export const runEscalationEffect = ( - effect: Effect.Effect, -) => runEffectExit(Effect.provide(effect, EscalationServiceLive)); +import { Cause } from "effect"; /** * Extract the first failure from a Cause for type-safe error matching. diff --git a/app/commands/escalate/service.ts b/app/commands/escalate/service.ts index 771bd1e5..c2448ad1 100644 --- a/app/commands/escalate/service.ts +++ b/app/commands/escalate/service.ts @@ -6,7 +6,7 @@ import { DatabaseLayer, DatabaseService, type SqlError } from "#~/Database"; import type { DB } from "#~/db"; import { AlreadyResolvedError, - EscalationNotFoundError, + NotFoundError, ResolutionExecutionError, } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; @@ -47,7 +47,7 @@ export interface IEscalationService { */ readonly getEscalation: ( id: string, - ) => Effect.Effect; + ) => Effect.Effect; /** * Record a vote for an escalation. @@ -70,10 +70,7 @@ export interface IEscalationService { readonly resolveEscalation: ( id: string, resolution: Resolution, - ) => Effect.Effect< - void, - EscalationNotFoundError | AlreadyResolvedError | SqlError - >; + ) => Effect.Effect; /** * Update the voting strategy for an escalation. @@ -168,7 +165,7 @@ export const EscalationServiceLive = Layer.effect( if (!escalation) { return yield* Effect.fail( - new EscalationNotFoundError({ escalationId: id }), + new NotFoundError({ id, resource: "escalation" }), ); } @@ -258,7 +255,7 @@ export const EscalationServiceLive = Layer.effect( if (!escalation) { return yield* Effect.fail( - new EscalationNotFoundError({ escalationId: id }), + new NotFoundError({ id, resource: "escalation" }), ); } diff --git a/app/commands/escalate/vote.ts b/app/commands/escalate/vote.ts index 57830672..897da526 100644 --- a/app/commands/escalate/vote.ts +++ b/app/commands/escalate/vote.ts @@ -1,17 +1,13 @@ import type { MessageComponentInteraction } from "discord.js"; import { Effect } from "effect"; -import { - AlreadyResolvedError, - DiscordApiError, - NotAuthorizedError, -} from "#~/effects/errors"; +import { AlreadyResolvedError, NotAuthorizedError } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; import { hasModRole } from "#~/helpers/discord"; import { calculateScheduledFor, parseFlags } from "#~/helpers/escalationVotes"; import type { Features } from "#~/helpers/featuresFlags"; import type { Resolution, VotingStrategy } from "#~/helpers/modResponse"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { EscalationService, type Escalation } from "./service"; import { @@ -43,15 +39,10 @@ export const voteEffect = const features: Features[] = []; // Get settings - const { moderator: modRoleId, restricted } = yield* Effect.tryPromise({ - try: () => - fetchSettings(guildId, [SETTINGS.moderator, SETTINGS.restricted]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator: modRoleId, restricted } = yield* fetchSettingsEffect( + guildId, + [SETTINGS.moderator, SETTINGS.restricted], + ); if (restricted) { features.push("restrict"); diff --git a/app/commands/force-ban.ts b/app/commands/force-ban.ts index 36c44f49..8491963b 100644 --- a/app/commands/force-ban.ts +++ b/app/commands/force-ban.ts @@ -1,6 +1,7 @@ import { ApplicationCommandType } from "discord-api-types/v10"; import { ContextMenuCommandBuilder, + MessageFlags, PermissionFlagsBits, type UserContextMenuCommandInteraction, } from "discord.js"; @@ -38,7 +39,7 @@ const handler = async (interaction: UserContextMenuCommandInteraction) => { commandStats.commandFailed(interaction, "force-ban", "No guild found"); await interaction.reply({ - ephemeral: true, + flags: [MessageFlags.Ephemeral], content: "Failed to ban user, couldn't find guild", }); return; @@ -60,7 +61,7 @@ const handler = async (interaction: UserContextMenuCommandInteraction) => { commandStats.commandExecuted(interaction, "force-ban", true); await interaction.reply({ - ephemeral: true, + flags: [MessageFlags.Ephemeral], content: "This member has been banned", }); } catch (error) { @@ -78,7 +79,7 @@ const handler = async (interaction: UserContextMenuCommandInteraction) => { commandStats.commandFailed(interaction, "force-ban", err.message); await interaction.reply({ - ephemeral: true, + 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.", }); diff --git a/app/commands/report.ts b/app/commands/report.ts index 1500d23b..d0064dd2 100644 --- a/app/commands/report.ts +++ b/app/commands/report.ts @@ -1,6 +1,7 @@ import { ApplicationCommandType } from "discord-api-types/v10"; import { ContextMenuCommandBuilder, + MessageFlags, PermissionFlagsBits, type MessageContextMenuCommandInteraction, } from "discord.js"; @@ -51,7 +52,7 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => { commandStats.commandExecuted(interaction, "report", true); await interaction.reply({ - ephemeral: true, + flags: [MessageFlags.Ephemeral], content: "This message has been reported anonymously", }); } catch (error) { @@ -70,7 +71,7 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => { commandStats.commandFailed(interaction, "report", err.message); await interaction.reply({ - ephemeral: true, + flags: [MessageFlags.Ephemeral], content: "Failed to submit report. Please try again later.", }); } diff --git a/app/commands/report/automodLog.ts b/app/commands/report/automodLog.ts index 25084811..25e9efd4 100644 --- a/app/commands/report/automodLog.ts +++ b/app/commands/report/automodLog.ts @@ -2,11 +2,11 @@ import { AutoModerationActionType, type Guild, type User } from "discord.js"; import { Effect } from "effect"; import { DatabaseLayer } from "#~/Database"; -import { DiscordApiError } from "#~/effects/errors"; +import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk"; import { logEffect } from "#~/effects/observability"; import { runEffect } from "#~/effects/runtime"; import { truncateMessage } from "#~/helpers/string"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { getOrCreateUserThread } from "#~/models/userThreads.ts"; export interface AutomodReport { @@ -52,14 +52,10 @@ export const logAutomod = ({ const thread = yield* getOrCreateUserThread(guild, user); // Get mod log for forwarding - const { modLog, moderator } = yield* Effect.tryPromise({ - try: () => fetchSettings(guild.id, [SETTINGS.modLog, SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { modLog, moderator } = yield* fetchSettingsEffect(guild.id, [ + SETTINGS.modLog, + SETTINGS.moderator, + ]); // Construct the log message const channelMention = channelId ? `<#${channelId}>` : "Unknown channel"; @@ -71,30 +67,13 @@ export const logAutomod = ({ ).trim(); // Send log to thread - const logMessage = yield* Effect.tryPromise({ - try: () => - thread.send({ - content: logContent, - allowedMentions: { roles: [moderator] }, - }), - catch: (error) => - new DiscordApiError({ - operation: "sendLogMessage", - discordError: error, - }), + const logMessage = yield* sendMessage(thread, { + content: logContent, + allowedMentions: { roles: [moderator] }, }); // Forward to mod log (non-critical) - yield* Effect.tryPromise({ - try: () => logMessage.forward(modLog), - catch: (error) => error, - }).pipe( - Effect.catchAll((error) => - logEffect("error", "logAutomod", "failed to forward to modLog", { - error: String(error), - }), - ), - ); + yield* forwardMessageSafe(logMessage, modLog, "logAutomod"); }).pipe( Effect.withSpan("logAutomod", { attributes: { userId: user.id, guildId: guild.id, ruleName }, diff --git a/app/commands/report/constructLog.ts b/app/commands/report/constructLog.ts index 8d39d7fa..72b9c00a 100644 --- a/app/commands/report/constructLog.ts +++ b/app/commands/report/constructLog.ts @@ -9,7 +9,7 @@ import { Effect } from "effect"; import { DiscordApiError } from "#~/effects/errors"; import { constructDiscordLink } from "#~/helpers/discord"; import { truncateMessage } from "#~/helpers/string"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { ReportReasons, type Report } from "#~/models/reportedMessages"; const ReadableReasons: Record = { @@ -38,7 +38,7 @@ export const constructLog = ({ return yield* Effect.fail( new DiscordApiError({ operation: "constructLog", - discordError: new Error( + cause: new Error( "Something went wrong when trying to retrieve last report", ), }), @@ -46,22 +46,17 @@ export const constructLog = ({ } const { message } = lastReport; const { author } = message; - const { moderator } = yield* Effect.tryPromise({ - try: () => - fetchSettings(lastReport.message.guild!.id, [SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { moderator } = yield* fetchSettingsEffect( + lastReport.message.guild.id, + [SETTINGS.moderator], + ); // This should never be possible but we gotta satisfy types if (!moderator) { return yield* Effect.fail( new DiscordApiError({ operation: "constructLog", - discordError: new Error("No role configured to be used as moderator"), + cause: new Error("No role configured to be used as moderator"), }), ); } diff --git a/app/commands/report/modActionLog.ts b/app/commands/report/modActionLog.ts index c9aaf4d0..50d4b6b0 100644 --- a/app/commands/report/modActionLog.ts +++ b/app/commands/report/modActionLog.ts @@ -2,11 +2,11 @@ import { type Guild, type PartialUser, type User } from "discord.js"; import { Effect } from "effect"; import { DatabaseLayer } from "#~/Database"; -import { DiscordApiError } from "#~/effects/errors"; +import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk"; import { logEffect } from "#~/effects/observability"; import { runEffect } from "#~/effects/runtime"; import { truncateMessage } from "#~/helpers/string"; -import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server"; import { getOrCreateUserThread } from "#~/models/userThreads.ts"; export type ModActionReport = @@ -64,14 +64,10 @@ export const logModAction = (report: ModActionReport) => const thread = yield* getOrCreateUserThread(guild, user); // Get mod log for forwarding - const { modLog, moderator } = yield* Effect.tryPromise({ - try: () => fetchSettings(guild.id, [SETTINGS.modLog, SETTINGS.moderator]), - catch: (error) => - new DiscordApiError({ - operation: "fetchSettings", - discordError: error, - }), - }); + const { modLog, moderator } = yield* fetchSettingsEffect(guild.id, [ + SETTINGS.modLog, + SETTINGS.moderator, + ]); // Construct the log message const actionLabels: Record = { @@ -97,30 +93,13 @@ export const logModAction = (report: ModActionReport) => ).trim(); // Send log to thread - const logMessage = yield* Effect.tryPromise({ - try: () => - thread.send({ - content: logContent, - allowedMentions: { roles: [moderator] }, - }), - catch: (error) => - new DiscordApiError({ - operation: "sendLogMessage", - discordError: error, - }), + const logMessage = yield* sendMessage(thread, { + content: logContent, + allowedMentions: { roles: [moderator] }, }); // Forward to mod log (non-critical) - yield* Effect.tryPromise({ - try: () => logMessage.forward(modLog), - catch: (error) => error, - }).pipe( - Effect.catchAll((error) => - logEffect("error", "logModAction", "failed to forward to modLog", { - error: String(error), - }), - ), - ); + yield* forwardMessageSafe(logMessage, modLog, "logModAction"); }).pipe( Effect.withSpan("logModAction", { attributes: { diff --git a/app/commands/report/modActionLogger.ts b/app/commands/report/modActionLogger.ts index a078644e..dad30b68 100644 --- a/app/commands/report/modActionLogger.ts +++ b/app/commands/report/modActionLogger.ts @@ -24,6 +24,26 @@ import { logModAction } from "./modActionLog"; // Time window to check audit log for matching entries (5 seconds) const AUDIT_LOG_WINDOW_MS = 5000; +// Deduplication for automod events - Discord fires multiple events per trigger +const recentAutomodTriggers = new Set(); + +const getAutomodDedupeKey = (userId: string, guildId: string): string => { + // Group events within the same second + const timeWindow = Math.floor(Date.now() / 1000); + return `${userId}:${guildId}:${timeWindow}`; +}; + +const shouldProcessAutomod = (userId: string, guildId: string): boolean => { + const key = getAutomodDedupeKey(userId, guildId); + if (recentAutomodTriggers.has(key)) { + return false; // Already processed an event for this trigger + } + recentAutomodTriggers.add(key); + // Clean up after 2 seconds to prevent memory leak + setTimeout(() => recentAutomodTriggers.delete(key), 2000); + return true; +}; + interface AuditLogEntryResult { executor: User | PartialUser | null; reason: string | null; @@ -264,6 +284,17 @@ const automodActionEffect = (execution: AutoModerationActionExecution) => return; } + // Deduplicate: only process first event per user/guild/second + // Discord fires multiple events (BlockMessage, SendAlertMessage, etc.) for one trigger + if (!shouldProcessAutomod(userId, guild.id)) { + yield* logEffect("debug", "Automod", "Skipping duplicate automod event", { + userId, + guildId: guild.id, + actionType: action.type, + }); + return; + } + yield* logEffect("info", "Automod", "Automod action executed", { userId, guildId: guild.id, diff --git a/app/commands/report/userLog.ts b/app/commands/report/userLog.ts index 88b54d0a..8f37fd60 100644 --- a/app/commands/report/userLog.ts +++ b/app/commands/report/userLog.ts @@ -80,7 +80,7 @@ export function logUserMessage({ return yield* Effect.fail( new DiscordApiError({ operation: "logUserMessage", - discordError: new Error("Tried to log a message without a guild"), + cause: new Error("Tried to log a message without a guild"), }), ); } @@ -93,7 +93,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "fetchSettings", - discordError: error, + cause: error, }), }), constructLog({ @@ -138,7 +138,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "logUserMessage existing", - discordError: error, + cause: error, }), }); @@ -200,7 +200,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "sendLogMessages", - discordError: error, + cause: error, }), }); @@ -231,7 +231,7 @@ export function logUserMessage({ yield* Effect.tryPromise({ try: () => logMessage.forward(modLog), catch: (error) => - new DiscordApiError({ operation: "forwardLog", discordError: error }), + new DiscordApiError({ operation: "forwardLog", cause: error }), }).pipe( Effect.catchAll((error) => logEffect("error", "logUserMessage", "failed to forward to modLog", { @@ -261,7 +261,7 @@ export function logUserMessage({ catch: (error) => new DiscordApiError({ operation: "logUserMessage", - discordError: error, + cause: error, }), }).pipe( Effect.catchAll((error) => diff --git a/app/commands/setupHoneypot.ts b/app/commands/setupHoneypot.ts index c04734b9..80f4565f 100644 --- a/app/commands/setupHoneypot.ts +++ b/app/commands/setupHoneypot.ts @@ -1,5 +1,6 @@ import { ChannelType, + MessageFlags, PermissionFlagsBits, SlashCommandBuilder, type ChatInputCommandInteraction, @@ -75,7 +76,7 @@ export const Command = [ await interaction.reply({ content: "Honeypot setup completed successfully!", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); } catch (e) { log("error", "HoneypotSetup", "Error during honeypot action", { @@ -83,7 +84,7 @@ export const Command = [ }); await interaction.reply({ content: "Failed to setup honeypot. Please try again.", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); } }, diff --git a/app/commands/setupReactjiChannel.ts b/app/commands/setupReactjiChannel.ts index abcee233..96f7bca2 100644 --- a/app/commands/setupReactjiChannel.ts +++ b/app/commands/setupReactjiChannel.ts @@ -1,5 +1,6 @@ import { randomUUID } from "crypto"; import { + MessageFlags, PermissionFlagsBits, SlashCommandBuilder, type ChatInputCommandInteraction, @@ -40,7 +41,7 @@ export const Command = { if (!interaction.guild) { await interaction.reply({ content: "This command can only be used in a server.", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); return; } @@ -61,7 +62,7 @@ export const Command = { if (!emoji) { await interaction.reply({ content: "Please provide a valid emoji.", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); return; } @@ -104,7 +105,7 @@ export const Command = { await interaction.reply({ content: "Something went wrong while configuring the reactji channeler.", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); } }, diff --git a/app/commands/setupTickets.ts b/app/commands/setupTickets.ts index cb7fc508..2d5c27dc 100644 --- a/app/commands/setupTickets.ts +++ b/app/commands/setupTickets.ts @@ -235,7 +235,7 @@ ${quoteMessageContent(concern)}`); void interaction.reply({ content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`, - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); return; }, @@ -252,7 +252,7 @@ ${quoteMessageContent(concern)}`); ); await interaction.reply({ content: "Something went wrong", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); return; } diff --git a/app/commands/track.ts b/app/commands/track.ts index 6de25e77..a4a77754 100644 --- a/app/commands/track.ts +++ b/app/commands/track.ts @@ -5,6 +5,7 @@ import { ButtonStyle, ContextMenuCommandBuilder, InteractionType, + MessageFlags, PermissionFlagsBits, } from "discord.js"; import { Effect } from "effect"; @@ -54,7 +55,7 @@ export const Command = [ yield* Effect.tryPromise(() => interaction.reply({ content: `Tracked <#${thread.id}>`, - ephemeral: true, + flags: [MessageFlags.Ephemeral], components: reportId ? [ new ActionRowBuilder().addComponents( @@ -76,7 +77,7 @@ export const Command = [ yield* Effect.tryPromise(() => interaction.reply({ content: "Failed to track message", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }), ).pipe(Effect.catchAll(() => Effect.void)); }), diff --git a/app/components/DiscordLayout.tsx b/app/components/DiscordLayout.tsx index f00319f0..066c727d 100644 --- a/app/components/DiscordLayout.tsx +++ b/app/components/DiscordLayout.tsx @@ -128,17 +128,21 @@ export function DiscordLayout({