From 866b33e0d33e6aa04986df3e27aa1b076fa04fb4 Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 20 Feb 2026 20:25:35 -0600 Subject: [PATCH] fix: lazy-load Discord allowlist guilds (#20208) (thanks @zhangjunmengyang) --- CHANGELOG.md | 1 + src/discord/resolve-users.test.ts | 242 ++++++++++++++++++++++++++++++ src/discord/resolve-users.ts | 20 ++- 3 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 src/discord/resolve-users.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6665e7a1b..00998bdf6bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow. - Discord/Streaming: apply `replyToMode: first` only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report. - Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow. +- Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang. - Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow. - Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow. diff --git a/src/discord/resolve-users.test.ts b/src/discord/resolve-users.test.ts new file mode 100644 index 00000000000..78864543c44 --- /dev/null +++ b/src/discord/resolve-users.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; +import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { resolveDiscordUserAllowlist } from "./resolve-users.js"; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { status: 200 }); +} + +const urlToString = (url: Request | URL | string): string => { + if (typeof url === "string") { + return url; + } + return "url" in url ? url.url : String(url); +}; + +describe("resolveDiscordUserAllowlist", () => { + it("resolves plain user ids without calling listGuilds", async () => { + let guildsCalled = false; + const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url.endsWith("/users/@me/guilds")) { + guildsCalled = true; + return jsonResponse([]); + } + return new Response("not found", { status: 404 }); + }); + + const results = await resolveDiscordUserAllowlist({ + token: "test", + entries: ["123456789012345678"], + fetcher, + }); + + expect(results).toEqual([ + { + input: "123456789012345678", + resolved: true, + id: "123456789012345678", + }, + ]); + expect(guildsCalled).toBe(false); + }); + + it("resolves mention-format ids without calling listGuilds", async () => { + let guildsCalled = false; + const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url.endsWith("/users/@me/guilds")) { + guildsCalled = true; + return jsonResponse([]); + } + return new Response("not found", { status: 404 }); + }); + + const results = await resolveDiscordUserAllowlist({ + token: "test", + entries: ["<@!123456789012345678>"], + fetcher, + }); + + expect(results).toEqual([ + { + input: "<@!123456789012345678>", + resolved: true, + id: "123456789012345678", + }, + ]); + expect(guildsCalled).toBe(false); + }); + + it("resolves prefixed ids (user:, discord:) without calling listGuilds", async () => { + let guildsCalled = false; + const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url.endsWith("/users/@me/guilds")) { + guildsCalled = true; + return jsonResponse([]); + } + return new Response("not found", { status: 404 }); + }); + + const results = await resolveDiscordUserAllowlist({ + token: "test", + entries: ["user:111", "discord:222"], + fetcher, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ resolved: true, id: "111" }); + expect(results[1]).toMatchObject({ resolved: true, id: "222" }); + expect(guildsCalled).toBe(false); + }); + + it("resolves user ids even when listGuilds would fail", async () => { + const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url.endsWith("/users/@me/guilds")) { + throw new Error("Forbidden: Missing Access"); + } + return new Response("not found", { status: 404 }); + }); + + // Before the fix, this would throw because listGuilds() was called eagerly + const results = await resolveDiscordUserAllowlist({ + token: "test", + entries: ["994979735488692324"], + fetcher, + }); + + expect(results).toEqual([ + { + input: "994979735488692324", + resolved: true, + id: "994979735488692324", + }, + ]); + }); + + it("calls listGuilds lazily when resolving usernames", async () => { + let guildsCalled = false; + const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url.endsWith("/users/@me/guilds")) { + guildsCalled = true; + return jsonResponse([{ id: "g1", name: "Test Guild" }]); + } + if (url.includes("/guilds/g1/members/search")) { + return jsonResponse([ + { + user: { id: "u1", username: "alice", bot: false }, + nick: null, + }, + ]); + } + return new Response("not found", { status: 404 }); + }); + + const results = await resolveDiscordUserAllowlist({ + token: "test", + entries: ["alice"], + fetcher, + }); + + expect(guildsCalled).toBe(true); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + input: "alice", + resolved: true, + id: "u1", + name: "alice", + }); + }); + + it("fetches guilds only once for multiple username entries", async () => { + let guildsCallCount = 0; + const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url.endsWith("/users/@me/guilds")) { + guildsCallCount++; + return jsonResponse([{ id: "g1", name: "Test Guild" }]); + } + if (url.includes("/guilds/g1/members/search")) { + const params = new URL(url).searchParams; + const query = params.get("query") ?? ""; + return jsonResponse([ + { + user: { id: `u-${query}`, username: query, bot: false }, + nick: null, + }, + ]); + } + return new Response("not found", { status: 404 }); + }); + + const results = await resolveDiscordUserAllowlist({ + token: "test", + entries: ["alice", "bob"], + fetcher, + }); + + expect(guildsCallCount).toBe(1); + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ resolved: true, id: "u-alice" }); + expect(results[1]).toMatchObject({ resolved: true, id: "u-bob" }); + }); + + it("handles mixed ids and usernames — ids resolve even if guilds fail", async () => { + const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url.endsWith("/users/@me/guilds")) { + throw new Error("Forbidden: Missing Access"); + } + return new Response("not found", { status: 404 }); + }); + + // IDs should succeed, username should fail (listGuilds throws) + await expect( + resolveDiscordUserAllowlist({ + token: "test", + entries: ["123456789012345678", "alice"], + fetcher, + }), + ).rejects.toThrow("Forbidden"); + + // But if we only pass IDs, it should work fine + const results = await resolveDiscordUserAllowlist({ + token: "test", + entries: ["123456789012345678", "<@999>"], + fetcher, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ resolved: true, id: "123456789012345678" }); + expect(results[1]).toMatchObject({ resolved: true, id: "999" }); + }); + + it("returns unresolved for empty/blank entries", async () => { + const fetcher = withFetchPreconnect(async () => { + return new Response("not found", { status: 404 }); + }); + + const results = await resolveDiscordUserAllowlist({ + token: "test", + entries: ["", " "], + fetcher, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ resolved: false }); + expect(results[1]).toMatchObject({ resolved: false }); + }); + + it("returns all unresolved when token is empty", async () => { + const results = await resolveDiscordUserAllowlist({ + token: "", + entries: ["123456789012345678", "alice"], + }); + + expect(results).toHaveLength(2); + expect(results.every((r) => !r.resolved)).toBe(true); + }); +}); diff --git a/src/discord/resolve-users.ts b/src/discord/resolve-users.ts index 68c9e1f2034..86450cde644 100644 --- a/src/discord/resolve-users.ts +++ b/src/discord/resolve-users.ts @@ -88,7 +88,18 @@ export async function resolveDiscordUserAllowlist(params: { })); } const fetcher = params.fetcher ?? fetch; - const guilds = await listGuilds(token, fetcher); + + // Lazy-load guilds: only fetch when an entry actually needs username search. + // This prevents listGuilds() failures (permissions, network) from blocking + // resolution of plain user-id entries that don't need guild data at all. + let guilds: DiscordGuildSummary[] | null = null; + const getGuilds = async (): Promise => { + if (!guilds) { + guilds = await listGuilds(token, fetcher); + } + return guilds; + }; + const results: DiscordUserResolution[] = []; for (const input of params.entries) { @@ -109,11 +120,12 @@ export async function resolveDiscordUserAllowlist(params: { } const guildName = parsed.guildName?.trim(); + const allGuilds = await getGuilds(); const guildList = parsed.guildId - ? guilds.filter((g) => g.id === parsed.guildId) + ? allGuilds.filter((g) => g.id === parsed.guildId) : guildName - ? guilds.filter((g) => g.slug === normalizeDiscordSlug(guildName)) - : guilds; + ? allGuilds.filter((g) => g.slug === normalizeDiscordSlug(guildName)) + : allGuilds; let best: { member: DiscordMember; guild: DiscordGuildSummary; score: number } | null = null; let matches = 0;