Compare commits
4 Commits
2d35a5eabb
...
bf20c61190
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf20c61190 | ||
|
|
099601ce6d | ||
|
|
55d2376ca1 | ||
|
|
6eb4a32a12 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const moderationCase = createCommand({
|
export const moderationCase = createCommand({
|
||||||
@@ -30,7 +30,7 @@ export const moderationCase = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the case
|
// Get the case
|
||||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
const moderationCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
if (!moderationCase) {
|
if (!moderationCase) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const cases = createCommand({
|
export const cases = createCommand({
|
||||||
@@ -29,7 +29,7 @@ export const cases = createCommand({
|
|||||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||||
|
|
||||||
// Get cases for the user
|
// Get cases for the user
|
||||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
|
||||||
|
|
||||||
const title = activeOnly
|
const title = activeOnly
|
||||||
? `⚠️ Active Cases for ${targetUser.username}`
|
? `⚠️ Active Cases for ${targetUser.username}`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const clearwarning = createCommand({
|
export const clearwarning = createCommand({
|
||||||
@@ -38,7 +38,7 @@ export const clearwarning = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if case exists and is active
|
// Check if case exists and is active
|
||||||
const existingCase = await ModerationService.getCaseById(caseId);
|
const existingCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
if (!existingCase) {
|
if (!existingCase) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
@@ -62,7 +62,7 @@ export const clearwarning = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear the warning
|
// Clear the warning
|
||||||
await ModerationService.clearCase({
|
await moderationService.clearCase({
|
||||||
caseId,
|
caseId,
|
||||||
clearedBy: interaction.user.id,
|
clearedBy: interaction.user.id,
|
||||||
clearedByName: interaction.user.username,
|
clearedByName: interaction.user.username,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { CaseType } from "@shared/lib/constants";
|
import { CaseType } from "@shared/lib/constants";
|
||||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export const note = createCommand({
|
|||||||
const noteText = interaction.options.getString("note", true);
|
const noteText = interaction.options.getString("note", true);
|
||||||
|
|
||||||
// Create the note case
|
// Create the note case
|
||||||
const moderationCase = await ModerationService.createCase({
|
const moderationCase = await moderationService.createCase({
|
||||||
type: CaseType.NOTE,
|
type: CaseType.NOTE,
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const notes = createCommand({
|
export const notes = createCommand({
|
||||||
@@ -22,7 +22,7 @@ export const notes = createCommand({
|
|||||||
const targetUser = interaction.options.getUser("user", true);
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
// Get all notes for the user
|
// Get all notes for the user
|
||||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||||
|
|
||||||
// Display the notes
|
// Display the notes
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
import { pruneService } from "@shared/modules/moderation/prune.service";
|
||||||
import {
|
import {
|
||||||
getConfirmationMessage,
|
getConfirmationMessage,
|
||||||
getProgressEmbed,
|
getProgressEmbed,
|
||||||
@@ -66,7 +66,7 @@ export const prune = createCommand({
|
|||||||
let estimatedCount: number | undefined;
|
let estimatedCount: number | undefined;
|
||||||
if (all) {
|
if (all) {
|
||||||
try {
|
try {
|
||||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
|
||||||
} catch {
|
} catch {
|
||||||
estimatedCount = undefined;
|
estimatedCount = undefined;
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ export const prune = createCommand({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Execute deletion with progress callback for 'all' mode
|
// Execute deletion with progress callback for 'all' mode
|
||||||
const result = await PruneService.deleteMessages(
|
const result = await pruneService.deleteMessages(
|
||||||
interaction.channel!,
|
interaction.channel!,
|
||||||
{
|
{
|
||||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||||
@@ -129,7 +129,7 @@ export const prune = createCommand({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No confirmation needed, proceed directly
|
// No confirmation needed, proceed directly
|
||||||
const result = await PruneService.deleteMessages(
|
const result = await pruneService.deleteMessages(
|
||||||
interaction.channel!,
|
interaction.channel!,
|
||||||
{
|
{
|
||||||
amount: finalAmount as number,
|
amount: finalAmount as number,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import {
|
import {
|
||||||
getWarnSuccessEmbed,
|
getWarnSuccessEmbed,
|
||||||
getModerationErrorEmbed,
|
getModerationErrorEmbed,
|
||||||
@@ -54,7 +54,7 @@ export const warn = createCommand({
|
|||||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
|
||||||
// Issue the warning via service
|
// Issue the warning via service
|
||||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const warnings = createCommand({
|
export const warnings = createCommand({
|
||||||
@@ -22,7 +22,7 @@ export const warnings = createCommand({
|
|||||||
const targetUser = interaction.options.getUser("user", true);
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
// Get active warnings for the user
|
// Get active warnings for the user
|
||||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||||
|
|
||||||
// Display the warnings
|
// Display the warnings
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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();
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
},
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user