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,6 +1,6 @@
|
||||
|
||||
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 { CaseType } from "@shared/lib/constants";
|
||||
|
||||
@@ -43,7 +43,7 @@ mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
describe("ModerationService", () => {
|
||||
describe("moderationService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockFindMany.mockReset();
|
||||
@@ -73,7 +73,7 @@ describe("ModerationService", () => {
|
||||
|
||||
const mockDmTarget = { send: mock() };
|
||||
|
||||
const result = await ModerationService.issueWarning({
|
||||
const result = await moderationService.issueWarning({
|
||||
...defaultOptions,
|
||||
dmTarget: mockDmTarget
|
||||
});
|
||||
@@ -91,7 +91,7 @@ describe("ModerationService", () => {
|
||||
|
||||
const mockDmTarget = { send: mock() };
|
||||
|
||||
await ModerationService.issueWarning({
|
||||
await moderationService.issueWarning({
|
||||
...defaultOptions,
|
||||
dmTarget: mockDmTarget,
|
||||
config: { dmOnWarn: false }
|
||||
@@ -108,7 +108,7 @@ describe("ModerationService", () => {
|
||||
|
||||
const mockTimeoutTarget = { timeout: mock() };
|
||||
|
||||
const result = await ModerationService.issueWarning({
|
||||
const result = await moderationService.issueWarning({
|
||||
...defaultOptions,
|
||||
timeoutTarget: mockTimeoutTarget,
|
||||
config: { autoTimeoutThreshold: 3 }
|
||||
@@ -128,7 +128,7 @@ describe("ModerationService", () => {
|
||||
|
||||
const mockTimeoutTarget = { timeout: mock() };
|
||||
|
||||
const result = await ModerationService.issueWarning({
|
||||
const result = await moderationService.issueWarning({
|
||||
...defaultOptions,
|
||||
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", () => {
|
||||
it("should create a new moderation case with correct values", async () => {
|
||||
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
|
||||
@@ -176,7 +155,7 @@ describe("ModerationService", () => {
|
||||
};
|
||||
mockReturning.mockResolvedValue([mockNewCase]);
|
||||
|
||||
const result = await ModerationService.createCase({
|
||||
const result = await moderationService.createCase({
|
||||
type: CaseType.WARN,
|
||||
userId: "123456789",
|
||||
username: "testuser",
|
||||
@@ -199,7 +178,7 @@ describe("ModerationService", () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
mockReturning.mockImplementation((values) => [values]); // Simplified mock
|
||||
|
||||
const result = await ModerationService.createCase({
|
||||
const result = await moderationService.createCase({
|
||||
type: CaseType.BAN,
|
||||
userId: "123456789",
|
||||
username: "testuser",
|
||||
@@ -219,7 +198,7 @@ describe("ModerationService", () => {
|
||||
const mockCase = { caseId: "CASE-0001", reason: "test" };
|
||||
mockFindFirst.mockResolvedValue(mockCase);
|
||||
|
||||
const result = await ModerationService.getCaseById("CASE-0001");
|
||||
const result = await moderationService.getCaseById("CASE-0001");
|
||||
expect(result).toEqual(mockCase as any);
|
||||
});
|
||||
});
|
||||
@@ -229,7 +208,7 @@ describe("ModerationService", () => {
|
||||
const mockCases = [{ caseId: "CASE-0001" }, { caseId: "CASE-0002" }];
|
||||
mockFindMany.mockResolvedValue(mockCases);
|
||||
|
||||
const result = await ModerationService.getUserCases("123456789");
|
||||
const result = await moderationService.getUserCases("123456789");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockFindMany).toHaveBeenCalled();
|
||||
});
|
||||
@@ -240,7 +219,7 @@ describe("ModerationService", () => {
|
||||
const mockUpdatedCase = { caseId: "CASE-0001", active: false };
|
||||
mockReturning.mockResolvedValue([mockUpdatedCase]);
|
||||
|
||||
const result = await ModerationService.clearCase({
|
||||
const result = await moderationService.clearCase({
|
||||
caseId: "CASE-0001",
|
||||
clearedBy: "987654321",
|
||||
clearedByName: "mod",
|
||||
@@ -264,13 +243,13 @@ describe("ModerationService", () => {
|
||||
{ id: 2n, type: CaseType.WARN, active: true }
|
||||
]);
|
||||
|
||||
const count = await ModerationService.getActiveWarningCount("123456789");
|
||||
const count = await moderationService.getActiveWarningCount("123456789");
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("should return 0 if no active warnings", async () => {
|
||||
mockFindMany.mockResolvedValue([]);
|
||||
const count = await ModerationService.getActiveWarningCount("123456789");
|
||||
const count = await moderationService.getActiveWarningCount("123456789");
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,34 +10,56 @@ export interface ModerationCaseConfig {
|
||||
autoTimeoutThreshold?: number;
|
||||
}
|
||||
|
||||
export class ModerationService {
|
||||
/**
|
||||
* Generate the next sequential case ID
|
||||
*/
|
||||
private static async getNextCaseId(): Promise<string> {
|
||||
const latestCase = await DrizzleClient.query.moderationCases.findFirst({
|
||||
orderBy: [desc(moderationCases.id)],
|
||||
});
|
||||
/**
|
||||
* Generate the next sequential case ID
|
||||
*/
|
||||
async function getNextCaseId(): Promise<string> {
|
||||
const latestCase = await DrizzleClient.query.moderationCases.findFirst({
|
||||
orderBy: [desc(moderationCases.id)],
|
||||
});
|
||||
|
||||
if (!latestCase) {
|
||||
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')}`;
|
||||
if (!latestCase) {
|
||||
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')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
static async createCase(options: CreateCaseOptions) {
|
||||
const caseId = await this.getNextCaseId();
|
||||
async createCase(options: CreateCaseOptions) {
|
||||
const caseId = await getNextCaseId();
|
||||
|
||||
const [newCase] = await DrizzleClient.insert(moderationCases).values({
|
||||
caseId,
|
||||
@@ -52,12 +74,12 @@ export class ModerationService {
|
||||
}).returning();
|
||||
|
||||
return newCase;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Issue a warning with DM and threshold logic
|
||||
*/
|
||||
static async issueWarning(options: {
|
||||
async issueWarning(options: {
|
||||
userId: string;
|
||||
username: string;
|
||||
moderatorId: string;
|
||||
@@ -81,7 +103,7 @@ export class ModerationService {
|
||||
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 ?? {};
|
||||
|
||||
// Try to DM the user if configured
|
||||
@@ -127,21 +149,21 @@ export class ModerationService {
|
||||
}
|
||||
|
||||
return { moderationCase, warningCount, autoTimeoutIssued };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a case by its case ID
|
||||
*/
|
||||
static async getCaseById(caseId: string) {
|
||||
async getCaseById(caseId: string) {
|
||||
return await DrizzleClient.query.moderationCases.findFirst({
|
||||
where: eq(moderationCases.caseId, caseId),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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))];
|
||||
|
||||
if (activeOnly) {
|
||||
@@ -152,26 +174,19 @@ export class ModerationService {
|
||||
where: and(...conditions),
|
||||
orderBy: [desc(moderationCases.createdAt)],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get active warnings for a user
|
||||
* Get active warnings for a user (public alias)
|
||||
*/
|
||||
static async 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)],
|
||||
});
|
||||
}
|
||||
async getUserWarnings(userId: string) {
|
||||
return await getUserWarnings(userId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all notes for a user
|
||||
*/
|
||||
static async getUserNotes(userId: string) {
|
||||
async getUserNotes(userId: string) {
|
||||
return await DrizzleClient.query.moderationCases.findMany({
|
||||
where: and(
|
||||
eq(moderationCases.userId, BigInt(userId)),
|
||||
@@ -179,12 +194,12 @@ export class ModerationService {
|
||||
),
|
||||
orderBy: [desc(moderationCases.createdAt)],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear/resolve a warning
|
||||
*/
|
||||
static async clearCase(options: ClearCaseOptions) {
|
||||
async clearCase(options: ClearCaseOptions) {
|
||||
const [updatedCase] = await DrizzleClient.update(moderationCases)
|
||||
.set({
|
||||
active: false,
|
||||
@@ -196,12 +211,12 @@ export class ModerationService {
|
||||
.returning();
|
||||
|
||||
return updatedCase;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Search cases with various filters
|
||||
*/
|
||||
static async searchCases(filter: SearchCasesFilter) {
|
||||
async searchCases(filter: SearchCasesFilter) {
|
||||
const conditions = [];
|
||||
|
||||
if (filter.userId) {
|
||||
@@ -228,13 +243,12 @@ export class ModerationService {
|
||||
limit: filter.limit || 50,
|
||||
offset: filter.offset || 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get total count of active warnings for a user (useful for auto-timeout)
|
||||
*/
|
||||
static async getActiveWarningCount(userId: string): Promise<number> {
|
||||
const warnings = await this.getUserWarnings(userId);
|
||||
return warnings.length;
|
||||
}
|
||||
}
|
||||
async getActiveWarningCount(userId: string): Promise<number> {
|
||||
return await getActiveWarningCount(userId);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,11 +3,73 @@ import type { TextBasedChannel } from "discord.js";
|
||||
import type { PruneOptions, PruneResult, PruneProgress } from "@/modules/moderation/prune.types";
|
||||
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
|
||||
*/
|
||||
static async deleteMessages(
|
||||
async deleteMessages(
|
||||
channel: TextBasedChannel,
|
||||
options: PruneOptions,
|
||||
progressCallback?: (progress: PruneProgress) => Promise<void>
|
||||
@@ -38,11 +100,11 @@ export class PruneService {
|
||||
requestedCount = estimatedTotal;
|
||||
|
||||
while (true) {
|
||||
const messages = await this.fetchMessages(channel, batchSize, lastMessageId);
|
||||
const messages = await fetchMessages(channel, batchSize, lastMessageId);
|
||||
|
||||
if (messages.size === 0) break;
|
||||
|
||||
const { deleted, skipped } = await this.processBatch(
|
||||
const { deleted, skipped } = await processBatch(
|
||||
channel,
|
||||
messages,
|
||||
userId
|
||||
@@ -70,15 +132,15 @@ export class PruneService {
|
||||
|
||||
// Delay to avoid rate limits
|
||||
if (messages.size >= batchSize) {
|
||||
await this.delay(batchDelay);
|
||||
await delay(batchDelay);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Delete specific amount
|
||||
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,
|
||||
messages,
|
||||
userId
|
||||
@@ -106,67 +168,12 @@ export class PruneService {
|
||||
username,
|
||||
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
|
||||
*/
|
||||
static async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
|
||||
async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
|
||||
if (!('messages' in channel)) {
|
||||
return 0;
|
||||
}
|
||||
@@ -187,12 +194,5 @@ export class PruneService {
|
||||
} catch {
|
||||
return 100; // Default estimate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to delay execution
|
||||
*/
|
||||
private static delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user