From 57699d60a8d721b30c40533db38ee00dda6314ca Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 6 Jun 2026 02:46:38 +0700 Subject: [PATCH] feat(services): implement periodic booster role cleanup Add a new `boostCleanupService` to periodically scan guilds and remove custom booster roles and database records for members who are no longer eligible (e.g., lost boost status or left the server). This ensures data consistency even if the bot was offline during status changes. - Implement `startBoostCleanup` with configurable intervals - Add `findByGuild` to `BoosterRoleStore` for batch processing - Update `DrizzleBoosterRoleStore` to support guild-based lookups - Add configuration for `boostCleanupIntervalMs` --- src/config/index.ts | 4 +- src/index.ts | 22 +++++- src/services/boostCleanupService.ts | 97 +++++++++++++++++++++++++ src/services/boosterRoleService.test.ts | 4 + src/services/boosterRoleService.ts | 1 + src/services/drizzleBoosterRoleStore.ts | 21 ++++-- 6 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 src/services/boostCleanupService.ts diff --git a/src/config/index.ts b/src/config/index.ts index ede5a1b..1a0b9f8 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -5,6 +5,7 @@ export type AppConfig = { databaseUrl: string; boosterRoleAnchorRoleId: string | null; boosterEligibilityRoleId: string; + boostCleanupIntervalMs: number; }; export function loadConfig(env: Record = process.env): AppConfig { @@ -14,7 +15,8 @@ export function loadConfig(env: Record = process.env discordGuildId: requireEnv(env, "DISCORD_GUILD_ID"), databaseUrl: env.DATABASE_URL ?? "postgresql://booster_role:booster_role@localhost:5432/booster_role", boosterRoleAnchorRoleId: env.BOOSTER_ROLE_ANCHOR_ROLE_ID ?? null, - boosterEligibilityRoleId: env.BOOSTER_ELIGIBILITY_ROLE_ID ?? "1206431347925852162" + boosterEligibilityRoleId: env.BOOSTER_ELIGIBILITY_ROLE_ID ?? "1206431347925852162", + boostCleanupIntervalMs: Number(env.BOOST_CLEANUP_INTERVAL_MS) || 5 * 60 * 1000 }; } diff --git a/src/index.ts b/src/index.ts index 1c3b0b9..6fc3e3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,11 @@ import { loadConfig } from "./config"; +import { createDb } from "./db"; import { attachBotHandlers } from "./discord/bot"; import { createDiscordClient } from "./discord/client"; import { registerGuildCommandsWithToken } from "./discord/registerCommands"; +import { startBoostCleanup } from "./services/boostCleanupService"; +import { DiscordRoleRepository } from "./services/discordRoleRepository"; +import { DrizzleBoosterRoleStore } from "./services/drizzleBoosterRoleStore"; import { logger } from "./logger"; const config = loadConfig(); @@ -13,11 +17,27 @@ await registerGuildCommandsWithToken(config.discordToken, { logger.info("Guild commands registered", { guildId: config.discordGuildId }); const client = createDiscordClient(); -attachBotHandlers(client, config); +const db = createDb(config.databaseUrl); +const store = new DrizzleBoosterRoleStore(db); client.once("clientReady", () => { logger.info("Discord client ready", { bot: client.user?.tag ?? "unknown bot" }); + + // Start periodic cleanup for boost ends missed during downtime + startBoostCleanup( + client, + store, + (guild) => new DiscordRoleRepository(guild, config.boosterRoleAnchorRoleId), + { + intervalMs: config.boostCleanupIntervalMs, + boosterEligibilityRoleId: config.boosterEligibilityRoleId, + anchorRoleId: config.boosterRoleAnchorRoleId, + }, + ); + logger.info("Boost cleanup started", { intervalMs: config.boostCleanupIntervalMs }); }); +attachBotHandlers(client, config); + logger.info("Logging in Discord client"); await client.login(config.discordToken); diff --git a/src/services/boostCleanupService.ts b/src/services/boostCleanupService.ts new file mode 100644 index 0000000..440def7 --- /dev/null +++ b/src/services/boostCleanupService.ts @@ -0,0 +1,97 @@ +import { logger } from "../logger"; +import type { Client, Guild } from "discord.js"; +import type { BoosterRoleStore } from "./boosterRoleService"; +import type { RoleRepository } from "./boosterRoleService"; + +export type CleanupServiceOptions = { + intervalMs: number; + boosterEligibilityRoleId: string; + anchorRoleId: string | null; +}; + +/** + * Periodically scans all guilds for booster roles whose owners are no longer + * boosting (missed due to bot downtime). Removes orphan custom roles and + * database records. + */ +export function startBoostCleanup( + client: Client, + store: BoosterRoleStore, + repoFactory: (guild: Guild) => RoleRepository, + options: CleanupServiceOptions, +): { stop: () => void } { + const timer = setInterval(() => { + runCleanup(client, store, repoFactory, options).catch((err) => { + logger.error("Boost cleanup cycle failed", { error: String(err) }); + }); + }, options.intervalMs); + + // Run once immediately on startup too + runCleanup(client, store, repoFactory, options).catch((err) => { + logger.error("Boost initial cleanup failed", { error: String(err) }); + }); + + return { + stop: () => clearInterval(timer), + }; +} + +async function runCleanup( + client: Client, + store: BoosterRoleStore, + repoFactory: (guild: Guild) => RoleRepository, + options: CleanupServiceOptions, +): Promise { + const guilds = client.guilds.cache; + + for (const guild of guilds.values()) { + try { + await cleanupGuild(guild, store, repoFactory(guild), options); + } catch (err) { + logger.error("Boost cleanup failed for guild", { + guildId: guild.id, + error: String(err), + }); + } + } +} + +async function cleanupGuild( + guild: Guild, + store: BoosterRoleStore, + roles: RoleRepository, + options: CleanupServiceOptions, +): Promise { + // Fetch fresh member data to check current booster eligibility + await guild.members.fetch(); + + const records = await store.findByGuild(guild.id); + + for (const record of records) { + const member = guild.members.cache.get(record.userId); + + // Member left the server entirely → remove role + record + if (!member) { + logger.info("Boost cleanup: member left guild, removing role", { + guildId: guild.id, + userId: record.userId, + roleId: record.roleId, + }); + await roles.deleteRole(record.roleId); + await store.delete(guild.id, record.userId); + continue; + } + + // Member still here but no longer has the booster eligibility role + const hasBoosterRole = member.roles.cache.has(options.boosterEligibilityRoleId); + if (!hasBoosterRole) { + logger.info("Boost cleanup: member lost boost, removing role", { + guildId: guild.id, + userId: record.userId, + roleId: record.roleId, + }); + await roles.deleteRole(record.roleId); + await store.delete(guild.id, record.userId); + } + } +} diff --git a/src/services/boosterRoleService.test.ts b/src/services/boosterRoleService.test.ts index 413ef62..40070eb 100644 --- a/src/services/boosterRoleService.test.ts +++ b/src/services/boosterRoleService.test.ts @@ -8,6 +8,10 @@ class MemoryRoleStore { return this.records.get(`${guildId}:${userId}`) ?? null; } + async findByGuild(guildId: string): Promise { + return Array.from(this.records.values()).filter((r) => r.guildId === guildId); + } + async create(record: BoosterRoleRecord): Promise { this.records.set(`${record.guildId}:${record.userId}`, record); } diff --git a/src/services/boosterRoleService.ts b/src/services/boosterRoleService.ts index 9ae9d68..98e7f27 100644 --- a/src/services/boosterRoleService.ts +++ b/src/services/boosterRoleService.ts @@ -22,6 +22,7 @@ export type BoosterRoleRecord = { export type BoosterRoleStore = { findByUser(guildId: string, userId: string): Promise; + findByGuild(guildId: string): Promise; create(record: BoosterRoleRecord): Promise; delete(guildId: string, userId: string): Promise; }; diff --git a/src/services/drizzleBoosterRoleStore.ts b/src/services/drizzleBoosterRoleStore.ts index 5497cef..6861045 100644 --- a/src/services/drizzleBoosterRoleStore.ts +++ b/src/services/drizzleBoosterRoleStore.ts @@ -2,13 +2,17 @@ import { and, eq } from "drizzle-orm"; import { boosterRoles } from "../db/schema"; import type { BoosterRoleRecord, BoosterRoleStore } from "./boosterRoleService"; +type SelectQuery = { + where(condition: unknown): QueryWithLimit & Promise; +}; + +type QueryWithLimit = { + limit(count: number): Promise | BoosterRoleRecord[]; +}; + type DatabaseLike = { select(): { - from(table: typeof boosterRoles): { - where(condition: unknown): { - limit(count: number): Promise | BoosterRoleRecord[]; - }; - }; + from(table: typeof boosterRoles): SelectQuery; }; insert(table: typeof boosterRoles): { values(record: BoosterRoleRecord): Promise | unknown; @@ -31,6 +35,13 @@ export class DrizzleBoosterRoleStore implements BoosterRoleStore { return rows[0] ?? null; } + async findByGuild(guildId: string): Promise { + return await this.db + .select() + .from(boosterRoles) + .where(eq(boosterRoles.guildId, guildId)); + } + async create(record: BoosterRoleRecord): Promise { await this.db.insert(boosterRoles).values(record); }