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:
+3
-1
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user