4 Commits

Author SHA1 Message Date
syntaxbullet
bf20c61190 chore: exclude tickets from being commited.
Some checks failed
Deploy to Production / test (push) Failing after 34s
2026-02-13 13:53:45 +01:00
syntaxbullet
099601ce6d 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)
2026-02-13 13:33:58 +01:00
syntaxbullet
55d2376ca1 refactor: convert LootdropService from class to singleton object pattern
- Move instance properties to module-level state (channelActivity, channelCooldowns)
- Convert constructor cleanup interval to module-level initialization
- Export state variables for testing
- Update tests to use direct state access instead of (service as any)
- Maintains same behavior while following project service pattern

Closes #4
2026-02-13 13:28:46 +01:00
syntaxbullet
6eb4a32a12 refactor: consolidate config types and remove file-based config
Tickets: #2, #3

- Remove duplicate type definitions from shared/lib/config.ts
- Import types from schema files (game-settings.ts, guild-settings.ts)
- Add GuildConfig interface to guild-settings.ts schema
- Rename ModerationConfig to ModerationCaseConfig in moderation.service.ts
- Delete shared/config/config.json and shared/scripts/migrate-config-to-db.ts
- Update settings API to use gameSettingsService exclusively
- Return DB format (strings) from API instead of runtime BigInts
- Fix moderation service tests to pass config as parameter

Breaking Changes:
- Removes legacy file-based configuration system
- API now returns database format with string values for BigInt fields
2026-02-13 13:24:02 +01:00
19 changed files with 476 additions and 773 deletions

2
.gitignore vendored
View File

@@ -46,5 +46,5 @@ 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
tickets/

View File

@@ -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({

View File

@@ -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}`

View File

@@ -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,

View File

@@ -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,

View File

@@ -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({

View File

@@ -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,

View File

@@ -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,

View File

@@ -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({

View File

@@ -10,6 +10,26 @@ import { relations, type InferSelectModel, type InferInsertModel } from 'drizzle
export type GuildSettings = InferSelectModel<typeof guildSettings>; export type GuildSettings = InferSelectModel<typeof guildSettings>;
export type GuildSettingsInsert = InferInsertModel<typeof guildSettings>; export type GuildSettingsInsert = InferInsertModel<typeof guildSettings>;
export interface GuildConfig {
studentRole?: string;
visitorRole?: string;
colorRoles: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminal?: {
channelId: string;
messageId: string;
};
moderation: {
cases: {
dmOnWarn: boolean;
logChannelId?: string;
autoTimeoutThreshold?: number;
};
};
}
export const guildSettings = pgTable('guild_settings', { export const guildSettings = pgTable('guild_settings', {
guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(), guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(),
studentRoleId: bigint('student_role_id', { mode: 'bigint' }), studentRoleId: bigint('student_role_id', { mode: 'bigint' }),

View File

@@ -1,29 +1,12 @@
import { jsonReplacer } from './utils'; import type {
import { readFileSync, existsSync, writeFileSync } from 'node:fs'; LevelingConfig,
import { join } from 'node:path'; EconomyConfig as EconomyConfigDB,
import { z } from 'zod'; InventoryConfig as InventoryConfigDB,
LootdropConfig,
const configPath = join(import.meta.dir, '..', 'config', 'config.json'); TriviaConfig as TriviaConfigDB,
ModerationConfig
export interface GuildConfig { } from "@db/schema/game-settings";
studentRole?: string; import type { GuildConfig } from "@db/schema/guild-settings";
visitorRole?: string;
colorRoles: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminal?: {
channelId: string;
messageId: string;
};
moderation: {
cases: {
dmOnWarn: boolean;
logChannelId?: string;
autoTimeoutThreshold?: number;
};
};
}
const guildConfigCache = new Map<string, { config: GuildConfig; timestamp: number }>(); const guildConfigCache = new Map<string, { config: GuildConfig; timestamp: number }>();
const CACHE_TTL_MS = 60000; const CACHE_TTL_MS = 60000;
@@ -82,16 +65,10 @@ export function invalidateGuildConfigCache(guildId: string) {
guildConfigCache.delete(guildId); guildConfigCache.delete(guildId);
} }
export interface LevelingConfig { // Re-export DB types
base: number; export type { LevelingConfig, LootdropConfig, ModerationConfig };
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
};
}
// Runtime config types with BigInt for numeric fields
export interface EconomyConfig { export interface EconomyConfig {
daily: { daily: {
amount: bigint; amount: bigint;
@@ -114,18 +91,6 @@ export interface InventoryConfig {
maxSlots: number; maxSlots: number;
} }
export interface LootdropConfig {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
};
}
export interface TriviaConfig { export interface TriviaConfig {
entryFee: bigint; entryFee: bigint;
rewardMultiplier: number; rewardMultiplier: number;
@@ -135,20 +100,6 @@ export interface TriviaConfig {
difficulty: 'easy' | 'medium' | 'hard' | 'random'; difficulty: 'easy' | 'medium' | 'hard' | 'random';
} }
export interface ModerationConfig {
prune: {
maxAmount: number;
confirmThreshold: number;
batchSize: number;
batchDelayMs: number;
};
cases: {
dmOnWarn: boolean;
logChannelId?: string;
autoTimeoutThreshold?: number;
};
}
export interface GameConfigType { export interface GameConfigType {
leveling: LevelingConfig; leveling: LevelingConfig;
economy: EconomyConfig; economy: EconomyConfig;
@@ -158,160 +109,11 @@ export interface GameConfigType {
trivia: TriviaConfig; trivia: TriviaConfig;
moderation: ModerationConfig; moderation: ModerationConfig;
system: Record<string, unknown>; system: Record<string, unknown>;
studentRole: string;
visitorRole: string;
colorRoles: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminal?: {
channelId: string;
messageId: string;
};
} }
export const config: GameConfigType = {} as GameConfigType; export const config: GameConfigType = {} as GameConfigType;
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()]) export const GameConfig = config;
.refine((val) => {
try {
BigInt(val);
return true;
} catch {
return false;
}
}, { message: "Must be a valid integer" })
.transform((val) => BigInt(val));
const fileConfigSchema = z.object({
leveling: z.object({
base: z.number(),
exponent: z.number(),
chat: z.object({
cooldownMs: z.number(),
minXp: z.number(),
maxXp: z.number(),
})
}),
economy: z.object({
daily: z.object({
amount: bigIntSchema,
streakBonus: bigIntSchema,
weeklyBonus: bigIntSchema.default(50n),
cooldownMs: z.number(),
}),
transfers: z.object({
allowSelfTransfer: z.boolean(),
minAmount: bigIntSchema,
}),
exam: z.object({
multMin: z.number(),
multMax: z.number(),
})
}),
inventory: z.object({
maxStackSize: bigIntSchema,
maxSlots: z.number(),
}),
commands: z.record(z.string(), z.boolean()),
lootdrop: z.object({
activityWindowMs: z.number(),
minMessages: z.number(),
spawnChance: z.number(),
cooldownMs: z.number(),
reward: z.object({
min: z.number(),
max: z.number(),
currency: z.string(),
})
}),
studentRole: z.string(),
visitorRole: z.string(),
colorRoles: z.array(z.string()).default([]),
welcomeChannelId: z.string().optional(),
welcomeMessage: z.string().optional(),
feedbackChannelId: z.string().optional(),
terminal: z.object({
channelId: z.string(),
messageId: z.string()
}).optional(),
moderation: z.object({
prune: z.object({
maxAmount: z.number().default(100),
confirmThreshold: z.number().default(50),
batchSize: z.number().default(100),
batchDelayMs: z.number().default(1000)
}),
cases: z.object({
dmOnWarn: z.boolean().default(true),
logChannelId: z.string().optional(),
autoTimeoutThreshold: z.number().optional()
})
}).default({
prune: {
maxAmount: 100,
confirmThreshold: 50,
batchSize: 100,
batchDelayMs: 1000
},
cases: {
dmOnWarn: true
}
}),
trivia: z.object({
entryFee: bigIntSchema,
rewardMultiplier: z.number().min(0).max(10),
timeoutSeconds: z.number().min(5).max(300),
cooldownMs: z.number().min(0),
categories: z.array(z.number()).default([]),
difficulty: z.enum(['easy', 'medium', 'hard', 'random']).default('random'),
}).default({
entryFee: 50n,
rewardMultiplier: 1.8,
timeoutSeconds: 30,
cooldownMs: 60000,
categories: [],
difficulty: 'random'
}),
system: z.record(z.string(), z.any()).default({}),
});
type FileConfig = z.infer<typeof fileConfigSchema>;
function loadFromFile(): FileConfig | null {
if (!existsSync(configPath)) {
return null;
}
try {
const raw = readFileSync(configPath, 'utf-8');
const rawConfig = JSON.parse(raw);
return fileConfigSchema.parse(rawConfig);
} catch (error) {
console.error("Failed to load config from file:", error);
return null;
}
}
function applyFileConfig(fileConfig: FileConfig) {
Object.assign(config, {
leveling: fileConfig.leveling,
economy: fileConfig.economy,
inventory: fileConfig.inventory,
commands: fileConfig.commands,
lootdrop: fileConfig.lootdrop,
trivia: fileConfig.trivia,
moderation: fileConfig.moderation,
system: fileConfig.system,
studentRole: fileConfig.studentRole,
visitorRole: fileConfig.visitorRole,
colorRoles: fileConfig.colorRoles,
welcomeChannelId: fileConfig.welcomeChannelId,
welcomeMessage: fileConfig.welcomeMessage,
feedbackChannelId: fileConfig.feedbackChannelId,
terminal: fileConfig.terminal,
});
}
async function loadFromDatabase(): Promise<boolean> { async function loadFromDatabase(): Promise<boolean> {
try { try {
@@ -358,88 +160,48 @@ async function loadFromDatabase(): Promise<boolean> {
return false; return false;
} }
async function loadDefaults(): Promise<void> {
console.warn("⚠️ No game config found in database. Using defaults.");
const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service');
const defaults = gameSettingsService.getDefaults();
Object.assign(config, {
leveling: defaults.leveling,
economy: {
...defaults.economy,
daily: {
...defaults.economy.daily,
amount: BigInt(defaults.economy.daily.amount),
streakBonus: BigInt(defaults.economy.daily.streakBonus),
weeklyBonus: BigInt(defaults.economy.daily.weeklyBonus),
},
transfers: {
...defaults.economy.transfers,
minAmount: BigInt(defaults.economy.transfers.minAmount),
},
},
inventory: {
...defaults.inventory,
maxStackSize: BigInt(defaults.inventory.maxStackSize),
},
commands: defaults.commands,
lootdrop: defaults.lootdrop,
trivia: {
...defaults.trivia,
entryFee: BigInt(defaults.trivia.entryFee),
},
moderation: defaults.moderation,
system: defaults.system,
});
}
export async function reloadConfig(): Promise<void> { export async function reloadConfig(): Promise<void> {
const dbLoaded = await loadFromDatabase(); const dbLoaded = await loadFromDatabase();
if (!dbLoaded) { if (!dbLoaded) {
const fileConfig = loadFromFile(); await loadDefaults();
if (fileConfig) {
applyFileConfig(fileConfig);
console.log("📄 Game config loaded from file (database not available).");
} else {
console.warn("⚠️ No game config found in database or file. Using defaults.");
const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service');
const defaults = gameSettingsService.getDefaults();
Object.assign(config, {
leveling: defaults.leveling,
economy: {
...defaults.economy,
daily: {
...defaults.economy.daily,
amount: BigInt(defaults.economy.daily.amount),
streakBonus: BigInt(defaults.economy.daily.streakBonus),
weeklyBonus: BigInt(defaults.economy.daily.weeklyBonus),
},
transfers: {
...defaults.economy.transfers,
minAmount: BigInt(defaults.economy.transfers.minAmount),
},
},
inventory: {
...defaults.inventory,
maxStackSize: BigInt(defaults.inventory.maxStackSize),
},
commands: defaults.commands,
lootdrop: defaults.lootdrop,
trivia: {
...defaults.trivia,
entryFee: BigInt(defaults.trivia.entryFee),
},
moderation: defaults.moderation,
system: defaults.system,
});
}
} }
} }
export function loadFileSync(): void {
const fileConfig = loadFromFile();
if (fileConfig) {
applyFileConfig(fileConfig);
console.log("📄 Game config loaded from file (sync).");
}
}
export const GameConfig = config;
export function saveConfig(newConfig: unknown) {
const validatedConfig = fileConfigSchema.parse(newConfig);
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
writeFileSync(configPath, jsonString, 'utf-8');
applyFileConfig(validatedConfig);
console.log("🔄 Config saved to file.");
}
export function toggleCommand(commandName: string, enabled: boolean) {
const fileConfig = loadFromFile();
if (!fileConfig) {
console.error("Cannot toggle command: no file config available");
return;
}
const newConfig = {
...fileConfig,
commands: {
...fileConfig.commands,
[commandName]: enabled
}
};
saveConfig(newConfig);
}
export async function initializeConfig(): Promise<void> { export async function initializeConfig(): Promise<void> {
loadFileSync();
await reloadConfig(); await reloadConfig();
} }
loadFileSync();

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"; import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { lootdropService } from "@shared/modules/economy/lootdrop.service"; import { lootdropService, channelActivity, channelCooldowns } from "@shared/modules/economy/lootdrop.service";
import { lootdrops } from "@db/schema"; import { lootdrops } from "@db/schema";
import { economyService } from "@shared/modules/economy/economy.service"; import { economyService } from "@shared/modules/economy/economy.service";
@@ -67,8 +67,8 @@ describe("lootdropService", () => {
mockFrom.mockClear(); mockFrom.mockClear();
// Reset internal state // Reset internal state
(lootdropService as any).channelActivity = new Map(); channelActivity.clear();
(lootdropService as any).channelCooldowns = new Map(); channelCooldowns.clear();
// Mock Math.random // Mock Math.random
originalRandom = Math.random; originalRandom = Math.random;

View File

@@ -7,234 +7,233 @@ import { lootdrops } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { eq, and, isNull, lt } from "drizzle-orm"; import { eq, and, isNull, lt } from "drizzle-orm";
interface Lootdrop { // Module-level state (exported for testing)
messageId: string; export const channelActivity: Map<string, number[]> = new Map();
channelId: string; export const channelCooldowns: Map<string, number> = new Map();
rewardAmount: number;
currency: string; // Private helper function
claimedBy?: string; function cleanupActivity() {
createdAt: Date; const now = Date.now();
const window = config.lootdrop.activityWindowMs;
for (const [channelId, timestamps] of channelActivity.entries()) {
const validTimestamps = timestamps.filter(t => now - t < window);
if (validTimestamps.length === 0) {
channelActivity.delete(channelId);
} else {
channelActivity.set(channelId, validTimestamps);
}
}
} }
class LootdropService { // Initialize cleanup interval on module load
private channelActivity: Map<string, number[]> = new Map(); setInterval(() => {
private channelCooldowns: Map<string, number> = new Map(); cleanupActivity();
cleanupExpiredLootdrops(true);
}, 60000);
constructor() { async function cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
// Cleanup interval for activity tracking and expired lootdrops try {
setInterval(() => { const now = new Date();
this.cleanupActivity(); const whereClause = includeClaimed
this.cleanupExpiredLootdrops(true); ? lt(lootdrops.expiresAt, now)
}, 60000); : and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
const result = await DrizzleClient.delete(lootdrops)
.where(whereClause)
.returning();
if (result.length > 0) {
console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`);
}
return result.length;
} catch (error) {
console.error("Failed to cleanup lootdrops:", error);
return 0;
} }
}
private cleanupActivity() { async function processMessage(message: Message) {
const now = Date.now(); if (message.author.bot || !message.guild) return;
const window = config.lootdrop.activityWindowMs;
for (const [channelId, timestamps] of this.channelActivity.entries()) { const channelId = message.channel.id;
const validTimestamps = timestamps.filter(t => now - t < window); const now = Date.now();
if (validTimestamps.length === 0) {
this.channelActivity.delete(channelId); // Check cooldown
} else { const cooldown = channelCooldowns.get(channelId);
this.channelActivity.set(channelId, validTimestamps); if (cooldown && now < cooldown) return;
}
// Track activity
const timestamps = channelActivity.get(channelId) || [];
timestamps.push(now);
channelActivity.set(channelId, timestamps);
// Filter for window
const window = config.lootdrop.activityWindowMs;
const recentActivity = timestamps.filter(t => now - t < window);
if (recentActivity.length >= config.lootdrop.minMessages) {
// Chance to spawn
if (Math.random() < config.lootdrop.spawnChance) {
await spawnLootdrop(message.channel as TextChannel);
// Set cooldown
channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
channelActivity.set(channelId, []);
} }
} }
}
public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> { async function spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) {
try { const min = config.lootdrop.reward.min;
const now = new Date(); const max = config.lootdrop.reward.max;
const whereClause = includeClaimed const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min);
? lt(lootdrops.expiresAt, now) const currency = overrideCurrency ?? config.lootdrop.reward.currency;
: and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
const result = await DrizzleClient.delete(lootdrops) const { content, files, components } = await getLootdropMessage(reward, currency);
.where(whereClause)
.returning();
if (result.length > 0) { try {
console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`); const message = await channel.send({ content, files, components });
}
return result.length; // Persist to DB
} catch (error) { await DrizzleClient.insert(lootdrops).values({
console.error("Failed to cleanup lootdrops:", error); messageId: message.id,
return 0; channelId: channel.id,
} rewardAmount: reward,
currency: currency,
createdAt: new Date(),
// Expire after 10 mins
expiresAt: new Date(Date.now() + 600000)
});
// Trigger Terminal Update
terminalService.update(channel.guildId);
} catch (error) {
console.error("Failed to spawn lootdrop:", error);
} }
}
public async processMessage(message: Message) { async function tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> {
if (message.author.bot || !message.guild) return; try {
// Atomic update: Try to set claimedBy where it is currently null
// This acts as a lock and check in one query
const result = await DrizzleClient.update(lootdrops)
.set({ claimedBy: BigInt(userId) })
.where(and(
eq(lootdrops.messageId, messageId),
isNull(lootdrops.claimedBy)
))
.returning();
const channelId = message.channel.id; if (result.length === 0 || !result[0]) {
const now = Date.now(); // If update affected 0 rows, check if it was because it doesn't exist or is already claimed
const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId));
if (check.length === 0) {
return { success: false, error: "This lootdrop has expired." };
}
return { success: false, error: "This lootdrop has already been claimed." };
}
const drop = result[0];
await economyService.modifyUserBalance(
userId,
BigInt(drop.rewardAmount),
"LOOTDROP_CLAIM",
`Claimed lootdrop in channel ${drop.channelId}`
);
// Trigger Terminal Update (uses primary guild from env)
terminalService.update();
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
} catch (error) {
console.error("Error claiming lootdrop:", error);
return { success: false, error: "An error occurred while processing the reward." };
}
}
function getLootdropState() {
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
let maxMessages = -1;
const window = config.lootdrop.activityWindowMs;
const now = Date.now();
const required = config.lootdrop.minMessages;
for (const [channelId, timestamps] of channelActivity.entries()) {
// Filter valid just to be sure we are reporting accurate numbers
const validCount = timestamps.filter(t => now - t < window).length;
// Check cooldown // Check cooldown
const cooldown = this.channelCooldowns.get(channelId); const cooldownUntil = channelCooldowns.get(channelId);
if (cooldown && now < cooldown) return; const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
// Track activity if (validCount > maxMessages) {
const timestamps = this.channelActivity.get(channelId) || []; maxMessages = validCount;
timestamps.push(now); hottestChannel = {
this.channelActivity.set(channelId, timestamps); id: channelId,
messages: validCount,
// Filter for window progress: Math.min(100, (validCount / required) * 100),
const window = config.lootdrop.activityWindowMs; cooldown: isOnCooldown
const recentActivity = timestamps.filter(t => now - t < window); };
if (recentActivity.length >= config.lootdrop.minMessages) {
// Chance to spawn
if (Math.random() < config.lootdrop.spawnChance) {
await this.spawnLootdrop(message.channel as TextChannel);
// Set cooldown
this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
this.channelActivity.set(channelId, []);
}
} }
} }
public async spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) { return {
const min = config.lootdrop.reward.min; monitoredChannels: channelActivity.size,
const max = config.lootdrop.reward.max; hottestChannel,
const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min); config: {
const currency = overrideCurrency ?? config.lootdrop.reward.currency; requiredMessages: required,
dropChance: config.lootdrop.spawnChance
}
};
}
const { content, files, components } = await getLootdropMessage(reward, currency); async function clearCaches() {
channelActivity.clear();
channelCooldowns.clear();
console.log("[LootdropService] Caches cleared via administrative action.");
}
async function deleteLootdrop(messageId: string): Promise<boolean> {
try {
// First fetch it to get channel info so we can delete the message
const drop = await DrizzleClient.query.lootdrops.findFirst({
where: eq(lootdrops.messageId, messageId)
});
if (!drop) return false;
// Delete from DB
await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId));
// Try to delete from Discord
try { try {
const message = await channel.send({ content, files, components }); const { AuroraClient } = await import("../../../bot/lib/BotClient");
const channel = await AuroraClient.channels.fetch(drop.channelId) as TextChannel;
// Persist to DB if (channel) {
await DrizzleClient.insert(lootdrops).values({ const message = await channel.messages.fetch(messageId);
messageId: message.id, if (message) await message.delete();
channelId: channel.id,
rewardAmount: reward,
currency: currency,
createdAt: new Date(),
// Expire after 10 mins
expiresAt: new Date(Date.now() + 600000)
});
// Trigger Terminal Update
terminalService.update(channel.guildId);
} catch (error) {
console.error("Failed to spawn lootdrop:", error);
}
}
public async tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> {
try {
// Atomic update: Try to set claimedBy where it is currently null
// This acts as a lock and check in one query
const result = await DrizzleClient.update(lootdrops)
.set({ claimedBy: BigInt(userId) })
.where(and(
eq(lootdrops.messageId, messageId),
isNull(lootdrops.claimedBy)
))
.returning();
if (result.length === 0 || !result[0]) {
// If update affected 0 rows, check if it was because it doesn't exist or is already claimed
const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId));
if (check.length === 0) {
return { success: false, error: "This lootdrop has expired." };
}
return { success: false, error: "This lootdrop has already been claimed." };
}
const drop = result[0];
await economyService.modifyUserBalance(
userId,
BigInt(drop.rewardAmount),
"LOOTDROP_CLAIM",
`Claimed lootdrop in channel ${drop.channelId}`
);
// Trigger Terminal Update (uses primary guild from env)
terminalService.update();
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
} catch (error) {
console.error("Error claiming lootdrop:", error);
return { success: false, error: "An error occurred while processing the reward." };
}
}
public getLootdropState() {
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
let maxMessages = -1;
const window = config.lootdrop.activityWindowMs;
const now = Date.now();
const required = config.lootdrop.minMessages;
for (const [channelId, timestamps] of this.channelActivity.entries()) {
// Filter valid just to be sure we are reporting accurate numbers
const validCount = timestamps.filter(t => now - t < window).length;
// Check cooldown
const cooldownUntil = this.channelCooldowns.get(channelId);
const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
if (validCount > maxMessages) {
maxMessages = validCount;
hottestChannel = {
id: channelId,
messages: validCount,
progress: Math.min(100, (validCount / required) * 100),
cooldown: isOnCooldown
};
} }
} catch (e) {
console.warn("Could not delete lootdrop message from Discord:", e);
} }
return { return true;
monitoredChannels: this.channelActivity.size, } catch (error) {
hottestChannel, console.error("Error deleting lootdrop:", error);
config: { return false;
requiredMessages: required,
dropChance: config.lootdrop.spawnChance
}
};
}
public async clearCaches() {
this.channelActivity.clear();
this.channelCooldowns.clear();
console.log("[LootdropService] Caches cleared via administrative action.");
}
public async deleteLootdrop(messageId: string): Promise<boolean> {
try {
// First fetch it to get channel info so we can delete the message
const drop = await DrizzleClient.query.lootdrops.findFirst({
where: eq(lootdrops.messageId, messageId)
});
if (!drop) return false;
// Delete from DB
await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId));
// Try to delete from Discord
try {
const { AuroraClient } = await import("../../../bot/lib/BotClient");
const channel = await AuroraClient.channels.fetch(drop.channelId) as TextChannel;
if (channel) {
const message = await channel.messages.fetch(messageId);
if (message) await message.delete();
}
} catch (e) {
console.warn("Could not delete lootdrop message from Discord:", e);
}
return true;
} catch (error) {
console.error("Error deleting lootdrop:", error);
return false;
}
} }
} }
export const lootdropService = new LootdropService(); export const lootdropService = {
cleanupExpiredLootdrops,
processMessage,
spawnLootdrop,
tryClaim,
getLootdropState,
clearCaches,
deleteLootdrop,
};

View File

@@ -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";
@@ -14,19 +14,7 @@ const mockReturning = mock();
const mockSet = mock(); const mockSet = mock();
const mockWhere = mock(); const mockWhere = mock();
// Mock Config
const mockConfig = {
moderation: {
cases: {
dmOnWarn: true,
autoTimeoutThreshold: 3
}
}
};
mock.module("@shared/lib/config", () => ({
config: mockConfig
}));
// Mock View // Mock View
const mockGetUserWarningEmbed = mock(() => ({})); const mockGetUserWarningEmbed = mock(() => ({}));
@@ -55,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();
@@ -66,9 +54,6 @@ describe("ModerationService", () => {
mockSet.mockClear(); mockSet.mockClear();
mockWhere.mockClear(); mockWhere.mockClear();
mockGetUserWarningEmbed.mockClear(); mockGetUserWarningEmbed.mockClear();
// Reset config to defaults
mockConfig.moderation.cases.dmOnWarn = true;
mockConfig.moderation.cases.autoTimeoutThreshold = 3;
}); });
describe("issueWarning", () => { describe("issueWarning", () => {
@@ -88,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
}); });
@@ -100,16 +85,16 @@ describe("ModerationService", () => {
}); });
it("should not DM if dmOnWarn is false", async () => { it("should not DM if dmOnWarn is false", async () => {
mockConfig.moderation.cases.dmOnWarn = false;
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" }); mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]); mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
mockFindMany.mockResolvedValue([]); mockFindMany.mockResolvedValue([]);
const mockDmTarget = { send: mock() }; const mockDmTarget = { send: mock() };
await ModerationService.issueWarning({ await moderationService.issueWarning({
...defaultOptions, ...defaultOptions,
dmTarget: mockDmTarget dmTarget: mockDmTarget,
config: { dmOnWarn: false }
}); });
expect(mockDmTarget.send).not.toHaveBeenCalled(); expect(mockDmTarget.send).not.toHaveBeenCalled();
@@ -123,9 +108,10 @@ 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 }
}); });
expect(result.autoTimeoutIssued).toBe(true); expect(result.autoTimeoutIssued).toBe(true);
@@ -142,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
}); });
@@ -153,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" });
@@ -190,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",
@@ -213,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",
@@ -233,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);
}); });
}); });
@@ -243,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();
}); });
@@ -254,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",
@@ -278,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);
}); });
}); });

View File

@@ -5,39 +5,61 @@ import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "@/m
import { getUserWarningEmbed } from "@/modules/moderation/moderation.view"; import { getUserWarningEmbed } from "@/modules/moderation/moderation.view";
import { CaseType } from "@shared/lib/constants"; import { CaseType } from "@shared/lib/constants";
export interface ModerationConfig { export interface ModerationCaseConfig {
dmOnWarn?: boolean; dmOnWarn?: boolean;
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;
@@ -66,7 +88,7 @@ export class ModerationService {
guildName?: string; guildName?: string;
dmTarget?: { send: (options: any) => Promise<any> }; dmTarget?: { send: (options: any) => Promise<any> };
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> }; timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
config?: ModerationConfig; config?: ModerationCaseConfig;
}) { }) {
const moderationCase = await this.createCase({ const moderationCase = await this.createCase({
type: CaseType.WARN, type: CaseType.WARN,
@@ -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; },
} };
}

View File

@@ -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));
}
}

View File

@@ -1,51 +0,0 @@
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { config } from "@shared/lib/config";
import { env } from "@shared/lib/env";
async function migrateConfigToDatabase() {
const guildId = env.DISCORD_GUILD_ID;
if (!guildId) {
console.error("DISCORD_GUILD_ID not set. Cannot migrate config.");
console.log("Set DISCORD_GUILD_ID in your environment to migrate config.");
process.exit(1);
}
console.log(`Migrating config for guild ${guildId}...`);
const existing = await guildSettingsService.getSettings(guildId);
if (existing) {
console.log("Guild settings already exist in database:");
console.log(JSON.stringify(existing, null, 2));
console.log("\nSkipping migration. Delete existing settings first if you want to re-migrate.");
return;
}
await guildSettingsService.upsertSettings({
guildId,
studentRoleId: config.studentRole ?? undefined,
visitorRoleId: config.visitorRole ?? undefined,
colorRoleIds: config.colorRoles ?? [],
welcomeChannelId: config.welcomeChannelId ?? undefined,
welcomeMessage: config.welcomeMessage ?? undefined,
feedbackChannelId: config.feedbackChannelId ?? undefined,
terminalChannelId: config.terminal?.channelId ?? undefined,
terminalMessageId: config.terminal?.messageId ?? undefined,
moderationLogChannelId: config.moderation?.cases?.logChannelId ?? undefined,
moderationDmOnWarn: config.moderation?.cases?.dmOnWarn ?? true,
moderationAutoTimeoutThreshold: config.moderation?.cases?.autoTimeoutThreshold ?? undefined,
});
console.log("✅ Migration complete!");
console.log("\nGuild settings are now stored in the database.");
console.log("You can manage them via:");
console.log(" - /settings command in Discord");
console.log(" - API endpoints at /api/guilds/:guildId/settings");
}
migrateConfigToDatabase()
.then(() => process.exit(0))
.catch((error) => {
console.error("Migration failed:", error);
process.exit(1);
});

View File

@@ -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,

View File

@@ -7,13 +7,6 @@
import type { RouteContext, RouteModule } from "./types"; import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, withErrorHandling } from "./utils"; import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
/**
* JSON replacer for BigInt serialization.
*/
function jsonReplacer(_key: string, value: unknown): unknown {
return typeof value === "bigint" ? value.toString() : value;
}
/** /**
* Settings routes handler. * Settings routes handler.
* *
@@ -32,26 +25,31 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
/** /**
* @route GET /api/settings * @route GET /api/settings
* @description Returns the current bot configuration. * @description Returns the current bot configuration from database.
* Configuration includes economy settings, leveling settings, * Configuration includes economy settings, leveling settings,
* command toggles, and other system settings. * command toggles, and other system settings.
* @response 200 - Full configuration object * @response 200 - Full configuration object (DB format with strings for BigInts)
* @response 500 - Error fetching settings * @response 500 - Error fetching settings
* *
* @example * @example
* // Response * // Response
* { * {
* "economy": { "dailyReward": 100, "streakBonus": 10 }, * "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
* "leveling": { "xpPerMessage": 15, "levelUpChannel": "123456789" }, * "leveling": { "base": 100, "exponent": 1.5 },
* "commands": { "disabled": [], "channelLocks": {} } * "commands": { "disabled": [], "channelLocks": {} }
* } * }
*/ */
if (pathname === "/api/settings" && method === "GET") { if (pathname === "/api/settings" && method === "GET") {
return withErrorHandling(async () => { return withErrorHandling(async () => {
const { config } = await import("@shared/lib/config"); const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
return new Response(JSON.stringify(config, jsonReplacer), { const settings = await gameSettingsService.getSettings();
headers: { "Content-Type": "application/json" }
}); if (!settings) {
// Return defaults if no settings in DB yet
return jsonResponse(gameSettingsService.getDefaults());
}
return jsonResponse(settings);
}, "fetch settings"); }, "fetch settings");
} }
@@ -61,7 +59,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
* Only the provided fields will be updated; other settings remain unchanged. * Only the provided fields will be updated; other settings remain unchanged.
* After updating, commands are automatically reloaded. * After updating, commands are automatically reloaded.
* *
* @body Partial configuration object * @body Partial configuration object (DB format with strings for BigInts)
* @response 200 - `{ success: true }` * @response 200 - `{ success: true }`
* @response 400 - Validation error * @response 400 - Validation error
* @response 500 - Error saving settings * @response 500 - Error saving settings
@@ -69,19 +67,15 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
* @example * @example
* // Request - Only update economy daily reward * // Request - Only update economy daily reward
* POST /api/settings * POST /api/settings
* { "economy": { "dailyReward": 150 } } * { "economy": { "daily": { "amount": "150" } } }
*/ */
if (pathname === "/api/settings" && method === "POST") { if (pathname === "/api/settings" && method === "POST") {
try { try {
const partialConfig = await req.json(); const partialConfig = await req.json() as Record<string, unknown>;
const { saveConfig, config: currentConfig } = await import("@shared/lib/config"); const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
const { deepMerge } = await import("@shared/lib/utils");
// Use upsertSettings to merge partial update
// Merge partial update into current config await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
const mergedConfig = deepMerge(currentConfig, partialConfig);
// saveConfig throws if validation fails
saveConfig(mergedConfig);
const { systemEvents, EVENTS } = await import("@shared/lib/events"); const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS); systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);