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); }