feat: add booster eligibility role and logging functionality

- Added BOOSTER_ELIGIBILITY_ROLE_ID and LOG_LEVEL to .env.example and README.md
- Updated GitHub Actions workflow to include new environment variables
- Enhanced configuration loading to support booster eligibility role
- Implemented role checking logic in bot handlers and guild member updates
- Introduced logging using Winston for better traceability of actions
- Created tests for guild member update handling to ensure correct role removal
- Refactored DiscordRoleRepository to simplify role position resolution
This commit is contained in:
MythEclipse
2026-05-21 15:27:10 +07:00
parent bfa91063ff
commit cc6975825f
13 changed files with 162 additions and 22 deletions
+3 -1
View File
@@ -4,6 +4,7 @@ export type AppConfig = {
discordGuildId: string;
databaseUrl: string;
boosterRoleAnchorRoleId: string | null;
boosterEligibilityRoleId: string;
};
export function loadConfig(env: Record<string, string | undefined> = process.env): AppConfig {
@@ -12,7 +13,8 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
discordClientId: requireEnv(env, "DISCORD_CLIENT_ID"),
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
boosterRoleAnchorRoleId: env.BOOSTER_ROLE_ANCHOR_ROLE_ID ?? null,
boosterEligibilityRoleId: env.BOOSTER_ELIGIBILITY_ROLE_ID ?? "1206431347925852162"
};
}
+22 -4
View File
@@ -21,7 +21,7 @@ export function attachBotHandlers(client: Client, config: AppConfig): void {
);
await handleInteraction(interaction, service, {
isBoosting: async () => Boolean(interaction.member && "premiumSince" in interaction.member && interaction.member.premiumSince)
isBoosting: async () => memberHasRole(interaction.member, config.boosterEligibilityRoleId)
});
});
@@ -32,13 +32,31 @@ export function attachBotHandlers(client: Client, config: AppConfig): void {
{ anchorPosition: resolveAnchorPosition(newMember.guild, config.boosterRoleAnchorRoleId) }
);
await handleGuildMemberUpdate(oldMember, newMember, service);
await handleGuildMemberUpdate(oldMember, newMember, service, config.boosterEligibilityRoleId);
});
}
function memberHasRole(member: unknown, roleId: string): boolean {
return Boolean(
member &&
typeof member === "object" &&
"roles" in member &&
member.roles &&
typeof member.roles === "object" &&
"cache" in member.roles &&
member.roles.cache &&
typeof member.roles.cache === "object" &&
"has" in member.roles.cache &&
typeof member.roles.cache.has === "function" &&
member.roles.cache.has(roleId)
);
}
function resolveAnchorPosition(guild: { members: { me: { roles: { highest: { position: number } } } | null }; roles: { cache: { get(id: string): { position: number } | undefined } } }, anchorRoleId: string | null): number {
if (!anchorRoleId) return botRolePosition(guild);
return guild.roles.cache.get(anchorRoleId)?.position ?? botRolePosition(guild);
const botPosition = botRolePosition(guild);
if (!anchorRoleId) return botPosition;
const anchorPosition = guild.roles.cache.get(anchorRoleId)?.position ?? botPosition;
return Math.min(anchorPosition, botPosition);
}
function botRolePosition(guild: { members: { me: { roles: { highest: { position: number } } } | null } }): number {
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { handleGuildMemberUpdate } from "./guildMemberUpdate";
class FakeService {
removed: Array<{ guildId: string; userId: string }> = [];
async removeRoleForLostBoost(input: { guildId: string; userId: string }): Promise<void> {
this.removed.push(input);
}
}
function member(hasRole: boolean) {
return {
id: "user",
guild: { id: "guild" },
roles: { cache: { has: (roleId: string) => roleId === "booster-role" && hasRole } }
};
}
describe("handleGuildMemberUpdate", () => {
test("removes custom role when booster eligibility role is removed", async () => {
const service = new FakeService();
await handleGuildMemberUpdate(member(true), member(false), service, "booster-role");
expect(service.removed).toEqual([{ guildId: "guild", userId: "user" }]);
});
test("does nothing while booster eligibility role remains", async () => {
const service = new FakeService();
await handleGuildMemberUpdate(member(true), member(true), service, "booster-role");
expect(service.removed).toEqual([]);
});
});
+14 -7
View File
@@ -1,16 +1,23 @@
import type { BoosterRoleService } from "../../services/boosterRoleService";
type GuildMemberLike = {
id: string;
premiumSince: Date | null;
guild: { id: string };
roles: { cache: { has(roleId: string): boolean } };
};
export async function handleGuildMemberUpdate(oldMember: GuildMemberLike, newMember: GuildMemberLike, service: BoosterRoleService): Promise<void> {
const hadBoost = Boolean(oldMember.premiumSince);
const hasBoost = Boolean(newMember.premiumSince);
type BoosterRoleRemovalService = {
removeRoleForLostBoost(input: { guildId: string; userId: string }): Promise<void>;
};
if (hadBoost && !hasBoost) {
export async function handleGuildMemberUpdate(
oldMember: GuildMemberLike,
newMember: GuildMemberLike,
service: BoosterRoleRemovalService,
boosterEligibilityRoleId: string
): Promise<void> {
const hadBoosterRole = oldMember.roles.cache.has(boosterEligibilityRoleId);
const hasBoosterRole = newMember.roles.cache.has(boosterEligibilityRoleId);
if (hadBoosterRole && !hasBoosterRole) {
await service.removeRoleForLostBoost({ guildId: newMember.guild.id, userId: newMember.id });
}
}
+10 -1
View File
@@ -1,4 +1,5 @@
import { MessageFlags, PermissionFlagsBits } from "discord.js";
import { logger } from "../logger";
import type { BoosterRoleRecord, RoleIcon } from "../services/boosterRoleService";
export type ChatInputInteractionLike = {
@@ -41,6 +42,7 @@ export async function handleInteraction(
const subcommand = interaction.options.getSubcommand();
if (subcommand === "claim") {
logger.info("Handling booster-role command", { guildId, userId, subcommand });
const role = await service.claimRole({
guildId,
userId,
@@ -54,24 +56,28 @@ export async function handleInteraction(
}
if (subcommand === "rename") {
logger.info("Handling booster-role command", { guildId, userId, subcommand });
await service.renameRole({ guildId, userId, name: requireString(interaction, "name") });
await interaction.reply({ content: "Booster role renamed.", flags: MessageFlags.Ephemeral });
return;
}
if (subcommand === "recolor") {
logger.info("Handling booster-role command", { guildId, userId, subcommand });
await service.recolorRole({ guildId, userId, color: requireString(interaction, "color") });
await interaction.reply({ content: "Booster role color updated.", flags: MessageFlags.Ephemeral });
return;
}
if (subcommand === "icon") {
logger.info("Handling booster-role command", { guildId, userId, subcommand });
await service.setRoleIcon({ guildId, userId, icon: requireIcon(interaction, "image") });
await interaction.reply({ content: "Booster role icon updated.", flags: MessageFlags.Ephemeral });
return;
}
if (subcommand === "delete") {
logger.info("Handling booster-role command", { guildId, userId, subcommand });
await service.deleteRole({ guildId, userId });
await interaction.reply({ content: "Booster role deleted.", flags: MessageFlags.Ephemeral });
return;
@@ -79,13 +85,16 @@ export async function handleInteraction(
if (subcommand === "admin-delete") {
requireAdministrator(interaction);
await service.deleteRole({ guildId, userId: requireUser(interaction, "user").id });
const targetUserId = requireUser(interaction, "user").id;
logger.info("Handling booster-role admin command", { guildId, userId, subcommand, targetUserId });
await service.deleteRole({ guildId, userId: targetUserId });
await interaction.reply({ content: "Booster role deleted by admin.", flags: MessageFlags.Ephemeral });
return;
}
throw new Error("Unknown booster-role subcommand");
} catch (error) {
logger.warn("Booster-role command failed", { error });
await interaction.reply({ content: error instanceof Error ? error.message : "Command failed", flags: MessageFlags.Ephemeral });
}
}
+5 -1
View File
@@ -2,18 +2,22 @@ import { loadConfig } from "./config";
import { attachBotHandlers } from "./discord/bot";
import { createDiscordClient } from "./discord/client";
import { registerGuildCommandsWithToken } from "./discord/registerCommands";
import { logger } from "./logger";
const config = loadConfig();
logger.info("Registering guild commands", { guildId: config.discordGuildId });
await registerGuildCommandsWithToken(config.discordToken, {
clientId: config.discordClientId,
guildId: config.discordGuildId
});
logger.info("Guild commands registered", { guildId: config.discordGuildId });
const client = createDiscordClient();
attachBotHandlers(client, config);
client.once("clientReady", () => {
console.log(`Logged in as ${client.user?.tag ?? "unknown bot"}`);
logger.info("Discord client ready", { bot: client.user?.tag ?? "unknown bot" });
});
logger.info("Logging in Discord client");
await client.login(config.discordToken);
+11
View File
@@ -0,0 +1,11 @@
import winston from "winston";
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL ?? "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [new winston.transports.Console()]
});
+1 -4
View File
@@ -40,10 +40,7 @@ export class DiscordRoleRepository implements RoleRepository {
}
private async resolvePosition(fallbackPosition: number): Promise<number> {
if (!this.anchorRoleId) return fallbackPosition;
const anchor = await this.fetchRole(this.anchorRoleId);
return Math.max(anchor.position, 1);
return Math.max(fallbackPosition, 1);
}
private async fetchRole(roleId: string): Promise<Role> {