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
5 changes: 5 additions & 0 deletions .changeset/gateway-resolve-guild-emoji.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 27 additions & 2 deletions packages/plugin-gateway/src/managers/GuildEmojiManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GuildEmoji[]> {
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<GuildEmoji | null> {
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<GuildEmoji[]> {
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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { basename } from "node:path";
import type { RawFile } from "@discordjs/rest";
import {
MessageReferenceType,
Expand All @@ -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";

/**
Expand Down
20 changes: 20 additions & 0 deletions packages/plugin-gateway/src/util/Util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<GuildEmoji | null> {
return client.guilds.emojis("").findCachedInAnyGuild(emojiId);
}

/**
* Resolves a SKU, or its ID, to its ID.
*
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin-gateway/tests/cached-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
Guild,
kClone,
Message,
resolveGuildEmoji,
StageChannel,
TextChannel,
User,
Expand Down Expand Up @@ -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", () => {
Expand Down
14 changes: 14 additions & 0 deletions packages/plugin-gateway/tests/message-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions packages/plugin-gateway/tests/serializers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
flatten,
ForumChannel,
GatewayClient,
GuildEmoji,
GatewayError,
GatewayTypeError,
getSortableGroupTypes,
Expand All @@ -48,6 +49,7 @@
resolveGuildTemplateCode,
resolveImage,
resolveInviteCode,
resolveGuildEmoji,
resolveSKUId,
Role,
snakeCase,
Expand Down Expand Up @@ -406,7 +408,7 @@
});

describe("message transformers", () => {
function message(extra: Partial<APIMessage> = {}): APIMessage {

Check warning on line 411 in packages/plugin-gateway/tests/serializers.test.ts

View workflow job for this annotation

GitHub Actions / 馃敔 Lint project

unicorn(consistent-function-scoping)

packages/plugin-gateway/tests/serializers.test.ts:411:12: Function `message` does not capture any variables from its parent scope
return {
id: "700000000000000700",
channel_id: "200000000000000200",
Expand Down Expand Up @@ -628,6 +630,30 @@
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);
Expand Down
Loading