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/log
scratchpad/
tickets/
bot/assets/graphics/items
tickets/

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils";
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";
export const moderationCase = createCommand({
@@ -30,7 +30,7 @@ export const moderationCase = createCommand({
}
// Get the case
const moderationCase = await ModerationService.getCaseById(caseId);
const moderationCase = await moderationService.getCaseById(caseId);
if (!moderationCase) {
await interaction.editReply({

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils";
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";
export const cases = createCommand({
@@ -29,7 +29,7 @@ export const cases = createCommand({
const activeOnly = interaction.options.getBoolean("active_only") || false;
// Get cases for the user
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
const title = activeOnly
? `⚠️ Active Cases for ${targetUser.username}`

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils";
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";
export const clearwarning = createCommand({
@@ -38,7 +38,7 @@ export const clearwarning = createCommand({
}
// Check if case exists and is active
const existingCase = await ModerationService.getCaseById(caseId);
const existingCase = await moderationService.getCaseById(caseId);
if (!existingCase) {
await interaction.editReply({
@@ -62,7 +62,7 @@ export const clearwarning = createCommand({
}
// Clear the warning
await ModerationService.clearCase({
await moderationService.clearCase({
caseId,
clearedBy: interaction.user.id,
clearedByName: interaction.user.username,

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils";
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 { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
@@ -31,7 +31,7 @@ export const note = createCommand({
const noteText = interaction.options.getString("note", true);
// Create the note case
const moderationCase = await ModerationService.createCase({
const moderationCase = await moderationService.createCase({
type: CaseType.NOTE,
userId: targetUser.id,
username: targetUser.username,

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils";
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";
export const notes = createCommand({
@@ -22,7 +22,7 @@ export const notes = createCommand({
const targetUser = interaction.options.getUser("user", true);
// Get all notes for the user
const userNotes = await ModerationService.getUserNotes(targetUser.id);
const userNotes = await moderationService.getUserNotes(targetUser.id);
// Display the notes
await interaction.editReply({

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { config } from "@shared/lib/config";
import { PruneService } from "@shared/modules/moderation/prune.service";
import { pruneService } from "@shared/modules/moderation/prune.service";
import {
getConfirmationMessage,
getProgressEmbed,
@@ -66,7 +66,7 @@ export const prune = createCommand({
let estimatedCount: number | undefined;
if (all) {
try {
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
} catch {
estimatedCount = undefined;
}
@@ -97,7 +97,7 @@ export const prune = createCommand({
});
// Execute deletion with progress callback for 'all' mode
const result = await PruneService.deleteMessages(
const result = await pruneService.deleteMessages(
interaction.channel!,
{
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
@@ -129,7 +129,7 @@ export const prune = createCommand({
}
} else {
// No confirmation needed, proceed directly
const result = await PruneService.deleteMessages(
const result = await pruneService.deleteMessages(
interaction.channel!,
{
amount: finalAmount as number,

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { moderationService } from "@shared/modules/moderation/moderation.service";
import {
getWarnSuccessEmbed,
getModerationErrorEmbed,
@@ -54,7 +54,7 @@ export const warn = createCommand({
const guildConfig = await getGuildConfig(interaction.guildId!);
// Issue the warning via service
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
userId: targetUser.id,
username: targetUser.username,
moderatorId: interaction.user.id,

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils";
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";
export const warnings = createCommand({
@@ -22,7 +22,7 @@ export const warnings = createCommand({
const targetUser = interaction.options.getUser("user", true);
// Get active warnings for the user
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
// Display the warnings
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 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', {
guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(),
studentRoleId: bigint('student_role_id', { mode: 'bigint' }),

View File

@@ -1,29 +1,12 @@
import { jsonReplacer } from './utils';
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { z } from 'zod';
const configPath = join(import.meta.dir, '..', 'config', 'config.json');
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;
};
};
}
import type {
LevelingConfig,
EconomyConfig as EconomyConfigDB,
InventoryConfig as InventoryConfigDB,
LootdropConfig,
TriviaConfig as TriviaConfigDB,
ModerationConfig
} from "@db/schema/game-settings";
import type { GuildConfig } from "@db/schema/guild-settings";
const guildConfigCache = new Map<string, { config: GuildConfig; timestamp: number }>();
const CACHE_TTL_MS = 60000;
@@ -82,16 +65,10 @@ export function invalidateGuildConfigCache(guildId: string) {
guildConfigCache.delete(guildId);
}
export interface LevelingConfig {
base: number;
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
};
}
// Re-export DB types
export type { LevelingConfig, LootdropConfig, ModerationConfig };
// Runtime config types with BigInt for numeric fields
export interface EconomyConfig {
daily: {
amount: bigint;
@@ -114,18 +91,6 @@ export interface InventoryConfig {
maxSlots: number;
}
export interface LootdropConfig {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
};
}
export interface TriviaConfig {
entryFee: bigint;
rewardMultiplier: number;
@@ -135,20 +100,6 @@ export interface TriviaConfig {
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 {
leveling: LevelingConfig;
economy: EconomyConfig;
@@ -158,160 +109,11 @@ export interface GameConfigType {
trivia: TriviaConfig;
moderation: ModerationConfig;
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;
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()])
.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,
});
}
export const GameConfig = config;
async function loadFromDatabase(): Promise<boolean> {
try {
@@ -358,88 +160,48 @@ async function loadFromDatabase(): Promise<boolean> {
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> {
const dbLoaded = await loadFromDatabase();
if (!dbLoaded) {
const fileConfig = loadFromFile();
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,
});
}
await loadDefaults();
}
}
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> {
loadFileSync();
await reloadConfig();
}
loadFileSync();

View File

@@ -1,5 +1,5 @@
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 { economyService } from "@shared/modules/economy/economy.service";
@@ -67,8 +67,8 @@ describe("lootdropService", () => {
mockFrom.mockClear();
// Reset internal state
(lootdropService as any).channelActivity = new Map();
(lootdropService as any).channelCooldowns = new Map();
channelActivity.clear();
channelCooldowns.clear();
// Mock Math.random
originalRandom = Math.random;

View File

@@ -7,234 +7,233 @@ import { lootdrops } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { eq, and, isNull, lt } from "drizzle-orm";
interface Lootdrop {
messageId: string;
channelId: string;
rewardAmount: number;
currency: string;
claimedBy?: string;
createdAt: Date;
// Module-level state (exported for testing)
export const channelActivity: Map<string, number[]> = new Map();
export const channelCooldowns: Map<string, number> = new Map();
// Private helper function
function cleanupActivity() {
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 {
private channelActivity: Map<string, number[]> = new Map();
private channelCooldowns: Map<string, number> = new Map();
// Initialize cleanup interval on module load
setInterval(() => {
cleanupActivity();
cleanupExpiredLootdrops(true);
}, 60000);
constructor() {
// Cleanup interval for activity tracking and expired lootdrops
setInterval(() => {
this.cleanupActivity();
this.cleanupExpiredLootdrops(true);
}, 60000);
async function cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
try {
const now = new Date();
const whereClause = includeClaimed
? lt(lootdrops.expiresAt, now)
: 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() {
const now = Date.now();
const window = config.lootdrop.activityWindowMs;
async function processMessage(message: Message) {
if (message.author.bot || !message.guild) return;
for (const [channelId, timestamps] of this.channelActivity.entries()) {
const validTimestamps = timestamps.filter(t => now - t < window);
if (validTimestamps.length === 0) {
this.channelActivity.delete(channelId);
} else {
this.channelActivity.set(channelId, validTimestamps);
}
const channelId = message.channel.id;
const now = Date.now();
// Check cooldown
const cooldown = channelCooldowns.get(channelId);
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> {
try {
const now = new Date();
const whereClause = includeClaimed
? lt(lootdrops.expiresAt, now)
: and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
async function spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) {
const min = config.lootdrop.reward.min;
const max = config.lootdrop.reward.max;
const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min);
const currency = overrideCurrency ?? config.lootdrop.reward.currency;
const result = await DrizzleClient.delete(lootdrops)
.where(whereClause)
.returning();
const { content, files, components } = await getLootdropMessage(reward, currency);
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;
}
try {
const message = await channel.send({ content, files, components });
// Persist to DB
await DrizzleClient.insert(lootdrops).values({
messageId: message.id,
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) {
if (message.author.bot || !message.guild) return;
async function 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();
const channelId = message.channel.id;
const now = Date.now();
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." };
}
}
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
const cooldown = this.channelCooldowns.get(channelId);
if (cooldown && now < cooldown) return;
const cooldownUntil = channelCooldowns.get(channelId);
const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
// Track activity
const timestamps = this.channelActivity.get(channelId) || [];
timestamps.push(now);
this.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 this.spawnLootdrop(message.channel as TextChannel);
// Set cooldown
this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
this.channelActivity.set(channelId, []);
}
if (validCount > maxMessages) {
maxMessages = validCount;
hottestChannel = {
id: channelId,
messages: validCount,
progress: Math.min(100, (validCount / required) * 100),
cooldown: isOnCooldown
};
}
}
public async spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) {
const min = config.lootdrop.reward.min;
const max = config.lootdrop.reward.max;
const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min);
const currency = overrideCurrency ?? config.lootdrop.reward.currency;
return {
monitoredChannels: channelActivity.size,
hottestChannel,
config: {
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 {
const message = await channel.send({ content, files, components });
// Persist to DB
await DrizzleClient.insert(lootdrops).values({
messageId: message.id,
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
};
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 {
monitoredChannels: this.channelActivity.size,
hottestChannel,
config: {
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;
}
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 { 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";
@@ -14,19 +14,7 @@ const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
// Mock Config
const mockConfig = {
moderation: {
cases: {
dmOnWarn: true,
autoTimeoutThreshold: 3
}
}
};
mock.module("@shared/lib/config", () => ({
config: mockConfig
}));
// Mock View
const mockGetUserWarningEmbed = mock(() => ({}));
@@ -55,7 +43,7 @@ mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
describe("ModerationService", () => {
describe("moderationService", () => {
beforeEach(() => {
mockFindFirst.mockReset();
mockFindMany.mockReset();
@@ -66,9 +54,6 @@ describe("ModerationService", () => {
mockSet.mockClear();
mockWhere.mockClear();
mockGetUserWarningEmbed.mockClear();
// Reset config to defaults
mockConfig.moderation.cases.dmOnWarn = true;
mockConfig.moderation.cases.autoTimeoutThreshold = 3;
});
describe("issueWarning", () => {
@@ -88,7 +73,7 @@ describe("ModerationService", () => {
const mockDmTarget = { send: mock() };
const result = await ModerationService.issueWarning({
const result = await moderationService.issueWarning({
...defaultOptions,
dmTarget: mockDmTarget
});
@@ -100,16 +85,16 @@ describe("ModerationService", () => {
});
it("should not DM if dmOnWarn is false", async () => {
mockConfig.moderation.cases.dmOnWarn = false;
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
mockFindMany.mockResolvedValue([]);
const mockDmTarget = { send: mock() };
await ModerationService.issueWarning({
await moderationService.issueWarning({
...defaultOptions,
dmTarget: mockDmTarget
dmTarget: mockDmTarget,
config: { dmOnWarn: false }
});
expect(mockDmTarget.send).not.toHaveBeenCalled();
@@ -123,9 +108,10 @@ describe("ModerationService", () => {
const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({
const result = await moderationService.issueWarning({
...defaultOptions,
timeoutTarget: mockTimeoutTarget
timeoutTarget: mockTimeoutTarget,
config: { autoTimeoutThreshold: 3 }
});
expect(result.autoTimeoutIssued).toBe(true);
@@ -142,7 +128,7 @@ describe("ModerationService", () => {
const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({
const result = await moderationService.issueWarning({
...defaultOptions,
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", () => {
it("should create a new moderation case with correct values", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
@@ -190,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",
@@ -213,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",
@@ -233,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);
});
});
@@ -243,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();
});
@@ -254,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",
@@ -278,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);
});
});

View File

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

View File

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

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;
}
const { ModerationService } = await import("@shared/modules/moderation/moderation.service");
const { moderationService } = await import("@shared/modules/moderation/moderation.service");
/**
* @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.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 });
}, "fetch moderation cases");
}
@@ -97,7 +97,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const caseId = pathname.split("/").pop()!.toUpperCase();
return withErrorHandling(async () => {
const moderationCase = await ModerationService.getCaseById(caseId);
const moderationCase = await moderationService.getCaseById(caseId);
if (!moderationCase) {
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,
userId: data.userId,
username: data.username,
@@ -193,7 +193,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
}
const updatedCase = await ModerationService.clearCase({
const updatedCase = await moderationService.clearCase({
caseId,
clearedBy: data.clearedBy,
clearedByName: data.clearedByName,

View File

@@ -7,13 +7,6 @@
import type { RouteContext, RouteModule } from "./types";
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.
*
@@ -32,26 +25,31 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
/**
* @route GET /api/settings
* @description Returns the current bot configuration.
* @description Returns the current bot configuration from database.
* Configuration includes economy settings, leveling 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
*
* @example
* // Response
* {
* "economy": { "dailyReward": 100, "streakBonus": 10 },
* "leveling": { "xpPerMessage": 15, "levelUpChannel": "123456789" },
* "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
* "leveling": { "base": 100, "exponent": 1.5 },
* "commands": { "disabled": [], "channelLocks": {} }
* }
*/
if (pathname === "/api/settings" && method === "GET") {
return withErrorHandling(async () => {
const { config } = await import("@shared/lib/config");
return new Response(JSON.stringify(config, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
const settings = await gameSettingsService.getSettings();
if (!settings) {
// Return defaults if no settings in DB yet
return jsonResponse(gameSettingsService.getDefaults());
}
return jsonResponse(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.
* 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 400 - Validation error
* @response 500 - Error saving settings
@@ -69,19 +67,15 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
* @example
* // Request - Only update economy daily reward
* POST /api/settings
* { "economy": { "dailyReward": 150 } }
* { "economy": { "daily": { "amount": "150" } } }
*/
if (pathname === "/api/settings" && method === "POST") {
try {
const partialConfig = await req.json();
const { saveConfig, config: currentConfig } = await import("@shared/lib/config");
const { deepMerge } = await import("@shared/lib/utils");
// Merge partial update into current config
const mergedConfig = deepMerge(currentConfig, partialConfig);
// saveConfig throws if validation fails
saveConfig(mergedConfig);
const partialConfig = await req.json() as Record<string, unknown>;
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
// Use upsertSettings to merge partial update
await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);