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