Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
263 changes: 263 additions & 0 deletions app/commands/escalationControls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
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) => {
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.reply({
content: "Insufficient permissions",
ephemeral: true,
});
}

try {
const result = await deleteAllReportedForUser(reportedUserId, guildId);
await interaction.reply(
`Messages deleted by ${interaction.user.username} (${result.deleted}/${result.total} successful)`,
);
} catch (error) {
console.error("Error deleting reported messages:", error);
await interaction.reply({
content: "Failed to delete messages",
ephemeral: true,
});
}
},
},

{
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>;
24 changes: 24 additions & 0 deletions app/db.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ export interface MessageStats {
word_count: number;
}

export interface ReportedMessages {
created_at: Generated<string>;
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;
Expand All @@ -61,12 +76,21 @@ export interface Users {
id: string;
}

export interface UserThreads {
created_at: Generated<string>;
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;
}
3 changes: 3 additions & 0 deletions app/discord/activityTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading