Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -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],
});
}
79 changes: 79 additions & 0 deletions src/events/reactionAdd.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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",
);
}
}
74 changes: 74 additions & 0 deletions src/events/reactionRemove.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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",
);
}
}
27 changes: 27 additions & 0 deletions src/events/shared.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,6 +18,8 @@ async function main(): Promise<void> {
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");
Expand Down
4 changes: 4 additions & 0 deletions src/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
},
};
}
1 change: 1 addition & 0 deletions src/sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MemberInfo | null>;
addMemberRole(opts: { guildId: string; userId: string; roleId: string }): Promise<void>;
removeMemberRole(opts: { guildId: string; userId: string; roleId: string }): Promise<void>;
}

const PAGE_SIZE = 100;
Expand Down
66 changes: 66 additions & 0 deletions test/events/shared.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
1 change: 1 addition & 0 deletions test/sweep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function mockRest(overrides: Partial<DiscordRestLike> = {}): DiscordRestLike {
listReactors: vi.fn().mockResolvedValue([]),
getMember: vi.fn().mockResolvedValue(null),
addMemberRole: vi.fn().mockResolvedValue(undefined),
removeMemberRole: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
Expand Down
Loading