forked from syntaxbullet/aurorabot
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
This commit is contained in:
@@ -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' }),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => ({}));
|
||||
@@ -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", () => {
|
||||
@@ -100,7 +85,6 @@ 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([]);
|
||||
@@ -109,7 +93,8 @@ describe("ModerationService", () => {
|
||||
|
||||
await ModerationService.issueWarning({
|
||||
...defaultOptions,
|
||||
dmTarget: mockDmTarget
|
||||
dmTarget: mockDmTarget,
|
||||
config: { dmOnWarn: false }
|
||||
});
|
||||
|
||||
expect(mockDmTarget.send).not.toHaveBeenCalled();
|
||||
@@ -125,7 +110,8 @@ describe("ModerationService", () => {
|
||||
|
||||
const result = await ModerationService.issueWarning({
|
||||
...defaultOptions,
|
||||
timeoutTarget: mockTimeoutTarget
|
||||
timeoutTarget: mockTimeoutTarget,
|
||||
config: { autoTimeoutThreshold: 3 }
|
||||
});
|
||||
|
||||
expect(result.autoTimeoutIssued).toBe(true);
|
||||
|
||||
@@ -5,7 +5,7 @@ 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;
|
||||
}
|
||||
@@ -66,7 +66,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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user