fix: lazy-load Discord allowlist guilds (#20208) (thanks @zhangjunmengyang)

This commit is contained in:
Shadow 2026-02-20 20:25:35 -06:00 committed by Shadow
parent 844d84a7f5
commit 866b33e0d3
3 changed files with 259 additions and 4 deletions

View File

@ -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.

View File

@ -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);
});
});

View File

@ -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<DiscordGuildSummary[]> => {
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;