diff --git a/src/client.ts b/src/client.ts index 9d08829..81ee7cf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,11 +1,11 @@ -import { Client, GatewayIntentBits } from "discord.js"; +import { Client, GatewayIntentBits, Partials } from "discord.js"; -// Step 3 only needs the `Guilds` intent to log a list of connected guilds on READY. -// Reaction handlers (step 5) will add `GuildMessageReactions` and the privileged -// `GuildMembers` intent (which must be toggled in the Discord Developer Portal), -// along with the `Message`/`Channel`/`Reaction` partials needed for old messages. export function createClient(): Client { return new Client({ - intents: [GatewayIntentBits.Guilds], + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessageReactions], + // Partials let the bot receive reaction events on messages older than its uptime. + // Partials.User is required for MessageReactionRemove on uncached users — the gateway + // payload only contains user_id, not the full user/member object that the add event ships. + partials: [Partials.Message, Partials.Channel, Partials.Reaction, Partials.User], }); } diff --git a/src/events/reactionAdd.ts b/src/events/reactionAdd.ts new file mode 100644 index 0000000..9e2500a --- /dev/null +++ b/src/events/reactionAdd.ts @@ -0,0 +1,79 @@ +import { + type Client, + Events, + type MessageReaction, + type PartialMessageReaction, + type PartialUser, + type User, +} from "discord.js"; +import type { Config } from "../config.js"; +import { logger } from "../logger.js"; +import type { DiscordRestLike } from "../sweep.js"; +import { emojiToString, findMatchingVerification } from "./shared.js"; + +export function registerReactionAdd(client: Client, cfg: Config, rest: DiscordRestLike): void { + client.on(Events.MessageReactionAdd, (reaction, user) => { + void handleReactionAdd(cfg, rest, reaction, user); + }); +} + +async function handleReactionAdd( + cfg: Config, + rest: DiscordRestLike, + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, +): Promise { + if (user.bot) return; + + try { + if (reaction.partial) await reaction.fetch(); + } catch (err) { + logger.error({ err }, "reactionAdd: failed to fetch reaction"); + return; + } + + const { message } = reaction; + const guildId = message.guildId; + if (!guildId) return; + + const verification = findMatchingVerification(cfg.verifications, { + guildId, + channelId: message.channelId, + messageId: message.id, + emoji: emojiToString(reaction.emoji), + }); + if (!verification) return; + + logger.debug( + { verification: verification.name, userId: user.id, username: user.username ?? null }, + "reactionAdd: matched", + ); + + try { + const member = await rest.getMember({ guildId, userId: user.id }); + if (!member) { + logger.warn( + { verification: verification.name, userId: user.id }, + "reactionAdd: user not in guild", + ); + return; + } + if (member.roles.includes(verification.role_id)) { + logger.debug( + { verification: verification.name, userId: user.id }, + "reactionAdd: already has role", + ); + return; + } + await rest.addMemberRole({ guildId, userId: user.id, roleId: verification.role_id }); + logger.info( + { verification: verification.name, userId: user.id, username: user.username ?? null }, + "reactionAdd: granted role", + ); + } catch (err) { + logger.error( + { err, verification: verification.name, userId: user.id }, + "reactionAdd: failed to grant role", + ); + } +} diff --git a/src/events/reactionRemove.ts b/src/events/reactionRemove.ts new file mode 100644 index 0000000..d1e690d --- /dev/null +++ b/src/events/reactionRemove.ts @@ -0,0 +1,74 @@ +import { + type Client, + Events, + type MessageReaction, + type PartialMessageReaction, + type PartialUser, + type User, +} from "discord.js"; +import type { Config } from "../config.js"; +import { logger } from "../logger.js"; +import type { DiscordRestLike } from "../sweep.js"; +import { emojiToString, findMatchingVerification } from "./shared.js"; + +export function registerReactionRemove(client: Client, cfg: Config, rest: DiscordRestLike): void { + client.on(Events.MessageReactionRemove, (reaction, user) => { + void handleReactionRemove(cfg, rest, reaction, user); + }); +} + +async function handleReactionRemove( + cfg: Config, + rest: DiscordRestLike, + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, +): Promise { + if (user.bot) return; + + try { + if (reaction.partial) await reaction.fetch(); + } catch (err) { + logger.error({ err }, "reactionRemove: failed to fetch reaction"); + return; + } + + const { message } = reaction; + const guildId = message.guildId; + if (!guildId) return; + + const verification = findMatchingVerification(cfg.verifications, { + guildId, + channelId: message.channelId, + messageId: message.id, + emoji: emojiToString(reaction.emoji), + }); + if (!verification) return; + + logger.debug( + { + verification: verification.name, + userId: user.id, + username: user.username ?? null, + on_remove: verification.on_remove, + }, + "reactionRemove: matched", + ); + + if (verification.on_remove === "keep") return; + + try { + const member = await rest.getMember({ guildId, userId: user.id }); + if (!member) return; + if (!member.roles.includes(verification.role_id)) return; + await rest.removeMemberRole({ guildId, userId: user.id, roleId: verification.role_id }); + logger.info( + { verification: verification.name, userId: user.id, username: user.username ?? null }, + "reactionRemove: revoked role", + ); + } catch (err) { + logger.error( + { err, verification: verification.name, userId: user.id }, + "reactionRemove: failed to revoke role", + ); + } +} diff --git a/src/events/shared.ts b/src/events/shared.ts new file mode 100644 index 0000000..b7ccde4 --- /dev/null +++ b/src/events/shared.ts @@ -0,0 +1,27 @@ +import type { Verification } from "../config.js"; + +export interface ResolvedReaction { + guildId: string; + channelId: string; + messageId: string; + emoji: string; +} + +export function emojiToString(emoji: { name: string | null; id: string | null }): string { + // Custom emoji: "name:id"; unicode: the character itself. + if (emoji.id) return `${emoji.name ?? "_"}:${emoji.id}`; + return emoji.name ?? ""; +} + +export function findMatchingVerification( + verifications: readonly Verification[], + resolved: ResolvedReaction, +): Verification | undefined { + return verifications.find( + (v) => + v.guild_id === resolved.guildId && + v.channel_id === resolved.channelId && + v.message_id === resolved.messageId && + v.emoji === resolved.emoji, + ); +} diff --git a/src/index.ts b/src/index.ts index 61cafb9..85b700b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ import { createClient } from "./client.js"; import { loadConfig } from "./config.js"; import { loadEnv } from "./env.js"; +import { registerReactionAdd } from "./events/reactionAdd.js"; +import { registerReactionRemove } from "./events/reactionRemove.js"; import { registerReady } from "./events/ready.js"; import { logger } from "./logger.js"; import { createDiscordRest } from "./rest.js"; @@ -16,6 +18,8 @@ async function main(): Promise { const client = createClient(); const rest = createDiscordRest(client.rest); registerReady(client, cfg, rest); + registerReactionAdd(client, cfg, rest); + registerReactionRemove(client, cfg, rest); client.on("error", (err) => { logger.error({ err }, "client error"); diff --git a/src/rest.ts b/src/rest.ts index 62ccc6e..5d2b69d 100644 --- a/src/rest.ts +++ b/src/rest.ts @@ -32,5 +32,9 @@ export function createDiscordRest(rest: REST): DiscordRestLike { async addMemberRole({ guildId, userId, roleId }) { await rest.put(Routes.guildMemberRole(guildId, userId, roleId)); }, + + async removeMemberRole({ guildId, userId, roleId }) { + await rest.delete(Routes.guildMemberRole(guildId, userId, roleId)); + }, }; } diff --git a/src/sweep.ts b/src/sweep.ts index 62e4159..45f9724 100644 --- a/src/sweep.ts +++ b/src/sweep.ts @@ -30,6 +30,7 @@ export interface DiscordRestLike { // Returns null when the user is not a member of the guild. getMember(opts: { guildId: string; userId: string }): Promise; addMemberRole(opts: { guildId: string; userId: string; roleId: string }): Promise; + removeMemberRole(opts: { guildId: string; userId: string; roleId: string }): Promise; } const PAGE_SIZE = 100; diff --git a/test/events/shared.test.ts b/test/events/shared.test.ts new file mode 100644 index 0000000..7a0fbc4 --- /dev/null +++ b/test/events/shared.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { Verification } from "../../src/config.js"; +import { emojiToString, findMatchingVerification } from "../../src/events/shared.js"; + +const baseV: Verification = { + name: "v1", + guild_id: "g", + channel_id: "c", + message_id: "m", + emoji: "✅", + role_id: "r", + on_remove: "keep", +}; + +describe("emojiToString", () => { + it("returns the name for a unicode emoji", () => { + expect(emojiToString({ name: "✅", id: null })).toBe("✅"); + }); + + it("returns 'name:id' for a custom emoji", () => { + expect(emojiToString({ name: "thumbsup", id: "123" })).toBe("thumbsup:123"); + }); + + it("falls back to '_' when a custom emoji has no name", () => { + expect(emojiToString({ name: null, id: "123" })).toBe("_:123"); + }); + + it("returns an empty string when both fields are null", () => { + expect(emojiToString({ name: null, id: null })).toBe(""); + }); +}); + +describe("findMatchingVerification", () => { + const resolved = { guildId: "g", channelId: "c", messageId: "m", emoji: "✅" }; + + it("returns the verification on an exact match", () => { + expect(findMatchingVerification([baseV], resolved)).toBe(baseV); + }); + + it("returns undefined when guild differs", () => { + expect(findMatchingVerification([baseV], { ...resolved, guildId: "other" })).toBeUndefined(); + }); + + it("returns undefined when channel differs", () => { + expect(findMatchingVerification([baseV], { ...resolved, channelId: "other" })).toBeUndefined(); + }); + + it("returns undefined when message differs", () => { + expect(findMatchingVerification([baseV], { ...resolved, messageId: "other" })).toBeUndefined(); + }); + + it("returns undefined when emoji differs", () => { + expect(findMatchingVerification([baseV], { ...resolved, emoji: "❌" })).toBeUndefined(); + }); + + it("picks the right one out of several", () => { + const v2: Verification = { ...baseV, name: "v2", message_id: "m2", emoji: "❌" }; + expect( + findMatchingVerification([baseV, v2], { ...resolved, messageId: "m2", emoji: "❌" }), + ).toBe(v2); + }); + + it("returns undefined on an empty list", () => { + expect(findMatchingVerification([], resolved)).toBeUndefined(); + }); +}); diff --git a/test/sweep.test.ts b/test/sweep.test.ts index 08e1fb4..79b3079 100644 --- a/test/sweep.test.ts +++ b/test/sweep.test.ts @@ -21,6 +21,7 @@ function mockRest(overrides: Partial = {}): DiscordRestLike { listReactors: vi.fn().mockResolvedValue([]), getMember: vi.fn().mockResolvedValue(null), addMemberRole: vi.fn().mockResolvedValue(undefined), + removeMemberRole: vi.fn().mockResolvedValue(undefined), ...overrides, }; }