forked from syntaxbullet/aurorabot
refactor: convert ModerationService and PruneService from classes to singleton objects
- Convert ModerationService class to moderationService singleton - Convert PruneService class to pruneService singleton - Update all command files to use new singleton imports - Update web routes to use new singleton imports - Update tests for singleton pattern - Remove getNextCaseId from tests (now private module function)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,5 +46,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
src/db/data
|
src/db/data
|
||||||
src/db/log
|
src/db/log
|
||||||
scratchpad/
|
scratchpad/
|
||||||
tickets/
|
|
||||||
bot/assets/graphics/items
|
bot/assets/graphics/items
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const moderationCase = createCommand({
|
export const moderationCase = createCommand({
|
||||||
@@ -30,7 +30,7 @@ export const moderationCase = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the case
|
// Get the case
|
||||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
const moderationCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
if (!moderationCase) {
|
if (!moderationCase) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const cases = createCommand({
|
export const cases = createCommand({
|
||||||
@@ -29,7 +29,7 @@ export const cases = createCommand({
|
|||||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||||
|
|
||||||
// Get cases for the user
|
// Get cases for the user
|
||||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
|
||||||
|
|
||||||
const title = activeOnly
|
const title = activeOnly
|
||||||
? `⚠️ Active Cases for ${targetUser.username}`
|
? `⚠️ Active Cases for ${targetUser.username}`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const clearwarning = createCommand({
|
export const clearwarning = createCommand({
|
||||||
@@ -38,7 +38,7 @@ export const clearwarning = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if case exists and is active
|
// Check if case exists and is active
|
||||||
const existingCase = await ModerationService.getCaseById(caseId);
|
const existingCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
if (!existingCase) {
|
if (!existingCase) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
@@ -62,7 +62,7 @@ export const clearwarning = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear the warning
|
// Clear the warning
|
||||||
await ModerationService.clearCase({
|
await moderationService.clearCase({
|
||||||
caseId,
|
caseId,
|
||||||
clearedBy: interaction.user.id,
|
clearedBy: interaction.user.id,
|
||||||
clearedByName: interaction.user.username,
|
clearedByName: interaction.user.username,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { CaseType } from "@shared/lib/constants";
|
import { CaseType } from "@shared/lib/constants";
|
||||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export const note = createCommand({
|
|||||||
const noteText = interaction.options.getString("note", true);
|
const noteText = interaction.options.getString("note", true);
|
||||||
|
|
||||||
// Create the note case
|
// Create the note case
|
||||||
const moderationCase = await ModerationService.createCase({
|
const moderationCase = await moderationService.createCase({
|
||||||
type: CaseType.NOTE,
|
type: CaseType.NOTE,
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const notes = createCommand({
|
export const notes = createCommand({
|
||||||
@@ -22,7 +22,7 @@ export const notes = createCommand({
|
|||||||
const targetUser = interaction.options.getUser("user", true);
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
// Get all notes for the user
|
// Get all notes for the user
|
||||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||||
|
|
||||||
// Display the notes
|
// Display the notes
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
import { pruneService } from "@shared/modules/moderation/prune.service";
|
||||||
import {
|
import {
|
||||||
getConfirmationMessage,
|
getConfirmationMessage,
|
||||||
getProgressEmbed,
|
getProgressEmbed,
|
||||||
@@ -66,7 +66,7 @@ export const prune = createCommand({
|
|||||||
let estimatedCount: number | undefined;
|
let estimatedCount: number | undefined;
|
||||||
if (all) {
|
if (all) {
|
||||||
try {
|
try {
|
||||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
|
||||||
} catch {
|
} catch {
|
||||||
estimatedCount = undefined;
|
estimatedCount = undefined;
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ export const prune = createCommand({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Execute deletion with progress callback for 'all' mode
|
// Execute deletion with progress callback for 'all' mode
|
||||||
const result = await PruneService.deleteMessages(
|
const result = await pruneService.deleteMessages(
|
||||||
interaction.channel!,
|
interaction.channel!,
|
||||||
{
|
{
|
||||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||||
@@ -129,7 +129,7 @@ export const prune = createCommand({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No confirmation needed, proceed directly
|
// No confirmation needed, proceed directly
|
||||||
const result = await PruneService.deleteMessages(
|
const result = await pruneService.deleteMessages(
|
||||||
interaction.channel!,
|
interaction.channel!,
|
||||||
{
|
{
|
||||||
amount: finalAmount as number,
|
amount: finalAmount as number,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import {
|
import {
|
||||||
getWarnSuccessEmbed,
|
getWarnSuccessEmbed,
|
||||||
getModerationErrorEmbed,
|
getModerationErrorEmbed,
|
||||||
@@ -54,7 +54,7 @@ export const warn = createCommand({
|
|||||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
|
||||||
// Issue the warning via service
|
// Issue the warning via service
|
||||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const warnings = createCommand({
|
export const warnings = createCommand({
|
||||||
@@ -22,7 +22,7 @@ export const warnings = createCommand({
|
|||||||
const targetUser = interaction.options.getUser("user", true);
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
// Get active warnings for the user
|
// Get active warnings for the user
|
||||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||||
|
|
||||||
// Display the warnings
|
// Display the warnings
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { moderationCases } from "@db/schema";
|
import { moderationCases } from "@db/schema";
|
||||||
import { CaseType } from "@shared/lib/constants";
|
import { CaseType } from "@shared/lib/constants";
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ mockUpdate.mockReturnValue({ set: mockSet });
|
|||||||
mockSet.mockReturnValue({ where: mockWhere });
|
mockSet.mockReturnValue({ where: mockWhere });
|
||||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||||
|
|
||||||
describe("ModerationService", () => {
|
describe("moderationService", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFindFirst.mockReset();
|
mockFindFirst.mockReset();
|
||||||
mockFindMany.mockReset();
|
mockFindMany.mockReset();
|
||||||
@@ -73,7 +73,7 @@ describe("ModerationService", () => {
|
|||||||
|
|
||||||
const mockDmTarget = { send: mock() };
|
const mockDmTarget = { send: mock() };
|
||||||
|
|
||||||
const result = await ModerationService.issueWarning({
|
const result = await moderationService.issueWarning({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
dmTarget: mockDmTarget
|
dmTarget: mockDmTarget
|
||||||
});
|
});
|
||||||
@@ -91,7 +91,7 @@ describe("ModerationService", () => {
|
|||||||
|
|
||||||
const mockDmTarget = { send: mock() };
|
const mockDmTarget = { send: mock() };
|
||||||
|
|
||||||
await ModerationService.issueWarning({
|
await moderationService.issueWarning({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
dmTarget: mockDmTarget,
|
dmTarget: mockDmTarget,
|
||||||
config: { dmOnWarn: false }
|
config: { dmOnWarn: false }
|
||||||
@@ -108,7 +108,7 @@ describe("ModerationService", () => {
|
|||||||
|
|
||||||
const mockTimeoutTarget = { timeout: mock() };
|
const mockTimeoutTarget = { timeout: mock() };
|
||||||
|
|
||||||
const result = await ModerationService.issueWarning({
|
const result = await moderationService.issueWarning({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
timeoutTarget: mockTimeoutTarget,
|
timeoutTarget: mockTimeoutTarget,
|
||||||
config: { autoTimeoutThreshold: 3 }
|
config: { autoTimeoutThreshold: 3 }
|
||||||
@@ -128,7 +128,7 @@ describe("ModerationService", () => {
|
|||||||
|
|
||||||
const mockTimeoutTarget = { timeout: mock() };
|
const mockTimeoutTarget = { timeout: mock() };
|
||||||
|
|
||||||
const result = await ModerationService.issueWarning({
|
const result = await moderationService.issueWarning({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
timeoutTarget: mockTimeoutTarget
|
timeoutTarget: mockTimeoutTarget
|
||||||
});
|
});
|
||||||
@@ -139,27 +139,6 @@ describe("ModerationService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getNextCaseId", () => {
|
|
||||||
it("should return CASE-0001 if no cases exist", async () => {
|
|
||||||
mockFindFirst.mockResolvedValue(undefined);
|
|
||||||
// Accessing private method via bracket notation for testing
|
|
||||||
const nextId = await (ModerationService as any).getNextCaseId();
|
|
||||||
expect(nextId).toBe("CASE-0001");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should increment the latest case ID", async () => {
|
|
||||||
mockFindFirst.mockResolvedValue({ caseId: "CASE-0042" });
|
|
||||||
const nextId = await (ModerationService as any).getNextCaseId();
|
|
||||||
expect(nextId).toBe("CASE-0043");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle padding correctly (e.g., 9 -> 0010)", async () => {
|
|
||||||
mockFindFirst.mockResolvedValue({ caseId: "CASE-0009" });
|
|
||||||
const nextId = await (ModerationService as any).getNextCaseId();
|
|
||||||
expect(nextId).toBe("CASE-0010");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createCase", () => {
|
describe("createCase", () => {
|
||||||
it("should create a new moderation case with correct values", async () => {
|
it("should create a new moderation case with correct values", async () => {
|
||||||
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
|
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
|
||||||
@@ -176,7 +155,7 @@ describe("ModerationService", () => {
|
|||||||
};
|
};
|
||||||
mockReturning.mockResolvedValue([mockNewCase]);
|
mockReturning.mockResolvedValue([mockNewCase]);
|
||||||
|
|
||||||
const result = await ModerationService.createCase({
|
const result = await moderationService.createCase({
|
||||||
type: CaseType.WARN,
|
type: CaseType.WARN,
|
||||||
userId: "123456789",
|
userId: "123456789",
|
||||||
username: "testuser",
|
username: "testuser",
|
||||||
@@ -199,7 +178,7 @@ describe("ModerationService", () => {
|
|||||||
mockFindFirst.mockResolvedValue(undefined);
|
mockFindFirst.mockResolvedValue(undefined);
|
||||||
mockReturning.mockImplementation((values) => [values]); // Simplified mock
|
mockReturning.mockImplementation((values) => [values]); // Simplified mock
|
||||||
|
|
||||||
const result = await ModerationService.createCase({
|
const result = await moderationService.createCase({
|
||||||
type: CaseType.BAN,
|
type: CaseType.BAN,
|
||||||
userId: "123456789",
|
userId: "123456789",
|
||||||
username: "testuser",
|
username: "testuser",
|
||||||
@@ -219,7 +198,7 @@ describe("ModerationService", () => {
|
|||||||
const mockCase = { caseId: "CASE-0001", reason: "test" };
|
const mockCase = { caseId: "CASE-0001", reason: "test" };
|
||||||
mockFindFirst.mockResolvedValue(mockCase);
|
mockFindFirst.mockResolvedValue(mockCase);
|
||||||
|
|
||||||
const result = await ModerationService.getCaseById("CASE-0001");
|
const result = await moderationService.getCaseById("CASE-0001");
|
||||||
expect(result).toEqual(mockCase as any);
|
expect(result).toEqual(mockCase as any);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -229,7 +208,7 @@ describe("ModerationService", () => {
|
|||||||
const mockCases = [{ caseId: "CASE-0001" }, { caseId: "CASE-0002" }];
|
const mockCases = [{ caseId: "CASE-0001" }, { caseId: "CASE-0002" }];
|
||||||
mockFindMany.mockResolvedValue(mockCases);
|
mockFindMany.mockResolvedValue(mockCases);
|
||||||
|
|
||||||
const result = await ModerationService.getUserCases("123456789");
|
const result = await moderationService.getUserCases("123456789");
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
expect(mockFindMany).toHaveBeenCalled();
|
expect(mockFindMany).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -240,7 +219,7 @@ describe("ModerationService", () => {
|
|||||||
const mockUpdatedCase = { caseId: "CASE-0001", active: false };
|
const mockUpdatedCase = { caseId: "CASE-0001", active: false };
|
||||||
mockReturning.mockResolvedValue([mockUpdatedCase]);
|
mockReturning.mockResolvedValue([mockUpdatedCase]);
|
||||||
|
|
||||||
const result = await ModerationService.clearCase({
|
const result = await moderationService.clearCase({
|
||||||
caseId: "CASE-0001",
|
caseId: "CASE-0001",
|
||||||
clearedBy: "987654321",
|
clearedBy: "987654321",
|
||||||
clearedByName: "mod",
|
clearedByName: "mod",
|
||||||
@@ -264,13 +243,13 @@ describe("ModerationService", () => {
|
|||||||
{ id: 2n, type: CaseType.WARN, active: true }
|
{ id: 2n, type: CaseType.WARN, active: true }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const count = await ModerationService.getActiveWarningCount("123456789");
|
const count = await moderationService.getActiveWarningCount("123456789");
|
||||||
expect(count).toBe(2);
|
expect(count).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 0 if no active warnings", async () => {
|
it("should return 0 if no active warnings", async () => {
|
||||||
mockFindMany.mockResolvedValue([]);
|
mockFindMany.mockResolvedValue([]);
|
||||||
const count = await ModerationService.getActiveWarningCount("123456789");
|
const count = await moderationService.getActiveWarningCount("123456789");
|
||||||
expect(count).toBe(0);
|
expect(count).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,34 +10,56 @@ export interface ModerationCaseConfig {
|
|||||||
autoTimeoutThreshold?: number;
|
autoTimeoutThreshold?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ModerationService {
|
/**
|
||||||
/**
|
* Generate the next sequential case ID
|
||||||
* Generate the next sequential case ID
|
*/
|
||||||
*/
|
async function getNextCaseId(): Promise<string> {
|
||||||
private static async getNextCaseId(): Promise<string> {
|
const latestCase = await DrizzleClient.query.moderationCases.findFirst({
|
||||||
const latestCase = await DrizzleClient.query.moderationCases.findFirst({
|
orderBy: [desc(moderationCases.id)],
|
||||||
orderBy: [desc(moderationCases.id)],
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (!latestCase) {
|
if (!latestCase) {
|
||||||
return "CASE-0001";
|
return "CASE-0001";
|
||||||
}
|
|
||||||
|
|
||||||
// Extract number from case ID (e.g., "CASE-0042" -> 42)
|
|
||||||
const match = latestCase.caseId.match(/CASE-(\d+)/);
|
|
||||||
if (!match || !match[1]) {
|
|
||||||
return "CASE-0001";
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextNumber = parseInt(match[1], 10) + 1;
|
|
||||||
return `CASE-${nextNumber.toString().padStart(4, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract number from case ID (e.g., "CASE-0042" -> 42)
|
||||||
|
const match = latestCase.caseId.match(/CASE-(\d+)/);
|
||||||
|
if (!match || !match[1]) {
|
||||||
|
return "CASE-0001";
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextNumber = parseInt(match[1], 10) + 1;
|
||||||
|
return `CASE-${nextNumber.toString().padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active warnings for a user
|
||||||
|
*/
|
||||||
|
async function getUserWarnings(userId: string) {
|
||||||
|
return await DrizzleClient.query.moderationCases.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(moderationCases.userId, BigInt(userId)),
|
||||||
|
eq(moderationCases.type, CaseType.WARN),
|
||||||
|
eq(moderationCases.active, true)
|
||||||
|
),
|
||||||
|
orderBy: [desc(moderationCases.createdAt)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total count of active warnings for a user (useful for auto-timeout)
|
||||||
|
*/
|
||||||
|
async function getActiveWarningCount(userId: string): Promise<number> {
|
||||||
|
const warnings = await getUserWarnings(userId);
|
||||||
|
return warnings.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moderationService = {
|
||||||
/**
|
/**
|
||||||
* Create a new moderation case
|
* Create a new moderation case
|
||||||
*/
|
*/
|
||||||
static async createCase(options: CreateCaseOptions) {
|
async createCase(options: CreateCaseOptions) {
|
||||||
const caseId = await this.getNextCaseId();
|
const caseId = await getNextCaseId();
|
||||||
|
|
||||||
const [newCase] = await DrizzleClient.insert(moderationCases).values({
|
const [newCase] = await DrizzleClient.insert(moderationCases).values({
|
||||||
caseId,
|
caseId,
|
||||||
@@ -52,12 +74,12 @@ export class ModerationService {
|
|||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
return newCase;
|
return newCase;
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue a warning with DM and threshold logic
|
* Issue a warning with DM and threshold logic
|
||||||
*/
|
*/
|
||||||
static async issueWarning(options: {
|
async issueWarning(options: {
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
moderatorId: string;
|
moderatorId: string;
|
||||||
@@ -81,7 +103,7 @@ export class ModerationService {
|
|||||||
throw new Error("Failed to create moderation case");
|
throw new Error("Failed to create moderation case");
|
||||||
}
|
}
|
||||||
|
|
||||||
const warningCount = await this.getActiveWarningCount(options.userId);
|
const warningCount = await getActiveWarningCount(options.userId);
|
||||||
const config = options.config ?? {};
|
const config = options.config ?? {};
|
||||||
|
|
||||||
// Try to DM the user if configured
|
// Try to DM the user if configured
|
||||||
@@ -127,21 +149,21 @@ export class ModerationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { moderationCase, warningCount, autoTimeoutIssued };
|
return { moderationCase, warningCount, autoTimeoutIssued };
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a case by its case ID
|
* Get a case by its case ID
|
||||||
*/
|
*/
|
||||||
static async getCaseById(caseId: string) {
|
async getCaseById(caseId: string) {
|
||||||
return await DrizzleClient.query.moderationCases.findFirst({
|
return await DrizzleClient.query.moderationCases.findFirst({
|
||||||
where: eq(moderationCases.caseId, caseId),
|
where: eq(moderationCases.caseId, caseId),
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all cases for a specific user
|
* Get all cases for a specific user
|
||||||
*/
|
*/
|
||||||
static async getUserCases(userId: string, activeOnly: boolean = false) {
|
async getUserCases(userId: string, activeOnly: boolean = false) {
|
||||||
const conditions = [eq(moderationCases.userId, BigInt(userId))];
|
const conditions = [eq(moderationCases.userId, BigInt(userId))];
|
||||||
|
|
||||||
if (activeOnly) {
|
if (activeOnly) {
|
||||||
@@ -152,26 +174,19 @@ export class ModerationService {
|
|||||||
where: and(...conditions),
|
where: and(...conditions),
|
||||||
orderBy: [desc(moderationCases.createdAt)],
|
orderBy: [desc(moderationCases.createdAt)],
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get active warnings for a user
|
* Get active warnings for a user (public alias)
|
||||||
*/
|
*/
|
||||||
static async getUserWarnings(userId: string) {
|
async getUserWarnings(userId: string) {
|
||||||
return await DrizzleClient.query.moderationCases.findMany({
|
return await getUserWarnings(userId);
|
||||||
where: and(
|
},
|
||||||
eq(moderationCases.userId, BigInt(userId)),
|
|
||||||
eq(moderationCases.type, CaseType.WARN),
|
|
||||||
eq(moderationCases.active, true)
|
|
||||||
),
|
|
||||||
orderBy: [desc(moderationCases.createdAt)],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all notes for a user
|
* Get all notes for a user
|
||||||
*/
|
*/
|
||||||
static async getUserNotes(userId: string) {
|
async getUserNotes(userId: string) {
|
||||||
return await DrizzleClient.query.moderationCases.findMany({
|
return await DrizzleClient.query.moderationCases.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(moderationCases.userId, BigInt(userId)),
|
eq(moderationCases.userId, BigInt(userId)),
|
||||||
@@ -179,12 +194,12 @@ export class ModerationService {
|
|||||||
),
|
),
|
||||||
orderBy: [desc(moderationCases.createdAt)],
|
orderBy: [desc(moderationCases.createdAt)],
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear/resolve a warning
|
* Clear/resolve a warning
|
||||||
*/
|
*/
|
||||||
static async clearCase(options: ClearCaseOptions) {
|
async clearCase(options: ClearCaseOptions) {
|
||||||
const [updatedCase] = await DrizzleClient.update(moderationCases)
|
const [updatedCase] = await DrizzleClient.update(moderationCases)
|
||||||
.set({
|
.set({
|
||||||
active: false,
|
active: false,
|
||||||
@@ -196,12 +211,12 @@ export class ModerationService {
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return updatedCase;
|
return updatedCase;
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search cases with various filters
|
* Search cases with various filters
|
||||||
*/
|
*/
|
||||||
static async searchCases(filter: SearchCasesFilter) {
|
async searchCases(filter: SearchCasesFilter) {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
|
|
||||||
if (filter.userId) {
|
if (filter.userId) {
|
||||||
@@ -228,13 +243,12 @@ export class ModerationService {
|
|||||||
limit: filter.limit || 50,
|
limit: filter.limit || 50,
|
||||||
offset: filter.offset || 0,
|
offset: filter.offset || 0,
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get total count of active warnings for a user (useful for auto-timeout)
|
* Get total count of active warnings for a user (useful for auto-timeout)
|
||||||
*/
|
*/
|
||||||
static async getActiveWarningCount(userId: string): Promise<number> {
|
async getActiveWarningCount(userId: string): Promise<number> {
|
||||||
const warnings = await this.getUserWarnings(userId);
|
return await getActiveWarningCount(userId);
|
||||||
return warnings.length;
|
},
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,11 +3,73 @@ import type { TextBasedChannel } from "discord.js";
|
|||||||
import type { PruneOptions, PruneResult, PruneProgress } from "@/modules/moderation/prune.types";
|
import type { PruneOptions, PruneResult, PruneProgress } from "@/modules/moderation/prune.types";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
|
|
||||||
export class PruneService {
|
/**
|
||||||
|
* Fetch messages from a channel
|
||||||
|
*/
|
||||||
|
async function fetchMessages(
|
||||||
|
channel: TextBasedChannel,
|
||||||
|
limit: number,
|
||||||
|
before?: string
|
||||||
|
): Promise<Collection<string, Message>> {
|
||||||
|
if (!('messages' in channel)) {
|
||||||
|
return new Collection();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await channel.messages.fetch({
|
||||||
|
limit,
|
||||||
|
before
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a batch of messages for deletion
|
||||||
|
*/
|
||||||
|
async function processBatch(
|
||||||
|
channel: TextBasedChannel,
|
||||||
|
messages: Collection<string, Message>,
|
||||||
|
userId?: string
|
||||||
|
): Promise<{ deleted: number; skipped: number }> {
|
||||||
|
if (!('bulkDelete' in channel)) {
|
||||||
|
throw new Error("This channel type does not support bulk deletion");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by user if specified
|
||||||
|
let messagesToDelete = messages;
|
||||||
|
if (userId) {
|
||||||
|
messagesToDelete = messages.filter(msg => msg.author.id === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messagesToDelete.size === 0) {
|
||||||
|
return { deleted: 0, skipped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// bulkDelete with filterOld=true will automatically skip messages >14 days
|
||||||
|
const deleted = await channel.bulkDelete(messagesToDelete, true);
|
||||||
|
const skipped = messagesToDelete.size - deleted.size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted: deleted.size,
|
||||||
|
skipped
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during bulk delete:", error);
|
||||||
|
throw new Error("Failed to delete messages");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to delay execution
|
||||||
|
*/
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pruneService = {
|
||||||
/**
|
/**
|
||||||
* Delete messages from a channel based on provided options
|
* Delete messages from a channel based on provided options
|
||||||
*/
|
*/
|
||||||
static async deleteMessages(
|
async deleteMessages(
|
||||||
channel: TextBasedChannel,
|
channel: TextBasedChannel,
|
||||||
options: PruneOptions,
|
options: PruneOptions,
|
||||||
progressCallback?: (progress: PruneProgress) => Promise<void>
|
progressCallback?: (progress: PruneProgress) => Promise<void>
|
||||||
@@ -38,11 +100,11 @@ export class PruneService {
|
|||||||
requestedCount = estimatedTotal;
|
requestedCount = estimatedTotal;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const messages = await this.fetchMessages(channel, batchSize, lastMessageId);
|
const messages = await fetchMessages(channel, batchSize, lastMessageId);
|
||||||
|
|
||||||
if (messages.size === 0) break;
|
if (messages.size === 0) break;
|
||||||
|
|
||||||
const { deleted, skipped } = await this.processBatch(
|
const { deleted, skipped } = await processBatch(
|
||||||
channel,
|
channel,
|
||||||
messages,
|
messages,
|
||||||
userId
|
userId
|
||||||
@@ -70,15 +132,15 @@ export class PruneService {
|
|||||||
|
|
||||||
// Delay to avoid rate limits
|
// Delay to avoid rate limits
|
||||||
if (messages.size >= batchSize) {
|
if (messages.size >= batchSize) {
|
||||||
await this.delay(batchDelay);
|
await delay(batchDelay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Delete specific amount
|
// Delete specific amount
|
||||||
const limit = Math.min(amount || 10, config.moderation.prune.maxAmount);
|
const limit = Math.min(amount || 10, config.moderation.prune.maxAmount);
|
||||||
const messages = await this.fetchMessages(channel, limit, undefined);
|
const messages = await fetchMessages(channel, limit, undefined);
|
||||||
|
|
||||||
const { deleted, skipped } = await this.processBatch(
|
const { deleted, skipped } = await processBatch(
|
||||||
channel,
|
channel,
|
||||||
messages,
|
messages,
|
||||||
userId
|
userId
|
||||||
@@ -106,67 +168,12 @@ export class PruneService {
|
|||||||
username,
|
username,
|
||||||
skippedOld: totalSkipped > 0 ? totalSkipped : undefined
|
skippedOld: totalSkipped > 0 ? totalSkipped : undefined
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch messages from a channel
|
|
||||||
*/
|
|
||||||
private static async fetchMessages(
|
|
||||||
channel: TextBasedChannel,
|
|
||||||
limit: number,
|
|
||||||
before?: string
|
|
||||||
): Promise<Collection<string, Message>> {
|
|
||||||
if (!('messages' in channel)) {
|
|
||||||
return new Collection();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await channel.messages.fetch({
|
|
||||||
limit,
|
|
||||||
before
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a batch of messages for deletion
|
|
||||||
*/
|
|
||||||
private static async processBatch(
|
|
||||||
channel: TextBasedChannel,
|
|
||||||
messages: Collection<string, Message>,
|
|
||||||
userId?: string
|
|
||||||
): Promise<{ deleted: number; skipped: number }> {
|
|
||||||
if (!('bulkDelete' in channel)) {
|
|
||||||
throw new Error("This channel type does not support bulk deletion");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by user if specified
|
|
||||||
let messagesToDelete = messages;
|
|
||||||
if (userId) {
|
|
||||||
messagesToDelete = messages.filter(msg => msg.author.id === userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messagesToDelete.size === 0) {
|
|
||||||
return { deleted: 0, skipped: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// bulkDelete with filterOld=true will automatically skip messages >14 days
|
|
||||||
const deleted = await channel.bulkDelete(messagesToDelete, true);
|
|
||||||
const skipped = messagesToDelete.size - deleted.size;
|
|
||||||
|
|
||||||
return {
|
|
||||||
deleted: deleted.size,
|
|
||||||
skipped
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error during bulk delete:", error);
|
|
||||||
throw new Error("Failed to delete messages");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estimate the total number of messages in a channel
|
* Estimate the total number of messages in a channel
|
||||||
*/
|
*/
|
||||||
static async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
|
async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
|
||||||
if (!('messages' in channel)) {
|
if (!('messages' in channel)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -187,12 +194,5 @@ export class PruneService {
|
|||||||
} catch {
|
} catch {
|
||||||
return 100; // Default estimate
|
return 100; // Default estimate
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
};
|
||||||
/**
|
|
||||||
* Helper to delay execution
|
|
||||||
*/
|
|
||||||
private static delay(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ModerationService } = await import("@shared/modules/moderation/moderation.service");
|
const { moderationService } = await import("@shared/modules/moderation/moderation.service");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route GET /api/moderation
|
* @route GET /api/moderation
|
||||||
@@ -78,7 +78,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||||
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||||
|
|
||||||
const cases = await ModerationService.searchCases(filter);
|
const cases = await moderationService.searchCases(filter);
|
||||||
return jsonResponse({ cases });
|
return jsonResponse({ cases });
|
||||||
}, "fetch moderation cases");
|
}, "fetch moderation cases");
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const caseId = pathname.split("/").pop()!.toUpperCase();
|
const caseId = pathname.split("/").pop()!.toUpperCase();
|
||||||
|
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
const moderationCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
if (!moderationCase) {
|
if (!moderationCase) {
|
||||||
return errorResponse("Case not found", 404);
|
return errorResponse("Case not found", 404);
|
||||||
@@ -148,7 +148,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newCase = await ModerationService.createCase({
|
const newCase = await moderationService.createCase({
|
||||||
type: data.type,
|
type: data.type,
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
@@ -193,7 +193,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
|
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedCase = await ModerationService.clearCase({
|
const updatedCase = await moderationService.clearCase({
|
||||||
caseId,
|
caseId,
|
||||||
clearedBy: data.clearedBy,
|
clearedBy: data.clearedBy,
|
||||||
clearedByName: data.clearedByName,
|
clearedByName: data.clearedByName,
|
||||||
|
|||||||
Reference in New Issue
Block a user