diff --git a/.gitignore b/.gitignore index af1f8a9..067cc48 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ coverage/ .env .env.* !.env.example +config/verifications.yaml .DS_Store .idea/ .vscode/ diff --git a/src/events/ready.ts b/src/events/ready.ts index 4d30764..f25fa78 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,9 +1,10 @@ import { type Client, Events } from "discord.js"; import type { Config } from "../config.js"; import { logger } from "../logger.js"; +import { type DiscordRestLike, runSweep } from "../sweep.js"; -export function registerReady(client: Client, cfg: Config): void { - client.once(Events.ClientReady, (readyClient) => { +export function registerReady(client: Client, cfg: Config, rest: DiscordRestLike): void { + client.once(Events.ClientReady, async (readyClient) => { const guilds = readyClient.guilds.cache.map((g) => ({ id: g.id, name: g.name })); logger.info( { @@ -14,5 +15,16 @@ export function registerReady(client: Client, cfg: Config): void { }, "ready", ); + + if (!cfg.sweep.on_startup) return; + + for (const v of cfg.verifications) { + try { + const result = await runSweep(rest, v); + logger.info({ verification: v.name, ...result }, "sweep complete"); + } catch (err) { + logger.error({ err, verification: v.name }, "sweep failed"); + } + } }); } diff --git a/src/index.ts b/src/index.ts index 1c73919..61cafb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { loadConfig } from "./config.js"; import { loadEnv } from "./env.js"; import { registerReady } from "./events/ready.js"; import { logger } from "./logger.js"; +import { createDiscordRest } from "./rest.js"; async function main(): Promise { const env = loadEnv(); @@ -13,7 +14,8 @@ async function main(): Promise { ); const client = createClient(); - registerReady(client, cfg); + const rest = createDiscordRest(client.rest); + registerReady(client, cfg, rest); client.on("error", (err) => { logger.error({ err }, "client error"); diff --git a/src/rest.ts b/src/rest.ts new file mode 100644 index 0000000..62ccc6e --- /dev/null +++ b/src/rest.ts @@ -0,0 +1,36 @@ +import { DiscordAPIError, type REST, Routes } from "discord.js"; +import type { DiscordRestLike, MemberInfo, Reactor } from "./sweep.js"; + +const UNKNOWN_MEMBER = 10007; + +export function createDiscordRest(rest: REST): DiscordRestLike { + return { + async listReactors({ channelId, messageId, emoji, after, limit = 100 }) { + const query = new URLSearchParams({ limit: String(limit) }); + if (after) query.set("after", after); + const data = (await rest.get( + Routes.channelMessageReaction(channelId, messageId, encodeURIComponent(emoji)), + { query }, + )) as Array<{ id: string; username: string }>; + return data.map((u) => ({ id: u.id, username: u.username })); + }, + + async getMember({ guildId, userId }) { + try { + const data = (await rest.get(Routes.guildMember(guildId, userId))) as { + user: { id: string }; + roles: string[]; + }; + const member: MemberInfo = { id: data.user.id, roles: data.roles }; + return member; + } catch (err) { + if (err instanceof DiscordAPIError && err.code === UNKNOWN_MEMBER) return null; + throw err; + } + }, + + async addMemberRole({ guildId, userId, roleId }) { + await rest.put(Routes.guildMemberRole(guildId, userId, roleId)); + }, + }; +} diff --git a/src/sweep.ts b/src/sweep.ts new file mode 100644 index 0000000..62e4159 --- /dev/null +++ b/src/sweep.ts @@ -0,0 +1,125 @@ +import type { Verification } from "./config.js"; +import { logger } from "./logger.js"; + +export interface SweepResult { + reactors: number; + granted: number; + alreadyHadRole: number; + notInGuild: number; + errors: number; +} + +export interface Reactor { + id: string; + username: string; +} + +export interface MemberInfo { + id: string; + roles: readonly string[]; +} + +export interface DiscordRestLike { + listReactors(opts: { + channelId: string; + messageId: string; + emoji: string; + after?: string; + limit?: number; + }): Promise; + // 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; +} + +const PAGE_SIZE = 100; + +export async function runSweep(rest: DiscordRestLike, v: Verification): Promise { + const result: SweepResult = { + reactors: 0, + granted: 0, + alreadyHadRole: 0, + notInGuild: 0, + errors: 0, + }; + + const reactors = await collectReactors(rest, v); + result.reactors = reactors.length; + + for (const reactor of reactors) { + const outcome = await processReactor(rest, v, reactor); + if (outcome === "granted") result.granted += 1; + else if (outcome === "already") result.alreadyHadRole += 1; + else if (outcome === "notInGuild") result.notInGuild += 1; + else result.errors += 1; + } + + return result; +} + +async function collectReactors(rest: DiscordRestLike, v: Verification): Promise { + const all: Reactor[] = []; + let after: string | undefined; + let done = false; + while (!done) { + const page = await rest.listReactors({ + channelId: v.channel_id, + messageId: v.message_id, + emoji: v.emoji, + ...(after === undefined ? {} : { after }), + limit: PAGE_SIZE, + }); + all.push(...page); + if (page.length < PAGE_SIZE) { + done = true; + } else { + after = page[page.length - 1]?.id; + } + } + return all; +} + +type Outcome = "granted" | "already" | "notInGuild" | "error"; + +async function processReactor( + rest: DiscordRestLike, + v: Verification, + reactor: Reactor, +): Promise { + let member: MemberInfo | null; + try { + member = await rest.getMember({ guildId: v.guild_id, userId: reactor.id }); + } catch (err) { + logger.error( + { err, verification: v.name, userId: reactor.id, username: reactor.username }, + "sweep: failed to fetch member", + ); + return "error"; + } + if (!member) return "notInGuild"; + if (member.roles.includes(v.role_id)) return "already"; + + try { + await rest.addMemberRole({ + guildId: v.guild_id, + userId: reactor.id, + roleId: v.role_id, + }); + logger.info( + { + verification: v.name, + userId: reactor.id, + username: reactor.username, + roleId: v.role_id, + }, + "sweep: granted role", + ); + return "granted"; + } catch (err) { + logger.error( + { err, verification: v.name, userId: reactor.id, username: reactor.username }, + "sweep: failed to add role", + ); + return "error"; + } +} diff --git a/test/sweep.test.ts b/test/sweep.test.ts new file mode 100644 index 0000000..08e1fb4 --- /dev/null +++ b/test/sweep.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Verification } from "../src/config.js"; +import { type DiscordRestLike, type Reactor, runSweep } from "../src/sweep.js"; + +const verification: Verification = { + name: "test", + guild_id: "1000000000000000001", + channel_id: "1000000000000000002", + message_id: "1000000000000000003", + emoji: "✅", + role_id: "1000000000000000004", + on_remove: "keep", +}; + +function reactor(id: string): Reactor { + return { id, username: `user${id}` }; +} + +function mockRest(overrides: Partial = {}): DiscordRestLike { + return { + listReactors: vi.fn().mockResolvedValue([]), + getMember: vi.fn().mockResolvedValue(null), + addMemberRole: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe("runSweep", () => { + it("returns zeroed counts when no reactors", async () => { + const result = await runSweep(mockRest(), verification); + expect(result).toEqual({ + reactors: 0, + granted: 0, + alreadyHadRole: 0, + notInGuild: 0, + errors: 0, + }); + }); + + it("grants role to a reactor who doesn't have it", async () => { + const rest = mockRest({ + listReactors: vi + .fn() + .mockResolvedValueOnce([reactor("1")]) + .mockResolvedValueOnce([]), + getMember: vi.fn().mockResolvedValue({ id: "1", roles: [] }), + }); + const result = await runSweep(rest, verification); + expect(result.granted).toBe(1); + expect(result.reactors).toBe(1); + expect(rest.addMemberRole).toHaveBeenCalledWith({ + guildId: verification.guild_id, + userId: "1", + roleId: verification.role_id, + }); + }); + + it("skips a reactor who already has the role", async () => { + const rest = mockRest({ + listReactors: vi + .fn() + .mockResolvedValueOnce([reactor("1")]) + .mockResolvedValueOnce([]), + getMember: vi.fn().mockResolvedValue({ id: "1", roles: [verification.role_id] }), + }); + const result = await runSweep(rest, verification); + expect(result.alreadyHadRole).toBe(1); + expect(result.granted).toBe(0); + expect(rest.addMemberRole).not.toHaveBeenCalled(); + }); + + it("counts reactors who left the guild", async () => { + const rest = mockRest({ + listReactors: vi + .fn() + .mockResolvedValueOnce([reactor("1")]) + .mockResolvedValueOnce([]), + getMember: vi.fn().mockResolvedValue(null), + }); + const result = await runSweep(rest, verification); + expect(result.notInGuild).toBe(1); + expect(rest.addMemberRole).not.toHaveBeenCalled(); + }); + + it("paginates through multiple pages", async () => { + const page1 = Array.from({ length: 100 }, (_, i) => reactor(String(i + 1))); + const page2 = [reactor("101"), reactor("102")]; + const listReactors = vi.fn().mockResolvedValueOnce(page1).mockResolvedValueOnce(page2); + const rest = mockRest({ + listReactors, + getMember: vi.fn().mockResolvedValue({ id: "x", roles: [] }), + }); + const result = await runSweep(rest, verification); + expect(result.reactors).toBe(102); + expect(result.granted).toBe(102); + expect(listReactors).toHaveBeenCalledTimes(2); + expect(listReactors.mock.calls[1]?.[0]).toMatchObject({ after: "100" }); + }); + + it("stops paginating when an exact-multiple page is followed by an empty page", async () => { + const page1 = Array.from({ length: 100 }, (_, i) => reactor(String(i + 1))); + const listReactors = vi.fn().mockResolvedValueOnce(page1).mockResolvedValueOnce([]); + const rest = mockRest({ + listReactors, + getMember: vi.fn().mockResolvedValue({ id: "x", roles: [verification.role_id] }), + }); + const result = await runSweep(rest, verification); + expect(result.reactors).toBe(100); + expect(listReactors).toHaveBeenCalledTimes(2); + }); + + it("counts errors when getMember throws", async () => { + const rest = mockRest({ + listReactors: vi + .fn() + .mockResolvedValueOnce([reactor("1")]) + .mockResolvedValueOnce([]), + getMember: vi.fn().mockRejectedValue(new Error("boom")), + }); + const result = await runSweep(rest, verification); + expect(result.errors).toBe(1); + expect(result.granted).toBe(0); + }); + + it("counts errors when addMemberRole throws", async () => { + const rest = mockRest({ + listReactors: vi + .fn() + .mockResolvedValueOnce([reactor("1")]) + .mockResolvedValueOnce([]), + getMember: vi.fn().mockResolvedValue({ id: "1", roles: [] }), + addMemberRole: vi.fn().mockRejectedValue(new Error("forbidden")), + }); + const result = await runSweep(rest, verification); + expect(result.errors).toBe(1); + expect(result.granted).toBe(0); + }); + + it("mixes outcomes across many reactors correctly", async () => { + const reactors = [reactor("1"), reactor("2"), reactor("3"), reactor("4")]; + const rest = mockRest({ + listReactors: vi.fn().mockResolvedValueOnce(reactors).mockResolvedValueOnce([]), + getMember: vi + .fn() + .mockResolvedValueOnce({ id: "1", roles: [] }) // grant + .mockResolvedValueOnce({ id: "2", roles: [verification.role_id] }) // already + .mockResolvedValueOnce(null) // not in guild + .mockRejectedValueOnce(new Error("boom")), // error + }); + const result = await runSweep(rest, verification); + expect(result).toEqual({ + reactors: 4, + granted: 1, + alreadyHadRole: 1, + notInGuild: 1, + errors: 1, + }); + }); +});