diff --git a/.changeset/gateway-application-emoji.md b/.changeset/gateway-application-emoji.md new file mode 100644 index 00000000..f922f371 --- /dev/null +++ b/.changeset/gateway-application-emoji.md @@ -0,0 +1,5 @@ +--- +"@wolfstar/plugin-gateway": minor +--- + +Add `ApplicationEmoji` and `ApplicationEmojiManager`, discord.js's emojis owned by the application rather than a guild, reachable as `client.application.emojis`. The manager is fetch-only (Discord sends no event for these emojis, so nothing is cached): `fetch()` lists them in a `Collection` and `fetch(id)` gets one, with `create`, `edit`, `delete` and `fetchAuthor`. An `ApplicationEmoji` extends `Emoji` with `author`, `application`, `edit`, `setName`, `delete`, `fetchAuthor` and `equals`, which compares only the ID, name and `animated`, the fields the API sends. diff --git a/packages/plugin-gateway/README.md b/packages/plugin-gateway/README.md index eb745445..9a33b02b 100644 --- a/packages/plugin-gateway/README.md +++ b/packages/plugin-gateway/README.md @@ -1099,8 +1099,7 @@ await client.application.editRoleConnectionMetadataRecords(records); ``` `owner` is the `Team` (with its `members`, each a `TeamMember`) when a team owns the application, the owner `User` -otherwise. `application.emojis` will be added to `ClientApplication` by its own feature, there is no -placeholder for it. +otherwise. ### Application commands @@ -1157,6 +1156,26 @@ await guild.commands.permissions.add({ }); ``` +#### Application emojis + +`client.application.emojis` is an `ApplicationEmojiManager` for the emojis the application owns, usable by the bot in +any guild. Discord sends no event for them, so they are never cached: every call goes to the API, and `fetch()` without +an ID lists them all, in a `Collection`. Unlike `GuildEmoji`, an `ApplicationEmoji` has no `roles`, `managed`, +`available` nor `requiresColons`, the API does not send them, and so `equals()` compares only the ID, name, and +`animated`. + +```ts +const emojis = await client.application.emojis.fetch(); // Collection +const emoji = await client.application.emojis.create({ + attachment: "data:image/png;base64,...", + name: "howl", +}); + +await emoji.setName("awoo"); +const author = await emoji.fetchAuthor(); +await emoji.delete(); +``` + ### Monetization `client.application.entitlements` is an `EntitlementManager`, a cached manager like the others: `ENTITLEMENT_CREATE`, diff --git a/packages/plugin-gateway/src/managers/ApplicationEmojiManager.ts b/packages/plugin-gateway/src/managers/ApplicationEmojiManager.ts new file mode 100644 index 00000000..06e91dc4 --- /dev/null +++ b/packages/plugin-gateway/src/managers/ApplicationEmojiManager.ts @@ -0,0 +1,138 @@ +import { Collection } from "@discordjs/collection"; +import type { + APIApplicationEmoji, + RESTPatchAPIApplicationEmojiJSONBody, + RESTPostAPIApplicationEmojiJSONBody, +} from "discord-api-types/v10"; +import type { GatewayClient } from "../GatewayClient.js"; +import { ApplicationEmoji } from "../structures/emojis/ApplicationEmoji.js"; +import { bindClient } from "../structures/Structure.js"; +import type { User } from "../structures/users/User.js"; +import { resolveImage, type ImageResolvable } from "../util/DataResolver.js"; +import { BaseManager } from "./BaseManager.js"; + +/** + * An application emoji, or its ID. + */ +export type ApplicationEmojiResolvable = ApplicationEmoji | string; + +/** + * The options to create an application emoji with. + */ +export interface ApplicationEmojiCreateOptions { + /** + * The image, up to 256 KiB: a data URI (`data:image/png;base64,...`), its contents, a path, a URL, a stream, or a + * blob. + */ + attachment: ImageResolvable; + name: string; +} + +/** + * The options to edit an application emoji with. + */ +export interface ApplicationEmojiEditOptions { + name?: string; +} + +/** + * Manages the emojis of the application, `client.application.emojis`. Discord does not send them over the gateway, so + * they are never cached: every method calls the API. + */ +export class ApplicationEmojiManager extends BaseManager { + public constructor(client: GatewayClient) { + super(client); + } + + /** + * The ID of the application the emojis belong to. + */ + public get applicationId(): string { + return this.client.application.id; + } + + /** + * Fetches an emoji of the application, or every one of them. + * + * @param emojiId The ID of the emoji, omit it to fetch them all. + * @returns The emoji, or a `Collection` of every emoji by ID. + */ + public fetch(): Promise>; + public fetch(emojiId: string): Promise; + public async fetch( + emojiId?: string, + ): Promise> { + if (emojiId !== undefined) { + return this.hydrate(await this.client.api.applications.getEmoji(this.applicationId, emojiId)); + } + + const { items } = await this.client.api.applications.getEmojis(this.applicationId); + const emojis = await Promise.all(items.map((emoji) => this.hydrate(emoji))); + return new Collection(emojis.map((emoji) => [emoji.id, emoji])); + } + + /** + * Uploads an emoji. + * + * @param options The image and name. + */ + public async create(options: ApplicationEmojiCreateOptions): Promise { + const body: RESTPostAPIApplicationEmojiJSONBody = { + image: (await resolveImage(options.attachment))!, + name: options.name, + }; + return this.hydrate(await this.client.api.applications.createEmoji(this.applicationId, body)); + } + + /** + * Edits an emoji. + * + * @param emoji The emoji, or its ID. + * @param options The changes to apply. + */ + public async edit( + emoji: ApplicationEmojiResolvable, + options: ApplicationEmojiEditOptions, + ): Promise { + const body: RESTPatchAPIApplicationEmojiJSONBody = { name: options.name }; + const edited = await this.client.api.applications.editEmoji( + this.applicationId, + resolveEmojiId(emoji), + body, + ); + return this.hydrate(edited); + } + + /** + * Deletes an emoji. + * + * @param emoji The emoji, or its ID. + */ + public async delete(emoji: ApplicationEmojiResolvable): Promise { + await this.client.api.applications.deleteEmoji(this.applicationId, resolveEmojiId(emoji)); + } + + /** + * Fetches the user who uploaded an emoji. + * + * @param emoji The emoji, or its ID. + */ + public async fetchAuthor(emoji: ApplicationEmojiResolvable): Promise { + const fetched = await this.fetch(resolveEmojiId(emoji)); + return fetched.author; + } + + /** + * Builds the structure of a raw emoji, resolving its uploader from the cache. + * + * @param data The raw emoji. + */ + public async hydrate(data: APIApplicationEmoji): Promise { + const author = await this.client.users._resolveData(data.user); + return bindClient(new ApplicationEmoji(data, { author }), this.client); + } +} + +function resolveEmojiId(emoji: ApplicationEmojiResolvable): string { + return typeof emoji === "string" ? emoji : emoji.id; +} diff --git a/packages/plugin-gateway/src/managers/index.ts b/packages/plugin-gateway/src/managers/index.ts index 5c3c4855..a4fea7b8 100644 --- a/packages/plugin-gateway/src/managers/index.ts +++ b/packages/plugin-gateway/src/managers/index.ts @@ -1,5 +1,6 @@ export * from "./ApplicationCommandManager.js"; export * from "./ApplicationCommandPermissionsManager.js"; +export * from "./ApplicationEmojiManager.js"; export * from "./AutoModerationRuleManager.js"; export * from "./BaseManager.js"; export * from "./CachedManager.js"; diff --git a/packages/plugin-gateway/src/structures/applications/ClientApplication.ts b/packages/plugin-gateway/src/structures/applications/ClientApplication.ts index fbe10ef6..38805684 100644 --- a/packages/plugin-gateway/src/structures/applications/ClientApplication.ts +++ b/packages/plugin-gateway/src/structures/applications/ClientApplication.ts @@ -12,6 +12,7 @@ import type { } from "discord-api-types/v10"; import { GatewayTypeError } from "../../errors/GatewayError.js"; import { ApplicationCommandManager } from "../../managers/ApplicationCommandManager.js"; +import { ApplicationEmojiManager } from "../../managers/ApplicationEmojiManager.js"; import { EntitlementManager } from "../../managers/EntitlementManager.js"; import { SubscriptionManager } from "../../managers/SubscriptionManager.js"; import type { SKUResolvable } from "../../types.js"; @@ -134,6 +135,7 @@ export class ClientApplication extends Application { // The owner is built once per payload, so that it stays the same object between reads. #owner: { team: unknown; user: unknown; value: Team | User | null } | null = null; + #emojis: ApplicationEmojiManager | null = null; // Built on first use: it needs the client, which binds the application after constructing it. #entitlements: EntitlementManager | null = null; @@ -297,6 +299,13 @@ export class ClientApplication extends Application { return types ? [...types] : null; } + /** + * The emojis of the application, fetched from the API: they are not cached. + */ + public get emojis(): ApplicationEmojiManager { + return (this.#emojis ??= new ApplicationEmojiManager(this.client)); + } + /** * The entitlements of the application: the access users and guilds were granted to its SKUs. Cached from the * gateway, like discord.js's `ClientApplication#entitlements`. diff --git a/packages/plugin-gateway/src/structures/emojis/ApplicationEmoji.ts b/packages/plugin-gateway/src/structures/emojis/ApplicationEmoji.ts new file mode 100644 index 00000000..992e06fe --- /dev/null +++ b/packages/plugin-gateway/src/structures/emojis/ApplicationEmoji.ts @@ -0,0 +1,115 @@ +import type { APIApplicationEmoji } from "discord-api-types/v10"; +import type { ApplicationEmojiEditOptions } from "../../managers/ApplicationEmojiManager.js"; +import type { ClientApplication } from "../applications/ClientApplication.js"; +import { kData, kPatch, kRelations } from "../Structure.js"; +import { User } from "../users/User.js"; +import { Emoji } from "./Emoji.js"; + +/** + * The relations of an {@link ApplicationEmoji}, resolved from the cache by `client.application.emojis`. + */ +export interface ApplicationEmojiRelations { + author?: User; +} + +/** + * A custom emoji owned by the application rather than by a guild, usable by the bot everywhere. Like discord.js's + * `ApplicationEmoji`. + * + * @remarks + * Discord sends no event for application emojis, so they are never cached: `client.application.emojis` fetches them + * from the API. The payload also carries no `managed`, `requires_colons`, `available` nor `roles`. + */ +export class ApplicationEmoji extends Emoji { + /** + * @param data The raw emoji. + * @param relations The uploader as resolved from the cache, by `client.application.emojis`. + */ + public constructor(data: APIApplicationEmoji, relations: ApplicationEmojiRelations = {}) { + super(data, relations); + } + + public override [kPatch](data: Readonly>): this { + if (data.user) this.dropRelations("author"); + return super[kPatch](data); + } + + public override get id(): string { + return this[kData].id; + } + + public override get name(): string { + return this[kData].name; + } + + /** + * The application that owns the emoji: the application of the client. + */ + public get application(): ClientApplication { + return this.client.application; + } + + /** + * The user who uploaded the emoji. The API always sends it for application emojis. + */ + public get author(): User { + return this[kRelations].author ?? new User(this[kData].user); + } + + /** + * Fetches the user who uploaded the emoji, from the API. + */ + public fetchAuthor(): Promise { + return this.client.application.emojis.fetchAuthor(this.id); + } + + /** + * Edits the emoji, and patches this structure with the result. + * + * @param options The changes to apply. + * @returns This emoji. + */ + public async edit(options: ApplicationEmojiEditOptions): Promise { + const emoji = await this.client.application.emojis.edit(this.id, options); + return this[kPatch](emoji.toJSON() as APIApplicationEmoji); + } + + /** + * Sets the name of the emoji. + * + * @param name The new name. + */ + public setName(name: string): Promise { + return this.edit({ name }); + } + + /** + * Deletes the emoji. + * + * @returns This emoji. + */ + public async delete(): Promise { + await this.client.application.emojis.delete(this.id); + return this; + } + + /** + * Whether this emoji has the same data as another one, like discord.js's `ApplicationEmoji#equals`. Against an + * application emoji or a raw one, it compares the ID, name, and `animated`: the only fields the API sends, so unlike + * `GuildEmoji#equals` there is no `managed`, `available`, `requiresColons` nor roles to compare. `false` for + * anything else, including another kind of emoji. + * + * @param other The application emoji, or raw one, to compare with. + */ + public equals(other: unknown): boolean { + if (other instanceof ApplicationEmoji) { + return other.id === this.id && other.name === this.name && other.animated === this.animated; + } + + if (typeof other !== "object" || other === null || other instanceof Emoji) return false; + const raw = other as Partial; + return ( + raw.id === this.id && raw.name === this.name && (raw.animated ?? false) === this.animated + ); + } +} diff --git a/packages/plugin-gateway/src/structures/emojis/index.ts b/packages/plugin-gateway/src/structures/emojis/index.ts index 942cddff..7915ac20 100644 --- a/packages/plugin-gateway/src/structures/emojis/index.ts +++ b/packages/plugin-gateway/src/structures/emojis/index.ts @@ -1,3 +1,4 @@ +export * from "./ApplicationEmoji.js"; export * from "./Emoji.js"; export * from "./GuildEmoji.js"; export * from "./ReactionEmoji.js"; diff --git a/packages/plugin-gateway/tests/application-emojis.test.ts b/packages/plugin-gateway/tests/application-emojis.test.ts new file mode 100644 index 00000000..3bea45b5 --- /dev/null +++ b/packages/plugin-gateway/tests/application-emojis.test.ts @@ -0,0 +1,237 @@ +import { container } from "@wolfstar/http-framework"; +import { createInMemoryCache } from "@wolfstar/plugin-cache"; +import { + GatewayIntentBits, + Routes, + type APIApplicationEmoji, + type APIUser, +} from "discord-api-types/v10"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + ApplicationEmoji, + ApplicationEmojiManager, + Emoji, + GatewayClient, + GuildEmoji, + User, + kPatch, +} from "../src/index.js"; + +const clientId = "266624760782258186"; +const emojiId = "800000000000000080"; + +const uploader: APIUser = { + id: "100000000000000010", + username: "alpha", + discriminator: "0", + global_name: "Alpha", + avatar: null, +}; + +function createClient() { + return new GatewayClient({ + discordPublicKey: "0".repeat(64), + discordToken: "test-token", + clientId, + intents: GatewayIntentBits.Guilds, + cache: createInMemoryCache(), + }); +} + +function emoji(extra: Partial = {}): APIApplicationEmoji { + return { id: emojiId, name: "howl", animated: false, user: uploader, ...extra }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("client.application.emojis", () => { + test("GIVEN a client THEN application.emojis is one fetch-only manager of the client", () => { + const client = createClient(); + + expect(client.application.emojis).toBeInstanceOf(ApplicationEmojiManager); + expect(client.application.emojis).toBe(client.application.emojis); + expect(client.application.emojis.client).toBe(client); + expect(client.application.emojis.applicationId).toBe(clientId); + }); + + test("GIVEN an ID THEN fetch gets the emoji of the application, bound to the client", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue(emoji()); + + const fetched = await client.application.emojis.fetch(emojiId); + + expect(get).toHaveBeenCalledWith(Routes.applicationEmoji(clientId, emojiId), expect.anything()); + expect(fetched).toBeInstanceOf(ApplicationEmoji); + expect(fetched).toBeInstanceOf(Emoji); + expect(fetched.id).toBe(emojiId); + expect(fetched.name).toBe("howl"); + expect(fetched.animated).toBe(false); + expect(fetched.identifier).toBe(`howl:${emojiId}`); + expect(fetched.toString()).toBe(`<:howl:${emojiId}>`); + expect(fetched.imageURL()).toBe(`https://cdn.discordapp.com/emojis/${emojiId}.webp`); + expect(fetched.client).toBe(client); + expect(fetched.application).toBe(client.application); + expect(fetched.author).toBeInstanceOf(User); + expect(fetched.author.id).toBe(uploader.id); + }); + + test("GIVEN no ID THEN fetch lists every emoji by ID, without caching them", async () => { + const client = createClient(); + const other = emoji({ id: "800000000000000081", name: "awoo", animated: true }); + const get = vi.spyOn(container.rest, "get").mockResolvedValue({ items: [emoji(), other] }); + + const emojis = await client.application.emojis.fetch(); + + expect(get).toHaveBeenCalledWith(Routes.applicationEmojis(clientId), expect.anything()); + expect([...emojis.keys()]).toEqual([emojiId, other.id]); + expect(emojis.get(other.id)?.animated).toBe(true); + expect(emojis.get(other.id)?.toString()).toBe(``); + expect(emojis.get(emojiId)?.client).toBe(client); + + await client.application.emojis.fetch(); + expect(get).toHaveBeenCalledTimes(2); + }); + + test("GIVEN an image THEN create uploads it, resolving the image", async () => { + const client = createClient(); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(emoji()); + + const created = await client.application.emojis.create({ + attachment: "data:image/png;base64,AAAA", + name: "howl", + }); + + expect(post).toHaveBeenCalledWith(Routes.applicationEmojis(clientId), { + body: { image: "data:image/png;base64,AAAA", name: "howl" }, + signal: undefined, + }); + expect(created).toBeInstanceOf(ApplicationEmoji); + expect(created.name).toBe("howl"); + }); + + test("GIVEN an emoji or its ID THEN edit patches its name", async () => { + const client = createClient(); + const patch = vi.spyOn(container.rest, "patch").mockResolvedValue(emoji({ name: "renamed" })); + const existing = new ApplicationEmoji(emoji()); + + const edited = await client.application.emojis.edit(existing, { name: "renamed" }); + await client.application.emojis.edit(emojiId, { name: "renamed" }); + + expect(patch).toHaveBeenCalledTimes(2); + expect(patch).toHaveBeenCalledWith(Routes.applicationEmoji(clientId, emojiId), { + body: { name: "renamed" }, + signal: undefined, + }); + expect(edited.name).toBe("renamed"); + expect(existing.name).toBe("howl"); + }); + + test("GIVEN an emoji or its ID THEN delete removes it", async () => { + const client = createClient(); + const del = vi.spyOn(container.rest, "delete").mockResolvedValue(undefined); + + await client.application.emojis.delete(emojiId); + await client.application.emojis.delete(new ApplicationEmoji(emoji())); + + expect(del).toHaveBeenCalledTimes(2); + expect(del).toHaveBeenCalledWith(Routes.applicationEmoji(clientId, emojiId), { + signal: undefined, + }); + }); + + test("GIVEN an ID THEN fetchAuthor gets the uploader from the API", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue(emoji()); + + const author = await client.application.emojis.fetchAuthor(emojiId); + + expect(get).toHaveBeenCalledWith(Routes.applicationEmoji(clientId, emojiId), expect.anything()); + expect(author).toBeInstanceOf(User); + expect(author.id).toBe(uploader.id); + }); +}); + +describe("ApplicationEmoji", () => { + test("GIVEN a built emoji THEN edit and setName patch it in place", async () => { + const client = createClient(); + const patch = vi.spyOn(container.rest, "patch").mockResolvedValue(emoji({ name: "renamed" })); + const existing = await client.application.emojis.hydrate(emoji()); + + expect(await existing.edit({ name: "renamed" })).toBe(existing); + expect(existing.name).toBe("renamed"); + + patch.mockResolvedValue(emoji({ name: "again" })); + await existing.setName("again"); + expect(existing.name).toBe("again"); + expect(patch).toHaveBeenLastCalledWith(Routes.applicationEmoji(clientId, emojiId), { + body: { name: "again" }, + signal: undefined, + }); + }); + + test("GIVEN a built emoji THEN delete calls the API and resolves to it", async () => { + const client = createClient(); + const del = vi.spyOn(container.rest, "delete").mockResolvedValue(undefined); + const existing = await client.application.emojis.hydrate(emoji()); + + expect(await existing.delete()).toBe(existing); + expect(del).toHaveBeenCalledWith(Routes.applicationEmoji(clientId, emojiId), { + signal: undefined, + }); + }); + + test("GIVEN a built emoji THEN fetchAuthor refetches the uploader", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue(emoji()); + const existing = await client.application.emojis.hydrate(emoji()); + + const author = await existing.fetchAuthor(); + + expect(get).toHaveBeenCalledOnce(); + expect(author.id).toBe(uploader.id); + }); + + test("GIVEN a patch with a user THEN the resolved author is dropped", () => { + const existing = new ApplicationEmoji(emoji(), { author: new User(uploader) }); + const resolved = existing.author; + + expect(existing.author).toBe(resolved); + existing[kPatch]({ + user: { ...uploader, id: "100000000000000011" }, + } as never); + expect(existing.author.id).toBe("100000000000000011"); + }); +}); + +describe("ApplicationEmoji#equals", () => { + const base = new ApplicationEmoji(emoji()); + + test("GIVEN an application emoji THEN it compares the ID, name, and animated", () => { + expect(base.equals(new ApplicationEmoji(emoji()))).toBe(true); + expect(base.equals(new ApplicationEmoji(emoji({ user: { ...uploader, id: "1" } })))).toBe(true); + expect(base.equals(new ApplicationEmoji(emoji({ name: "renamed" })))).toBe(false); + expect(base.equals(new ApplicationEmoji(emoji({ animated: true })))).toBe(false); + expect(base.equals(new ApplicationEmoji(emoji({ id: "1" })))).toBe(false); + }); + + test("GIVEN a raw emoji THEN it compares the same fields, animated defaulting to false", () => { + expect(base.equals(emoji())).toBe(true); + expect(base.equals({ id: emojiId, name: "howl" })).toBe(true); + expect(base.equals(emoji({ name: "renamed" }))).toBe(false); + expect(base.equals(emoji({ animated: true }))).toBe(false); + }); + + test("GIVEN a foreign argument THEN it is false", () => { + expect(base.equals(new GuildEmoji({ id: emojiId, name: "howl", guild_id: "1" } as never))).toBe( + false, + ); + expect(base.equals(new Emoji({ id: emojiId, name: "howl" }))).toBe(false); + expect(base.equals(null)).toBe(false); + expect(base.equals(undefined)).toBe(false); + expect(base.equals("howl")).toBe(false); + expect(base.equals(42)).toBe(false); + expect(base.equals({})).toBe(false); + }); +}); diff --git a/packages/plugin-gateway/tests/types/application-emojis.ts b/packages/plugin-gateway/tests/types/application-emojis.ts new file mode 100644 index 00000000..d7ff0b5e --- /dev/null +++ b/packages/plugin-gateway/tests/types/application-emojis.ts @@ -0,0 +1,35 @@ +// Type-level test, checked by the root `typecheck` script through `tsconfig.consumption.json`: +// `client.application.emojis` is fetch-only and its methods resolve to discord.js's types. +import type { Collection } from "@discordjs/collection"; +import type { + ApplicationEmoji, + ApplicationEmojiManager, + ClientApplication, + Emoji, + GatewayClient, + User, +} from "../../src/index.js"; + +declare const client: GatewayClient; +declare const emoji: ApplicationEmoji; + +export const manager: ApplicationEmojiManager = client.application.emojis; +export const viaApplication: ClientApplication = emoji.application; +export const one: Promise = client.application.emojis.fetch("1"); +export const all: Promise> = client.application.emojis.fetch(); +export const created: Promise = client.application.emojis.create({ + attachment: "data:image/png;base64,AAAA", + name: "howl", +}); +export const edited: Promise = client.application.emojis.edit(emoji, { + name: "awoo", +}); +export const deleted: Promise = client.application.emojis.delete("1"); +export const author: User = emoji.author; +export const fetchedAuthor: Promise = emoji.fetchAuthor(); +export const renamed: Promise = emoji.setName("awoo"); +export const base: Emoji = emoji; +export const same: boolean = emoji.equals({ id: "1", name: "howl" }); + +// @ts-expect-error `emojis` is read-only. +client.application.emojis = manager;