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
312 changes: 312 additions & 0 deletions app/commands/modreport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
import {
PermissionFlagsBits,
SlashCommandBuilder,
time,
type APIEmbed,
type APIEmbedField,
} from "discord.js";
import { Effect } from "effect";

import { ReadableReasons } from "#~/commands/report/constructLog";
import {
interactionDeferReply,
interactionEditReply,
} from "#~/effects/discordSdk.ts";
import { logEffect } from "#~/effects/observability.ts";
import type { SlashCommand } from "#~/helpers/discord";
import { commandStats } from "#~/helpers/metrics";
import { truncateMessage } from "#~/helpers/string";
import { getDeletionLogThread } from "#~/models/deletionLogThreads";
import {
getModActionCounts,
getRecentModActions,
type ModActionType,
} from "#~/models/modActions";
import {
getChannelBreakdown,
getMonthlyReportCounts,
getRecentReportCount,
getUserReportSummary,
type ReportReasons,
} from "#~/models/reportedMessages";
import { getUserThread } from "#~/models/userThreads";

const parseDate = (value: string | null): Date | null => {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
};

const SPARKLINE_BLOCKS = "▁▂▃▄▅▆▇█";

/**
* Build a sparkline string from monthly report counts.
* Fills in zero for months with no data so the shape is continuous.
*/
const buildSparkline = (
monthlyData: { month: unknown; count: unknown }[],
numMonths: number,
): string | null => {
if (monthlyData.length === 0) return null;

// Build a map of YYYY-MM -> count
const countsByMonth = new Map<string, number>();
for (const row of monthlyData) {
countsByMonth.set(String(row.month), Number(row.count));
}

// Generate the last N months as YYYY-MM keys
const now = new Date();
const months: string[] = [];
for (let i = numMonths - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
);
}

const values = months.map((m) => countsByMonth.get(m) ?? 0);
const max = Math.max(...values);
if (max === 0) return null;

const spark = values
.map((v) => {
const idx = Math.round((v / max) * (SPARKLINE_BLOCKS.length - 1));
return SPARKLINE_BLOCKS[idx];
})
.join("");

return spark;
};

const actionLabels: Record<ModActionType, string> = {
kick: "Kicked",
ban: "Banned",
unban: "Unbanned",
timeout: "Timed out",
timeout_removed: "Timeout removed",
};

export const Command = {
command: new SlashCommandBuilder()
.setName("modreport")
.setDescription("Show a summary of a user's moderation history")
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addUserOption((x) =>
x.setName("user").setDescription("The user to look up").setRequired(true),
) as SlashCommandBuilder,

handler: (interaction) =>
Effect.gen(function* () {
yield* interactionDeferReply(interaction);

const targetUser = interaction.options.getUser("user", true);
const guildId = interaction.guildId;

if (!guildId) {
yield* interactionEditReply(interaction, {
content: "This command can only be used in a server.",
});
return;
}

yield* logEffect("info", "Commands", "Modreport command executed", {
guildId,
userId: interaction.user.id,
targetUserId: targetUser.id,
});

const SPARKLINE_MONTHS = 6;
const [
summary,
userThread,
deletionThread,
actionCounts,
recentActions,
recency,
channels,
monthlyData,
] = yield* Effect.all([
getUserReportSummary(targetUser.id, guildId),
getUserThread(targetUser.id, guildId),
getDeletionLogThread(targetUser.id, guildId),
getModActionCounts(targetUser.id, guildId),
getRecentModActions(targetUser.id, guildId),
getRecentReportCount(targetUser.id, guildId),
getChannelBreakdown(targetUser.id, guildId),
getMonthlyReportCounts(targetUser.id, guildId, SPARKLINE_MONTHS),
]);

const hasActions = Object.keys(actionCounts).length > 0;

if (summary.reportCount === 0 && !hasActions) {
yield* interactionEditReply(interaction, {
content: `No moderation history found for <@${targetUser.id}>.`,
allowedMentions: { users: [] },
});
commandStats.commandExecuted(interaction, "modreport", true);
return;
}

// --- Description: top-line summary ---
const descLines: string[] = [];

if (summary.reportCount > 0) {
// Recency line
if (recency.recent > 0 && recency.total > recency.recent) {
descLines.push(
`${recency.recent} report${recency.recent === 1 ? "" : "s"} in the last ${recency.days} days (${recency.total} total)`,
);
} else {
descLines.push(
`${summary.uniqueMessages} message${summary.uniqueMessages === 1 ? "" : "s"} reported ${summary.reportCount} time${summary.reportCount === 1 ? "" : "s"} across ${summary.uniqueChannels} channel${summary.uniqueChannels === 1 ? "" : "s"}`,
);
}

const firstDate = parseDate(summary.firstReport);
const lastDate = parseDate(summary.lastReport);
if (firstDate && lastDate) {
const peakPart =
summary.peakDayCount > 1
? `. Most reports in a single day was ${summary.peakDayCount}`
: "";
if (summary.reportCount === 1) {
descLines.push(`Reported ${time(firstDate, "R")}${peakPart}`);
} else {
descLines.push(
`First reported ${time(firstDate, "D")}; most recently ${time(lastDate, "R")}${peakPart}`,
);
}
}

const sparkline = buildSparkline(monthlyData, SPARKLINE_MONTHS);
if (sparkline) {
descLines.push(`\`${sparkline}\` (last ${SPARKLINE_MONTHS} months)`);
}

if (summary.uniqueStaffCount > 0) {
descLines.push(
`Reported by ${summary.uniqueStaffCount} different staff member${summary.uniqueStaffCount === 1 ? "" : "s"}`,
);
}
}

// Action count summary line
const actionSummaryLabels: [ModActionType, string][] = [
["kick", "Kicked"],
["ban", "Banned"],
["timeout", "Timed out"],
];
const actionParts = actionSummaryLabels
.filter(([type]) => actionCounts[type])
.map(
([type, label]) =>
`${label} ${actionCounts[type]} time${actionCounts[type] === 1 ? "" : "s"}`,
);
if (actionParts.length > 0) {
descLines.push(actionParts.join(" · "));
}

// Thread links
const threadLinks = [
userThread
? `[Moderation logs](https://discord.com/channels/${guildId}/${userThread.thread_id})`
: null,
deletionThread
? `[Deleted message logs](https://discord.com/channels/${guildId}/${deletionThread.thread_id})`
: null,
].filter(Boolean);
if (threadLinks.length > 0) {
descLines.push(threadLinks.join(" · "));
}

// --- Embed fields ---
const fields: APIEmbedField[] = [];

// Reason breakdown
if (summary.reasonBreakdown.length > 0) {
const reasonText = summary.reasonBreakdown
.map(
(r) =>
`${ReadableReasons[r.reason as ReportReasons] ?? r.reason} ×${r.count}`,
)
.join(" · ");
fields.push({
name: "Reasons",
value: truncateMessage(reasonText, 1024),
inline: true,
});
}

// Channel breakdown
if (channels.length > 0) {
const channelText = channels
.map((c) => `<#${c.reported_channel_id}> (${Number(c.count)})`)
.join(" · ");
fields.push({
name: "Top Channels",
value: truncateMessage(channelText, 1024),
inline: true,
});
}

// Mod action timeline
if (recentActions.length > 0) {
const timelineLines = recentActions.map((a) => {
const actionLabel =
actionLabels[a.action_type as ModActionType] ?? a.action_type;
const executor = a.executor_username
? ` by @${a.executor_username}`
: "";
const reason = a.reason ? ` — "${a.reason}"` : "";
const timestamp = parseDate(a.created_at);
const ts = timestamp ? ` ${time(timestamp, "R")}` : "";
return `${actionLabel}${executor}${reason}${ts}`;
});
fields.push({
name: "Actions",
value: truncateMessage(timelineLines.join("\n"), 1024),
inline: false,
});
}

const embed: APIEmbed = {
author: {
name: targetUser.username,
icon_url: targetUser.displayAvatarURL(),
},
description: descLines.join("\n"),
fields: fields.length > 0 ? fields : undefined,
};

yield* interactionEditReply(interaction, {
embeds: [embed],
});

commandStats.commandExecuted(interaction, "modreport", true);
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
const err = error instanceof Error ? error : new Error(String(error));

yield* logEffect("error", "Commands", "Modreport command failed", {
guildId: interaction.guildId,
userId: interaction.user.id,
error: err,
});

commandStats.commandFailed(interaction, "modreport", err.message);

yield* interactionEditReply(interaction, {
content: "Failed to fetch moderation summary.",
}).pipe(Effect.catchAll(() => Effect.void));
}),
),
Effect.withSpan("modreportCommand", {
attributes: {
guildId: interaction.guildId,
userId: interaction.user.id,
},
}),
),
} satisfies SlashCommand;
12 changes: 12 additions & 0 deletions app/commands/report/modActionLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk";
import { logEffect } from "#~/effects/observability";
import { truncateMessage } from "#~/helpers/string";
import { fetchSettingsEffect, SETTINGS } from "#~/models/guilds.server";
import { recordModAction } from "#~/models/modActions";
import { getOrCreateUserThread } from "#~/models/userThreads.ts";

export type ModActionReport =
Expand Down Expand Up @@ -98,6 +99,17 @@ export const logModAction = (report: ModActionReport) =>

// Forward to mod log (non-critical)
yield* forwardMessageSafe(logMessage, modLog);

// Persist to database
yield* recordModAction({
userId: user.id,
guildId: guild.id,
actionType,
executorId: executor?.id,
executorUsername: executor?.username,
reason,
duration: actionType === "timeout" ? report.duration : undefined,
});
}).pipe(
Effect.withSpan("logModAction", {
attributes: {
Expand Down
3 changes: 2 additions & 1 deletion app/commands/report/userLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ export function logUserMessage({
]);

const alreadyReported = existingReports.find(
(r) => r.reported_message_id === message.id,
(r) =>
r.reported_message_id === message.id && r.reason === (reason as string),
);

yield* logEffect(
Expand Down
13 changes: 13 additions & 0 deletions app/db.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ export interface MessageStats {
word_count: number;
}

export interface ModActions {
action_type: string;
created_at: string;
duration: string | null;
executor_id: string | null;
executor_username: string | null;
guild_id: string;
id: string;
reason: string | null;
user_id: string;
}

export interface ReactjiChannelerConfig {
channel_id: string;
configured_by_id: string;
Expand Down Expand Up @@ -154,6 +166,7 @@ export interface DB {
honeypot_config: HoneypotConfig;
message_cache: MessageCache;
message_stats: MessageStats;
mod_actions: ModActions;
reactji_channeler_config: ReactjiChannelerConfig;
reported_messages: ReportedMessages;
sessions: Sessions;
Expand Down
Loading