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`
This commit is contained in:
MythEclipse
2026-06-06 02:46:38 +07:00
parent 01450d976c
commit 57699d60a8
6 changed files with 142 additions and 7 deletions
+3 -1
View File
@@ -5,6 +5,7 @@ export type AppConfig = {
databaseUrl: string;
boosterRoleAnchorRoleId: string | null;
boosterEligibilityRoleId: string;
boostCleanupIntervalMs: number;
};
export function loadConfig(env: Record<string, string | undefined> = process.env): AppConfig {
@@ -14,7 +15,8 @@ export function loadConfig(env: Record<string, string | undefined> = 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
};
}
+21 -1
View File
@@ -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);
+97
View File
@@ -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<void> {
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<void> {
// 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);
}
}
}
+4
View File
@@ -8,6 +8,10 @@ class MemoryRoleStore {
return this.records.get(`${guildId}:${userId}`) ?? null;
}
async findByGuild(guildId: string): Promise<BoosterRoleRecord[]> {
return Array.from(this.records.values()).filter((r) => r.guildId === guildId);
}
async create(record: BoosterRoleRecord): Promise<void> {
this.records.set(`${record.guildId}:${record.userId}`, record);
}
+1
View File
@@ -22,6 +22,7 @@ export type BoosterRoleRecord = {
export type BoosterRoleStore = {
findByUser(guildId: string, userId: string): Promise<BoosterRoleRecord | null>;
findByGuild(guildId: string): Promise<BoosterRoleRecord[]>;
create(record: BoosterRoleRecord): Promise<void>;
delete(guildId: string, userId: string): Promise<void>;
};
+16 -5
View File
@@ -2,13 +2,17 @@ import { and, eq } from "drizzle-orm";
import { boosterRoles } from "../db/schema";
import type { BoosterRoleRecord, BoosterRoleStore } from "./boosterRoleService";
type DatabaseLike = {
select(): {
from(table: typeof boosterRoles): {
where(condition: unknown): {
type SelectQuery = {
where(condition: unknown): QueryWithLimit & Promise<BoosterRoleRecord[]>;
};
type QueryWithLimit = {
limit(count: number): Promise<BoosterRoleRecord[]> | BoosterRoleRecord[];
};
};
type DatabaseLike = {
select(): {
from(table: typeof boosterRoles): SelectQuery;
};
insert(table: typeof boosterRoles): {
values(record: BoosterRoleRecord): Promise<unknown> | unknown;
@@ -31,6 +35,13 @@ export class DrizzleBoosterRoleStore implements BoosterRoleStore {
return rows[0] ?? null;
}
async findByGuild(guildId: string): Promise<BoosterRoleRecord[]> {
return await this.db
.select()
.from(boosterRoles)
.where(eq(boosterRoles.guildId, guildId));
}
async create(record: BoosterRoleRecord): Promise<void> {
await this.db.insert(boosterRoles).values(record);
}