diff --git a/.gitignore b/.gitignore index 8449a5d..1824790 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ k8s-context tsconfig.tsbuildinfo .react-router tailwind.css +userInfoCache.json +vite.config.ts* diff --git a/CLAUDE.md b/CLAUDE.md index ae7f3d3..1f66970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,7 @@ - You should periodically jot down your thoughts in `/notes`, especially if it will help you remember important implementation details later. - Your notes must be named consistently with a date prefix in the format YYYY-MM-DD followed by a sequence in the format \_X where x is a monotonically increasing integer. +- You must commit periodically, running `npm run validate` first. - You expect to be able to access VS Code. If you can't, prompt me about it. - This project uses sqlite, so you can inspect the database yourself. You can make your own dummy data, but don't do anything destructive, and make sure to describe how to reverse any DB changes. - You can curl this website, it's running locally at http://localhost:3000. You are not able to access areas behind authentication without data from me. diff --git a/app/commands/demo.ts b/app/commands/demo.ts index 803ec23..0176544 100644 --- a/app/commands/demo.ts +++ b/app/commands/demo.ts @@ -8,7 +8,7 @@ export const command = new SlashCommandBuilder() export const handler = async (interaction: CommandInteraction) => { await interaction.reply({ - ephemeral: true, + flags: "Ephemeral", content: "ok", }); }; diff --git a/app/commands/escalationControls.ts b/app/commands/escalationControls.ts new file mode 100644 index 0000000..b67849e --- /dev/null +++ b/app/commands/escalationControls.ts @@ -0,0 +1,262 @@ +import { InteractionType, PermissionsBitField } from "discord.js"; +import type { MessageComponentCommand } from "#~/helpers/discord"; +import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; +import { deleteAllReportedForUser } from "#~/models/reportedMessages.server"; +import { timeout, ban, kick, applyRestriction } from "#~/models/discord.server"; + +export const EscalationCommands = [ + { + command: { + type: InteractionType.MessageComponent, + name: "escalate-delete", + }, + handler: async (interaction) => { + await interaction.deferReply(); + const reportedUserId = interaction.customId.split("|")[1]; + const guildId = interaction.guildId!; + + // Permission check + const member = await interaction.guild!.members.fetch( + interaction.user.id, + ); + if (!member.permissions.has(PermissionsBitField.Flags.ManageMessages)) { + return interaction.editReply({ + content: "Insufficient permissions", + }); + } + + try { + const result = await deleteAllReportedForUser(reportedUserId, guildId); + await interaction.editReply( + `Messages deleted by ${interaction.user.username} (${result.deleted}/${result.total} successful)`, + ); + } catch (error) { + console.error("Error deleting reported messages:", error); + await interaction.editReply({ + content: "Failed to delete messages", + }); + } + }, + }, + + { + command: { type: InteractionType.MessageComponent, name: "escalate-kick" }, + handler: async (interaction) => { + const reportedUserId = interaction.customId.split("|")[1]; + const guildId = interaction.guildId!; + + // Get moderator role for permission check + const { moderator: modRoleId } = await fetchSettings(guildId, [ + SETTINGS.moderator, + ]); + + const member = interaction.member; + if ( + !member || + (Array.isArray(member.roles) + ? !member.roles.includes(modRoleId) + : !member.roles.cache.has(modRoleId)) + ) { + return interaction.reply({ + content: "Insufficient permissions", + ephemeral: true, + }); + } + + try { + const reportedMember = + await interaction.guild!.members.fetch(reportedUserId); + await Promise.allSettled([ + kick(reportedMember), + interaction.reply( + `<@${reportedUserId}> kicked by ${interaction.user.username}`, + ), + ]); + } catch (error) { + console.error("Error kicking user:", error); + await interaction.reply({ + content: "Failed to kick user", + ephemeral: true, + }); + } + }, + }, + + { + command: { type: InteractionType.MessageComponent, name: "escalate-ban" }, + handler: async (interaction) => { + const reportedUserId = interaction.customId.split("|")[1]; + const guildId = interaction.guildId!; + + // Get moderator role for permission check + const { moderator: modRoleId } = await fetchSettings(guildId, [ + SETTINGS.moderator, + ]); + + const member = interaction.member; + if ( + !member || + (Array.isArray(member.roles) + ? !member.roles.includes(modRoleId) + : !member.roles.cache.has(modRoleId)) + ) { + return interaction.reply({ + content: "Insufficient permissions", + ephemeral: true, + }); + } + + try { + const reportedMember = + await interaction.guild!.members.fetch(reportedUserId); + await Promise.allSettled([ + ban(reportedMember), + interaction.reply( + `<@${reportedUserId}> banned by ${interaction.user.username}`, + ), + ]); + } catch (error) { + console.error("Error banning user:", error); + await interaction.reply({ + content: "Failed to ban user", + ephemeral: true, + }); + } + }, + }, + + { + command: { + type: InteractionType.MessageComponent, + name: "escalate-restrict", + }, + handler: async (interaction) => { + const reportedUserId = interaction.customId.split("|")[1]; + const guildId = interaction.guildId!; + + // Get moderator role for permission check + const { moderator: modRoleId } = await fetchSettings(guildId, [ + SETTINGS.moderator, + ]); + + const member = interaction.member; + if ( + !member || + (Array.isArray(member.roles) + ? !member.roles.includes(modRoleId) + : !member.roles.cache.has(modRoleId)) + ) { + return interaction.reply({ + content: "Insufficient permissions", + ephemeral: true, + }); + } + + try { + const reportedMember = + await interaction.guild!.members.fetch(reportedUserId); + await Promise.allSettled([ + applyRestriction(reportedMember), + interaction.reply( + `<@${reportedUserId}> restricted by ${interaction.user.username}`, + ), + ]); + } catch (error) { + console.error("Error restricting user:", error); + await interaction.reply({ + content: "Failed to restrict user", + ephemeral: true, + }); + } + }, + }, + + { + command: { + type: InteractionType.MessageComponent, + name: "escalate-timeout", + }, + handler: async (interaction) => { + const reportedUserId = interaction.customId.split("|")[1]; + const guildId = interaction.guildId!; + + // Get moderator role for permission check + const { moderator: modRoleId } = await fetchSettings(guildId, [ + SETTINGS.moderator, + ]); + + const member = interaction.member; + if ( + !member || + (Array.isArray(member.roles) + ? !member.roles.includes(modRoleId) + : !member.roles.cache.has(modRoleId)) + ) { + return interaction.reply({ + content: "Insufficient permissions", + ephemeral: true, + }); + } + + try { + const reportedMember = + await interaction.guild!.members.fetch(reportedUserId); + await Promise.allSettled([ + timeout(reportedMember), + interaction.reply( + `<@${reportedUserId}> timed out by ${interaction.user.username}`, + ), + ]); + } catch (error) { + console.error("Error timing out user:", error); + await interaction.reply({ + content: "Failed to timeout user", + ephemeral: true, + }); + } + }, + }, + + { + command: { + type: InteractionType.MessageComponent, + name: "escalate-escalate", + }, + handler: async (interaction) => { + const guildId = interaction.guildId!; + + // Get moderator role for mentions + const { moderator: modRoleId } = await fetchSettings(guildId, [ + SETTINGS.moderator, + ]); + + try { + const member = await interaction.guild!.members.fetch( + interaction.user.id, + ); + + await Promise.all([ + interaction.channel && "send" in interaction.channel + ? interaction.channel.send( + `Report escalated by <@${member.id}>, <@&${modRoleId}> please respond.`, + ) + : Promise.resolve(), + interaction.reply({ + content: `Report escalated successfully`, + ephemeral: true, + }), + ]); + + // Note: The full escalate() function with ModResponse voting would need + // more complex refactoring to work without Reacord. For now, this provides + // basic escalation notification functionality. + } catch (error) { + console.error("Error escalating report:", error); + await interaction.reply({ + content: "Failed to escalate report", + ephemeral: true, + }); + } + }, + }, +] as Array; diff --git a/app/commands/report.ts b/app/commands/report.ts index 654a44c..e252170 100644 --- a/app/commands/report.ts +++ b/app/commands/report.ts @@ -2,7 +2,7 @@ import type { MessageContextMenuCommandInteraction } from "discord.js"; import { PermissionFlagsBits, ContextMenuCommandBuilder } from "discord.js"; import { ApplicationCommandType } from "discord-api-types/v10"; import { reportUser } from "#~/helpers/modLog"; -import { ReportReasons } from "./track/reportCache"; +import { ReportReasons } from "#~/models/reportedMessages.server"; import { log, trackPerformance } from "#~/helpers/observability"; import { commandStats } from "#~/helpers/metrics"; diff --git a/app/commands/track.tsx b/app/commands/track.tsx index f838ce1..05813b9 100644 --- a/app/commands/track.tsx +++ b/app/commands/track.tsx @@ -5,7 +5,10 @@ import { Button } from "reacord"; import { reacord } from "#~/discord/client.server"; import { reportUser } from "#~/helpers/modLog"; -import { ReportReasons } from "#~/commands/track/reportCache"; +import { + ReportReasons, + markMessageAsDeleted, +} from "#~/models/reportedMessages.server"; const command = new ContextMenuCommandBuilder() .setName("Track") @@ -35,7 +38,9 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => { const { latestReport, thread } = await reportPromise; await Promise.allSettled([ - message.delete(), + message + .delete() + .then(() => markMessageAsDeleted(message.id, message.guild!.id)), latestReport?.reply({ allowedMentions: { users: [] }, content: `deleted by ${user.username}`, diff --git a/app/commands/track/reportCache.ts b/app/commands/track/reportCache.ts deleted file mode 100644 index c8f1c36..0000000 --- a/app/commands/track/reportCache.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { simplifyString } from "#~/helpers/string.js"; -import TTLCache from "@isaacs/ttlcache"; -import type { Message, User } from "discord.js"; - -export interface Report { - reason: ReportReasons; - message: Message; - extra?: string; - staff: User | false; -} - -export const enum ReportReasons { - anonReport = "anonReport", - track = "track", - modResolution = "modResolution", - spam = "spam", -} - -const HOUR = 60 * 60 * 1000; -type UserID = string; -type GuildID = string; -const cache = new TTLCache< - `${UserID}${GuildID}`, - Map< - string, - { - logMessage: Message; - logs: Report[]; - } - > ->({ - ttl: 20 * HOUR, - max: 1000, -}); - -export const queryCacheMetadata = (message: Message) => { - const cacheKey = `${message.guildId}${message.author.id}`; - - const user = cache.get(cacheKey); - if (!user) { - return { - uniqueMessages: 0, - uniqueChannels: 0, - reportCount: 0, - allReports: [], - }; - } - - const uniqueMessages = new Set(); - const uniqueChannels = new Set(); - let reportCount = 0; - user?.forEach((u) => { - reportCount += u.logs.length; - u.logs.forEach(({ message }) => { - uniqueChannels.add(message.channelId); - uniqueMessages.add(message.id); - }); - }); - - return { - uniqueMessages: uniqueMessages.size, - uniqueChannels: uniqueChannels.size, - reportCount, - allReports: [...user.values()].flatMap(({ logs }) => logs), - }; -}; - -export const queryReportCache = (message: Message) => { - const cacheKey = `${message.guildId}${message.author.id}`; - const simplifiedContent = simplifyString(message.content); - - const cachedWarnings = cache.get(cacheKey); - return cachedWarnings?.get(simplifiedContent); -}; - -export const trackReport = (logMessage: Message, newReport: Report) => { - const cacheKey = `${newReport.message.guildId}${newReport.message.author.id}`; - const simplifiedContent = simplifyString(newReport.message.content); - - let cachedWarnings = cache.get(cacheKey); - if (!cachedWarnings) { - console.log("[trackReport]", "no cached warnings found for guild+author"); - cachedWarnings = new Map(); - } - - let existingReports = cachedWarnings.get(simplifiedContent); - if (!existingReports) { - console.log("[trackReport]", "tracking a new reported message"); - // This is busted cuz it would need to create a new log thread - existingReports = { logMessage, logs: [] }; - } - - const newLogs = existingReports.logs.concat([newReport]); - cachedWarnings.set(simplifiedContent, { - logMessage, - logs: newLogs, - }); - cache.set(cacheKey, cachedWarnings); -}; - -export const deleteAllReported = async (message: Message) => { - const allReports = queryReportCache(message); - if (!allReports) return; - - await Promise.allSettled(allReports?.logs.map((l) => l.message.delete())); -}; diff --git a/app/db.d.ts b/app/db.d.ts index 261c70d..4b448b3 100644 --- a/app/db.d.ts +++ b/app/db.d.ts @@ -42,6 +42,22 @@ export interface MessageStats { word_count: number; } +export interface ReportedMessages { + created_at: Generated; + deleted_at: string | null; + extra: string | null; + guild_id: string; + id: string; + log_channel_id: string; + log_message_id: string; + reason: string; + reported_channel_id: string; + reported_message_id: string; + reported_user_id: string; + staff_id: string | null; + staff_username: string | null; +} + export interface Sessions { data: string | null; expires: string | null; @@ -61,12 +77,21 @@ export interface Users { id: string; } +export interface UserThreads { + created_at: Generated; + guild_id: string; + thread_id: string; + user_id: string; +} + export interface DB { channel_info: ChannelInfo; guild_subscriptions: GuildSubscriptions; guilds: Guilds; message_stats: MessageStats; + reported_messages: ReportedMessages; sessions: Sessions; tickets_config: TicketsConfig; + user_threads: UserThreads; users: Users; } diff --git a/app/discord/activityTracker.ts b/app/discord/activityTracker.ts index 6d5ff96..5b0cde5 100644 --- a/app/discord/activityTracker.ts +++ b/app/discord/activityTracker.ts @@ -144,6 +144,9 @@ export async function startActivityTracking(client: Client) { }); client.on(Events.MessageDelete, async (msg) => { + if (msg.system || msg.author?.bot) { + return; + } await trackPerformance( "processMessageDelete", async () => { diff --git a/app/discord/automod.ts b/app/discord/automod.ts index a7aabdb..18cc5c1 100644 --- a/app/discord/automod.ts +++ b/app/discord/automod.ts @@ -4,7 +4,10 @@ import { isStaff } from "#~/helpers/discord"; import { reportUser } from "#~/helpers/modLog"; import { client } from "./client.server"; import { isSpam } from "#~/helpers/isSpam"; -import { ReportReasons } from "#~/commands/track/reportCache.js"; +import { + ReportReasons, + markMessageAsDeleted, +} from "#~/models/reportedMessages.server"; const AUTO_SPAM_THRESHOLD = 3; @@ -27,7 +30,9 @@ export default async (bot: Client) => { message: message, staff: client.user || false, }), - message.delete(), + message + .delete() + .then(() => markMessageAsDeleted(message.id, message.guild!.id)), ]); if (warnings >= AUTO_SPAM_THRESHOLD) { diff --git a/app/helpers/escalate.tsx b/app/helpers/escalate.tsx index 1aee30d..522c7a3 100644 --- a/app/helpers/escalate.tsx +++ b/app/helpers/escalate.tsx @@ -1,7 +1,11 @@ -import { ChannelType, PermissionsBitField } from "discord.js"; +import { + ChannelType, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, +} from "discord.js"; import type { Message, TextChannel, ThreadChannel, User } from "discord.js"; -import { reacord } from "#~/discord/client.server"; import { quoteAndEscape } from "#~/helpers/discord"; import { reportUser } from "#~/helpers/modLog"; import { resolutions } from "#~/helpers/modResponse"; @@ -9,101 +13,62 @@ import { resolutions } from "#~/helpers/modResponse"; import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; import { applyRestriction, ban, kick, timeout } from "#~/models/discord.server"; import { ModResponse } from "#~/commands/reacord/ModResponse"; -import { - Button, - type ComponentEventReplyOptions, - type ReacordInstance, -} from "reacord"; -import { - deleteAllReported, - ReportReasons, -} from "#~/commands/track/reportCache.js"; +import { type ComponentEventReplyOptions, type ReacordInstance } from "reacord"; +import { ReportReasons } from "#~/models/reportedMessages.server"; export async function escalationControls( reportedMessage: Message, thread: ThreadChannel, - modRoleId: string, + _modRoleId: string, ) { - reacord.createChannelMessage(thread.id).render( - <> - Moderator controls -