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`
203 lines
6.7 KiB
TypeScript
203 lines
6.7 KiB
TypeScript
import { assertBoostEligibility } from "../domain/boostEligibility";
|
|
import {
|
|
assertRoleNameIsAvailable,
|
|
assertRolePositionIsSafe,
|
|
normalizeHexColor,
|
|
normalizeOptionalHexColor,
|
|
validateRoleName,
|
|
type ExistingRole
|
|
} from "../domain/roleGuards";
|
|
|
|
export type BoosterRoleRecord = {
|
|
guildId: string;
|
|
userId: string;
|
|
roleId: string;
|
|
name: string;
|
|
color: string | null;
|
|
color2: string | null;
|
|
icon: string | null;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
};
|
|
|
|
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>;
|
|
};
|
|
|
|
export type RoleRepository = {
|
|
listRoles(): Promise<ExistingRole[]>;
|
|
createRole(input: { name: string; color: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; permissions: string[]; position: number }): Promise<{ id: string }>;
|
|
updateRole(roleId: string, input: { name?: string; color?: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; icon?: string | null }): Promise<void>;
|
|
assignRole(userId: string, roleId: string): Promise<void>;
|
|
removeRole(userId: string, roleId: string): Promise<void>;
|
|
deleteRole(roleId: string): Promise<void>;
|
|
};
|
|
|
|
export type RoleIcon = {
|
|
contentType: string;
|
|
size: number;
|
|
dataUri: string;
|
|
};
|
|
|
|
export type BoosterRoleServiceOptions = {
|
|
anchorPosition: number;
|
|
maxIconBytes?: number;
|
|
now?: () => number;
|
|
};
|
|
|
|
export class BoosterRoleService {
|
|
private readonly now: () => number;
|
|
|
|
constructor(
|
|
private readonly store: BoosterRoleStore,
|
|
private readonly roles: RoleRepository,
|
|
private readonly options: BoosterRoleServiceOptions
|
|
) {
|
|
this.now = options.now ?? Date.now;
|
|
}
|
|
|
|
async claimRole(input: {
|
|
guildId: string;
|
|
userId: string;
|
|
name: string;
|
|
color: string | null;
|
|
color2?: string | null;
|
|
icon?: RoleIcon | null;
|
|
isBoosting: boolean;
|
|
}): Promise<BoosterRoleRecord> {
|
|
const { guildId, userId, isBoosting } = input;
|
|
assertBoostEligibility({ isBoosting });
|
|
|
|
const existingRecord = await this.store.findByUser(guildId, userId);
|
|
if (existingRecord) {
|
|
throw new Error("User already has a booster role");
|
|
}
|
|
|
|
const name = validateRoleName(input.name);
|
|
const color = normalizeOptionalHexColor(input.color);
|
|
const color2 = normalizeOptionalHexColor(input.color2 ?? null);
|
|
const colors = resolveGradientColors(color, color2);
|
|
assertRoleNameIsAvailable(name, await this.roles.listRoles());
|
|
|
|
const position = this.options.anchorPosition - 1;
|
|
assertRolePositionIsSafe(position, this.options.anchorPosition);
|
|
|
|
const role = await this.roles.createRole({ name, color, colors, permissions: [], position });
|
|
let assigned = false;
|
|
|
|
try {
|
|
if (input.icon) {
|
|
this.validateRoleIcon(input.icon);
|
|
await this.roles.updateRole(role.id, { icon: input.icon.dataUri });
|
|
}
|
|
|
|
await this.roles.assignRole(userId, role.id);
|
|
assigned = true;
|
|
|
|
const timestamp = this.now();
|
|
const record = {
|
|
guildId,
|
|
userId,
|
|
roleId: role.id,
|
|
name,
|
|
color,
|
|
color2,
|
|
icon: input.icon?.dataUri ?? null,
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp
|
|
};
|
|
|
|
await this.store.create(record);
|
|
return record;
|
|
} catch (error) {
|
|
await this.rollbackClaim({ guildId, userId, roleId: role.id, assigned });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async renameRole(input: { guildId: string; userId: string; name: string }): Promise<void> {
|
|
const { guildId, userId } = input;
|
|
const record = await this.getUserRecord(guildId, userId);
|
|
const name = validateRoleName(input.name);
|
|
assertRoleNameIsAvailable(name, (await this.roles.listRoles()).filter((role) => role.id !== record.roleId));
|
|
await this.roles.updateRole(record.roleId, { name });
|
|
}
|
|
|
|
async recolorRole(input: { guildId: string; userId: string; color: string; color2?: string | null }): Promise<void> {
|
|
const { guildId, userId, color, color2 } = input;
|
|
const record = await this.getUserRecord(guildId, userId);
|
|
const primaryColor = normalizeHexColor(color);
|
|
const secondaryColor = normalizeOptionalHexColor(color2 ?? null);
|
|
const colors = resolveGradientColors(primaryColor, secondaryColor);
|
|
await this.roles.updateRole(record.roleId, { colors });
|
|
}
|
|
|
|
async setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise<void> {
|
|
const { guildId, userId, icon } = input;
|
|
const record = await this.getUserRecord(guildId, userId);
|
|
this.validateRoleIcon(icon);
|
|
await this.roles.updateRole(record.roleId, { icon: icon.dataUri });
|
|
}
|
|
|
|
async deleteRole(input: { guildId: string; userId: string }): Promise<void> {
|
|
const { guildId, userId } = input;
|
|
const record = await this.getUserRecord(guildId, userId);
|
|
await this.roles.deleteRole(record.roleId);
|
|
await this.store.delete(guildId, userId);
|
|
}
|
|
|
|
async removeRoleForLostBoost(input: { guildId: string; userId: string }): Promise<void> {
|
|
const { guildId, userId } = input;
|
|
const record = await this.store.findByUser(guildId, userId);
|
|
if (!record) return;
|
|
|
|
await this.roles.deleteRole(record.roleId);
|
|
await this.store.delete(guildId, userId);
|
|
}
|
|
|
|
private async rollbackClaim(input: { guildId: string; userId: string; roleId: string; assigned: boolean }): Promise<void> {
|
|
if (input.assigned) {
|
|
await ignoreRollbackError(() => this.roles.removeRole(input.userId, input.roleId));
|
|
}
|
|
|
|
await ignoreRollbackError(() => this.roles.deleteRole(input.roleId));
|
|
await ignoreRollbackError(() => this.store.delete(input.guildId, input.userId));
|
|
}
|
|
|
|
private validateRoleIcon(icon: RoleIcon): void {
|
|
if (!icon.contentType.startsWith("image/")) {
|
|
throw new Error("Role icon must be an image");
|
|
}
|
|
|
|
if (icon.size > (this.options.maxIconBytes ?? 512_000)) {
|
|
throw new Error("Role icon is too large");
|
|
}
|
|
}
|
|
|
|
private async getUserRecord(guildId: string, userId: string): Promise<BoosterRoleRecord> {
|
|
const record = await this.store.findByUser(guildId, userId);
|
|
if (!record) {
|
|
throw new Error("No booster role found for this user");
|
|
}
|
|
return record;
|
|
}
|
|
}
|
|
|
|
async function ignoreRollbackError(action: () => Promise<void>): Promise<void> {
|
|
try {
|
|
await action();
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
function resolveGradientColors(color: string | null, color2: string | null): { primaryColor: string; secondaryColor?: string } | null {
|
|
if (!color) return null;
|
|
if (color2) {
|
|
return { primaryColor: color, secondaryColor: color2 };
|
|
}
|
|
return { primaryColor: color };
|
|
}
|