diff --git a/src/lib/config.ts b/src/lib/config.ts index 00263ec..d6b65f4 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -69,6 +69,12 @@ export interface GameConfigType { autoTimeoutThreshold?: number; }; }; + system: { + cleanup: { + intervalMs: number; + questArchiveDays: number; + }; + }; } // Initial default config state @@ -160,6 +166,17 @@ const configSchema = z.object({ cases: { dmOnWarn: true } + }), + system: z.object({ + cleanup: z.object({ + intervalMs: z.number().default(24 * 60 * 60 * 1000), // Daily + questArchiveDays: z.number().default(30) + }) + }).default({ + cleanup: { + intervalMs: 24 * 60 * 60 * 1000, + questArchiveDays: 30 + } }) }); diff --git a/src/modules/system/cleanup.service.test.ts b/src/modules/system/cleanup.service.test.ts new file mode 100644 index 0000000..f304169 --- /dev/null +++ b/src/modules/system/cleanup.service.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test"; +import { cleanupService } from "./cleanup.service"; +import { lootdrops, userTimers, userQuests } from "@/db/schema"; +import { config } from "@/lib/config"; + +const mockDelete = mock(); +const mockReturning = mock(); +const mockWhere = mock(); +const mockFindMany = mock(); + +mockDelete.mockReturnValue({ where: mockWhere }); +mockWhere.mockReturnValue({ returning: mockReturning }); + +// Mock DrizzleClient +mock.module("@/lib/DrizzleClient", () => ({ + DrizzleClient: { + delete: mockDelete, + query: { + userTimers: { + findMany: mockFindMany + } + } + } +})); + +// Mock AuroraClient +mock.module("@/lib/BotClient", () => ({ + AuroraClient: { + guilds: { + fetch: mock().mockResolvedValue({ + members: { + fetch: mock().mockResolvedValue({ + user: { tag: "TestUser#1234" }, + roles: { remove: mock().mockResolvedValue({}) } + }) + } + }) + } + } +})); + +// Mock Config +mock.module("@/lib/config", () => ({ + config: { + system: { + cleanup: { + intervalMs: 86400000, + questArchiveDays: 30 + } + } + } +})); + +describe("cleanupService", () => { + beforeEach(() => { + mockDelete.mockClear(); + mockWhere.mockClear(); + mockReturning.mockClear(); + mockFindMany.mockClear(); + }); + + it("cleanupLootdrops should delete expired unclaimed lootdrops", async () => { + mockReturning.mockResolvedValue([{ id: "msg1" }, { id: "msg2" }]); + + const count = await cleanupService.cleanupLootdrops(); + + expect(count).toBe(2); + expect(mockDelete).toHaveBeenCalledWith(lootdrops); + }); + + it("cleanupTimers should delete expired timers and handle roles", async () => { + // Mock findMany for expired ACCESS timers + mockFindMany.mockResolvedValue([ + { userId: 123n, type: 'ACCESS', key: 'role_456', metadata: { roleId: '456' } } + ]); + + // Mock returning for bulk delete + mockReturning.mockResolvedValue([{ userId: 789n }]); // One other timer + + const count = await cleanupService.cleanupTimers(); + + // 1 from findMany + 1 from bulk delete (simplified mock behavior) + expect(count).toBe(2); + expect(mockDelete).toHaveBeenCalledWith(userTimers); + }); + + it("cleanupQuests should delete old completed quests", async () => { + mockReturning.mockResolvedValue([{ userId: 123n }]); + + const count = await cleanupService.cleanupQuests(); + + expect(count).toBe(1); + expect(mockDelete).toHaveBeenCalledWith(userQuests); + }); + + it("runAll should run all cleanup tasks and log stats", async () => { + const spyLootdrops = spyOn(cleanupService, 'cleanupLootdrops').mockResolvedValue(1); + const spyTimers = spyOn(cleanupService, 'cleanupTimers').mockResolvedValue(2); + const spyQuests = spyOn(cleanupService, 'cleanupQuests').mockResolvedValue(3); + + await cleanupService.runAll(); + + expect(spyLootdrops).toHaveBeenCalled(); + expect(spyTimers).toHaveBeenCalled(); + expect(spyQuests).toHaveBeenCalled(); + + spyLootdrops.mockRestore(); + spyTimers.mockRestore(); + spyQuests.mockRestore(); + }); +}); diff --git a/src/modules/system/cleanup.service.ts b/src/modules/system/cleanup.service.ts new file mode 100644 index 0000000..cbbc2ea --- /dev/null +++ b/src/modules/system/cleanup.service.ts @@ -0,0 +1,118 @@ +import { lootdrops, userTimers, userQuests } from "@/db/schema"; +import { eq, and, lt, isNull, sql } from "drizzle-orm"; +import { DrizzleClient } from "@/lib/DrizzleClient"; +import { AuroraClient } from "@/lib/BotClient"; +import { env } from "@/lib/env"; +import { config } from "@/lib/config"; + +export const cleanupService = { + /** + * Runs all cleanup tasks + */ + runAll: async () => { + console.log("๐Ÿงน Starting system cleanup..."); + const stats = { + lootdrops: 0, + timers: 0, + quests: 0 + }; + + try { + stats.lootdrops = await cleanupService.cleanupLootdrops(); + stats.timers = await cleanupService.cleanupTimers(); + stats.quests = await cleanupService.cleanupQuests(); + + console.log(`โœ… Cleanup finished. Stats: +- Lootdrops: ${stats.lootdrops} removed +- Timers: ${stats.timers} removed +- Quests: ${stats.quests} cleaned`); + } catch (error) { + console.error("โŒ Error during cleanup:", error); + } + }, + + /** + * Deletes unclaimed expired lootdrops + */ + cleanupLootdrops: async (): Promise => { + const now = new Date(); + const result = await DrizzleClient.delete(lootdrops) + .where(and( + lt(lootdrops.expiresAt, now), + isNull(lootdrops.claimedBy) + )) + .returning({ id: lootdrops.messageId }); + + return result.length; + }, + + /** + * Cleans up expired user timers and handles side effects (like role removal) + */ + cleanupTimers: async (): Promise => { + const now = new Date(); + let deletedCount = 0; + + // 1. Specific handling for ACCESS timers (revoking roles etc) + // This is migrated from scheduler.ts + const expiredAccess = await DrizzleClient.query.userTimers.findMany({ + where: and( + eq(userTimers.type, 'ACCESS'), + lt(userTimers.expiresAt, now) + ) + }); + + for (const timer of expiredAccess) { + const meta = timer.metadata as any; + const userIdStr = timer.userId.toString(); + + if (timer.key.startsWith('role_')) { + try { + const roleId = meta?.roleId || timer.key.replace('role_', ''); + const guildId = env.DISCORD_GUILD_ID; + + if (guildId) { + const guild = await AuroraClient.guilds.fetch(guildId); + const member = await guild.members.fetch(userIdStr); + await member.roles.remove(roleId); + console.log(`๐Ÿ‘‹ Removed temporary role ${roleId} from ${member.user.tag}`); + } + } catch (err) { + console.error(`Failed to remove role for user ${userIdStr}:`, err); + } + } + + // Delete specifically this one + await DrizzleClient.delete(userTimers) + .where(and( + eq(userTimers.userId, timer.userId), + eq(userTimers.type, timer.type), + eq(userTimers.key, timer.key) + )); + deletedCount++; + } + + // 2. Bulk delete all other expired timers (COOLDOWN, EFFECT, etc) + const result = await DrizzleClient.delete(userTimers) + .where(lt(userTimers.expiresAt, now)) + .returning({ userId: userTimers.userId }); + + deletedCount += result.length; + return deletedCount; + }, + + /** + * Deletes or archives old completed quests + */ + cleanupQuests: async (): Promise => { + const archiveDays = config.system.cleanup.questArchiveDays; + const threshold = new Date(); + threshold.setDate(threshold.getDate() - archiveDays); + + const result = await DrizzleClient.delete(userQuests) + .where(lt(userQuests.completedAt, threshold)) + .returning({ userId: userQuests.userId }); + + return result.length; + } +}; diff --git a/src/modules/system/scheduler.ts b/src/modules/system/scheduler.ts index 4956994..9dfc159 100644 --- a/src/modules/system/scheduler.ts +++ b/src/modules/system/scheduler.ts @@ -1,86 +1,32 @@ -import { userTimers } from "@/db/schema"; -import { eq, and, lt } from "drizzle-orm"; -import { DrizzleClient } from "@/lib/DrizzleClient"; -import { AuroraClient } from "@/lib/BotClient"; -import { env } from "@/lib/env"; +import { cleanupService } from "./cleanup.service"; +import { config } from "@/lib/config"; /** - * The Janitor responsible for cleaning up expired ACCESS timers - * and revoking privileges. + * The Scheduler responsible for periodic tasks and system maintenance. */ export const schedulerService = { start: () => { - console.log("๐Ÿ•’ Scheduler started: Janitor loop running every 60s"); - // Run immediately on start - schedulerService.runJanitor(); + console.log("๐Ÿ•’ Scheduler started: Maintenance loops initialized."); - // Loop every 60 seconds + // 1. High-frequency timer cleanup (every 60s) + // This handles role revocations and cooldown expirations setInterval(() => { - schedulerService.runJanitor(); + cleanupService.cleanupTimers(); }, 60 * 1000); - // Terminal Update Loop (every 60s) + // 2. Scheduled system cleanup (configurable, default daily) + // This handles lootdrops, quests, etc. + setInterval(() => { + cleanupService.runAll(); + }, config.system.cleanup.intervalMs); + + // 3. Terminal Update Loop (every 60s) const { terminalService } = require("@/modules/terminal/terminal.service"); setInterval(() => { terminalService.update(); }, 60 * 1000); - }, - runJanitor: async () => { - try { - // Find all expired ACCESS timers - // We do this in a transaction to ensure we read and delete atomically if possible, - // though for this specific case, fetching then deleting is fine as long as we handle race conditions gracefully. - - const now = new Date(); - - const expiredAccess = await DrizzleClient.query.userTimers.findMany({ - where: and( - eq(userTimers.type, 'ACCESS'), - lt(userTimers.expiresAt, now) - ) - }); - - if (expiredAccess.length === 0) return; - - console.log(`๐Ÿงน Janitor: Found ${expiredAccess.length} expired access timers.`); - - for (const timer of expiredAccess) { - const meta = timer.metadata as any; - const userIdStr = timer.userId.toString(); - - // Specific Handling for Roles - if (timer.key.startsWith('role_')) { - try { - const roleId = meta?.roleId || timer.key.replace('role_', ''); - const guildId = env.DISCORD_GUILD_ID; - - if (guildId) { - // We try to fetch, if bot is not in guild or lacks perms, it will catch - const guild = await AuroraClient.guilds.fetch(guildId); - const member = await guild.members.fetch(userIdStr); - await member.roles.remove(roleId); - console.log(`๐Ÿ‘‹ Removed temporary role ${roleId} from ${member.user.tag}`); - } - } catch (err) { - console.error(`Failed to remove role for user ${userIdStr}:`, err); - // We still delete the timer so we don't loop forever on a left user - } - } else { - console.log(`๐Ÿšซ Revoking access for User ${timer.userId}: Key=${timer.key} (Channel: ${meta?.channelId || 'N/A'})`); - // TODO: Generic channel permission removal if needed - } - - // Delete the timer row - await DrizzleClient.delete(userTimers) - .where(and( - eq(userTimers.userId, timer.userId), - eq(userTimers.type, timer.type), - eq(userTimers.key, timer.key) - )); - } - } catch (error) { - console.error("Janitor Error:", error); - } + // Run an initial cleanup on start for good measure + cleanupService.runAll(); } };