Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ k8s-context
tsconfig.tsbuildinfo
.react-router
tailwind.css
userInfoCache.json
vite.config.ts*
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion app/commands/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const command = new SlashCommandBuilder()

export const handler = async (interaction: CommandInteraction) => {
await interaction.reply({
ephemeral: true,
flags: "Ephemeral",
content: "ok",
});
};
Expand Down
262 changes: 262 additions & 0 deletions app/commands/escalationControls.ts
Original file line number Diff line number Diff line change
@@ -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<MessageComponentCommand>;
2 changes: 1 addition & 1 deletion app/commands/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
9 changes: 7 additions & 2 deletions app/commands/track.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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}`,
Expand Down
Loading
Loading