forked from syntaxbullet/AuroraBot-discord
feat: implement scheduled cleanup job for expired data
This commit is contained in:
@@ -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
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
111
src/modules/system/cleanup.service.test.ts
Normal file
111
src/modules/system/cleanup.service.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
118
src/modules/system/cleanup.service.ts
Normal file
118
src/modules/system/cleanup.service.ts
Normal file
@@ -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<number> => {
|
||||
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<number> => {
|
||||
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<number> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user