From a5959b451ab32c23aba3c3b164ae698ba4580435 Mon Sep 17 00:00:00 2001 From: RedStar Date: Fri, 9 Oct 2026 13:13:59 +0200 Subject: [PATCH] feat(plugin-gateway): add resolveGuildEmoji Finds a custom emoji by ID across every cached guild, like discord.js. MessagePayload now names files with the shared basename helper instead of its own node:path copy. Refs #159. --- .changeset/gateway-resolve-guild-emoji.md | 5 ++++ .../src/managers/GuildEmojiManager.ts | 29 +++++++++++++++++-- .../src/structures/messages/MessagePayload.ts | 3 +- packages/plugin-gateway/src/util/Util.ts | 20 +++++++++++++ .../tests/cached-manager.test.ts | 3 ++ .../tests/message-payload.test.ts | 14 +++++++++ .../plugin-gateway/tests/serializers.test.ts | 26 +++++++++++++++++ 7 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 .changeset/gateway-resolve-guild-emoji.md diff --git a/.changeset/gateway-resolve-guild-emoji.md b/.changeset/gateway-resolve-guild-emoji.md new file mode 100644 index 00000000..e2538c45 --- /dev/null +++ b/.changeset/gateway-resolve-guild-emoji.md @@ -0,0 +1,5 @@ +--- +"@wolfstar/plugin-gateway": minor +--- + +Add `resolveGuildEmoji(client, emojiId)`, which finds a custom emoji by ID across every cached guild like discord.js's `resolveGuildEmoji`. `MessagePayload` now names URL and path files with the shared `basename` helper. diff --git a/packages/plugin-gateway/src/managers/GuildEmojiManager.ts b/packages/plugin-gateway/src/managers/GuildEmojiManager.ts index 8e8c9361..8b179dff 100644 --- a/packages/plugin-gateway/src/managers/GuildEmojiManager.ts +++ b/packages/plugin-gateway/src/managers/GuildEmojiManager.ts @@ -193,14 +193,39 @@ export class GuildEmojiManager extends CachedManager<"emojis", GuildEmoji, [emoj * @throws {TypeError} When the store cannot enumerate its entries. */ public async listCached(): Promise { + return this.collectCached((key) => key.startsWith(`${this.guildId}:`)); + } + + /** + * Finds a custom emoji by its ID across every guild held in the cache, without calling the API. + * + * @remarks + * Emojis are keyed by guild and ID, so it enumerates the whole entity cache like {@link GuildEmojiManager.listCached}. + * The manager's own guild plays no part in the lookup. + * + * @param emojiId The ID of the emoji. + * @returns The cached emoji, `null` when no guild has it or this entity is not cached. + * @throws {TypeError} When the store cannot enumerate its entries. + * @internal + */ + public async findCachedInAnyGuild(emojiId: string): Promise { + const suffix = `:${emojiId}`; + const [emoji] = await this.collectCached((key) => key.endsWith(suffix), 1); + return emoji ?? null; + } + + private async collectCached( + matches: (key: string) => boolean, + limit = Infinity, + ): Promise { const cache = this.iterableCache(); if (!cache) return []; - const prefix = `${this.guildId}:`; const keys = await this.guard("keys", null, () => cache.keys(), []); const values = await Promise.all( keys - .filter((key) => key.startsWith(prefix)) + .filter(matches) + .slice(0, limit) .map((key) => this.guard("get", key, () => cache.get(key), undefined)), ); return Promise.all( diff --git a/packages/plugin-gateway/src/structures/messages/MessagePayload.ts b/packages/plugin-gateway/src/structures/messages/MessagePayload.ts index 57b18160..c2f74b32 100644 --- a/packages/plugin-gateway/src/structures/messages/MessagePayload.ts +++ b/packages/plugin-gateway/src/structures/messages/MessagePayload.ts @@ -1,4 +1,3 @@ -import { basename } from "node:path"; import type { RawFile } from "@discordjs/rest"; import { MessageReferenceType, @@ -16,7 +15,7 @@ import { import type { GatewayClient } from "../../GatewayClient.js"; import { resolveFile } from "../../util/DataResolver.js"; import { MessageFlagsBitField, type MessageFlagsResolvable } from "../../util/flags.js"; -import { resolvePartialEmoji } from "../../util/Util.js"; +import { basename, resolvePartialEmoji } from "../../util/Util.js"; import { GatewayTypeError, GatewayRangeError } from "../../errors/GatewayError.js"; /** diff --git a/packages/plugin-gateway/src/util/Util.ts b/packages/plugin-gateway/src/util/Util.ts index c376b954..4863e0c9 100644 --- a/packages/plugin-gateway/src/util/Util.ts +++ b/packages/plugin-gateway/src/util/Util.ts @@ -9,6 +9,7 @@ import type { Awaitable, CacheEntityTypes } from "@wolfstar/plugin-cache"; import { ChannelType, FormattingPatterns, type Snowflake } from "discord-api-types/v10"; import type { GatewayClient } from "../GatewayClient.js"; import type { AnyChannel } from "../managers/ChannelManager.js"; +import type { GuildEmoji } from "../structures/emojis/GuildEmoji.js"; import type { EmojiIdentifierResolvable } from "../structures/emojis/ReactionEmoji.js"; import type { GuildMember } from "../structures/guilds/GuildMember.js"; import type { Role } from "../structures/guilds/Role.js"; @@ -415,6 +416,25 @@ export function findName(thing: unknown): string { return "file.jpg"; } +/** + * Finds a custom emoji by its ID in the cache of every guild, like discord.js's `resolveGuildEmoji`. + * + * @remarks + * Emojis are cached per guild, so it enumerates the whole emoji cache: it needs a store able to list its entries, and + * never calls the API. + * + * @param client The client whose cache to search. + * @param emojiId The ID of the emoji. + * @returns The emoji, `null` when no cached guild has it. + * @throws {TypeError} When the emoji cache cannot enumerate its entries. + */ +export function resolveGuildEmoji( + client: GatewayClient, + emojiId: Snowflake, +): Promise { + return client.guilds.emojis("").findCachedInAnyGuild(emojiId); +} + /** * Resolves a SKU, or its ID, to its ID. * diff --git a/packages/plugin-gateway/tests/cached-manager.test.ts b/packages/plugin-gateway/tests/cached-manager.test.ts index 7721fcaa..c7f69341 100644 --- a/packages/plugin-gateway/tests/cached-manager.test.ts +++ b/packages/plugin-gateway/tests/cached-manager.test.ts @@ -18,6 +18,7 @@ import { Guild, kClone, Message, + resolveGuildEmoji, StageChannel, TextChannel, User, @@ -500,8 +501,10 @@ describe("RFC manager pattern", () => { await expect(withoutStore.presences.listCached(guildId)).resolves.toEqual([]); await expect(withoutStore.guilds.emojis(guildId).listCached()).resolves.toEqual([]); + await expect(resolveGuildEmoji(withoutStore, "500000000000000001")).resolves.toBeNull(); await expect(nonIterable.presences.listCached(guildId)).rejects.toThrow(TypeError); await expect(nonIterable.guilds.emojis(guildId).listCached()).rejects.toThrow(TypeError); + await expect(resolveGuildEmoji(nonIterable, "500000000000000001")).rejects.toThrow(TypeError); }); test("GIVEN a manager THEN cache.construct builds structures bound to its client", () => { diff --git a/packages/plugin-gateway/tests/message-payload.test.ts b/packages/plugin-gateway/tests/message-payload.test.ts index ebd35d60..1e16aa25 100644 --- a/packages/plugin-gateway/tests/message-payload.test.ts +++ b/packages/plugin-gateway/tests/message-payload.test.ts @@ -204,6 +204,20 @@ describe("MessagePayload", () => { }); describe("Webhook relations", () => { + test("GIVEN file URLs and paths THEN their names drop the query string and fragment", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("wolf")); + + const names = await Promise.all( + [ + "https://cdn.example/wolf.png?size=64", + "https://cdn.example/wolf.gif#frag", + "https://cdn.example/", + ].map(async (url) => (await MessagePayload.resolveFile(url)).name), + ); + + expect(names).toEqual(["wolf.png", "wolf.gif", "file.jpg"]); + }); + test("GIVEN a fetched webhook THEN its guild and channel come from the cache", async () => { const client = createClient(); await client.cache!.guilds.set(guildId, { id: guildId, name: "Pack" } as never); diff --git a/packages/plugin-gateway/tests/serializers.test.ts b/packages/plugin-gateway/tests/serializers.test.ts index bdbe446f..0bc12f77 100644 --- a/packages/plugin-gateway/tests/serializers.test.ts +++ b/packages/plugin-gateway/tests/serializers.test.ts @@ -33,6 +33,7 @@ import { flatten, ForumChannel, GatewayClient, + GuildEmoji, GatewayError, GatewayTypeError, getSortableGroupTypes, @@ -48,6 +49,7 @@ import { resolveGuildTemplateCode, resolveImage, resolveInviteCode, + resolveGuildEmoji, resolveSKUId, Role, snakeCase, @@ -628,6 +630,30 @@ describe("Util", () => { expect(getSortableGroupTypes(ChannelType.GuildStageVoice)).toContain(ChannelType.GuildVoice); }); + test("GIVEN emojis in several guilds THEN resolveGuildEmoji finds one by ID in any of them", async () => { + const client = createClient(); + const emoji = (id: string, guild: string) => ({ + id, + name: "wolf", + animated: false, + guild_id: guild, + }); + await client.guilds + .emojis("100000000000000011") + ._add(emoji("500000000000000001", "100000000000000011")); + await client.guilds + .emojis("100000000000000012") + ._add(emoji("500000000000000002", "100000000000000012")); + + const found = await resolveGuildEmoji(client, "500000000000000002"); + + expect(found).toBeInstanceOf(GuildEmoji); + expect(found?.id).toBe("500000000000000002"); + expect(found?.guildId).toBe("100000000000000012"); + expect(await resolveGuildEmoji(client, "500000000000000003")).toBeNull(); + expect(await resolveGuildEmoji(client, "000000000000000001")).toBeNull(); + }); + test("GIVEN the remaining helpers THEN they behave like discord.js's", () => { const array = ["a", "b", "c"]; expect(moveElementInArray(array, "a", 1, true)).toBe(1);