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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ coverage/
.env
.env.*
!.env.example
config/verifications.yaml
.DS_Store
.idea/
.vscode/
Expand Down
16 changes: 14 additions & 2 deletions src/events/ready.ts
Original file line number Diff line number Diff line change
@@ -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(
{
Expand All @@ -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");
}
}
});
}
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const env = loadEnv();
Expand All @@ -13,7 +14,8 @@ async function main(): Promise<void> {
);

const client = createClient();
registerReady(client, cfg);
const rest = createDiscordRest(client.rest);
registerReady(client, cfg, rest);

client.on("error", (err) => {
logger.error({ err }, "client error");
Expand Down
36 changes: 36 additions & 0 deletions src/rest.ts
Original file line number Diff line number Diff line change
@@ -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<Reactor>((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));
},
};
}
125 changes: 125 additions & 0 deletions src/sweep.ts
Original file line number Diff line number Diff line change
@@ -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<Reactor[]>;
// 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>;
}

const PAGE_SIZE = 100;

export async function runSweep(rest: DiscordRestLike, v: Verification): Promise<SweepResult> {
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<Reactor[]> {
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<Outcome> {
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";
}
}
159 changes: 159 additions & 0 deletions test/sweep.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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,
});
});
});
Loading