diff --git a/.changeset/gateway-client-application.md b/.changeset/gateway-client-application.md new file mode 100644 index 00000000..4466d3de --- /dev/null +++ b/.changeset/gateway-client-application.md @@ -0,0 +1,5 @@ +--- +"@wolfstar/plugin-gateway": minor +--- + +Add `client.application` to `@wolfstar/plugin-gateway`, a `ClientApplication` like discord.js's, with the `Application`, `Team` and `TeamMember` structures. It exists from the construction of the client with only the client ID known (`partial`), unlike discord.js where it is `null` until `READY`; `READY` patches the same instance with the application's flags, and `fetch()` (`GET /applications/@me`) with the rest. It also has `edit()`, `fetchRoleConnectionMetadataRecords()` and `editRoleConnectionMetadataRecords()`, and exposes the install params, integration types config, `owner` (a `Team` or a `User`), the event webhooks and the guild and user install counts. diff --git a/packages/plugin-gateway/README.md b/packages/plugin-gateway/README.md index de8df19f..ba71e1bc 100644 --- a/packages/plugin-gateway/README.md +++ b/packages/plugin-gateway/README.md @@ -221,7 +221,8 @@ without touching the cache again. - `client.replayDispatch({ t, d, s? }, shardId, state?)` emits `raw` and the matching event, with the same Structures and `old` arguments the connected client emitted. It never reads or writes the cache, and never emits `dispatch`. Dispatches of a guild replay in order, like on a connected client. `client.replayDispatchTypes` lists the types it - handles: `READY` and `INTERACTION_CREATE` are not replayed, so `client.user` stays `null` until something sets it, + handles: `READY` and `INTERACTION_CREATE` are not replayed, so `client.user` stays `null` until something sets it (and + `client.application` partial until `fetch()`), and shard lifecycle events never fire. The promise rejects when a listener or the handler throws, which lets the caller retry the dispatch; async listeners are awaited. @@ -684,7 +685,7 @@ already ships (`User`, `Message`, `Attachment`, `Embed`, `Reaction`, `Poll`, `Em cache, CDN URLs, and actions through the client. The ones it has no counterpart for yet (`Guild`, `GuildMember`, `Role`, `ThreadMember`, `GuildScheduledEvent`, ...) extend its base `Structure`. They live in one folder per domain, each with an `index.ts`, mirroring `@discordjs/structures`: -`automoderation/`, `channels/` (and `channels/mixins/`), `emojis/`, `guilds/`, `invites/`, +`applications/`, `automoderation/`, `channels/` (and `channels/mixins/`), `emojis/`, `guilds/`, `invites/`, `messages/`, `polls/`, `presences/`, `soundboards/`, `stageInstances/`, `stickers/`, `users/`, `voice/`, and `webhooks/`. @@ -1029,6 +1030,29 @@ await webhook.editMessage(message.id, "Awoo!"); const fetched = await client.fetchWebhook(webhookId, token); // no bot authorization needed ``` +### Application + +`client.application` is a `ClientApplication`, the entry point for what the application owns rather than a guild. +Unlike discord.js, where it is `null` until `READY`, it is never `null`: the client builds it from `clientId`, so it +exists before `READY` and without a gateway connection (a client replaying another process's dispatches never sees +`READY`, and keeps it that way). Until then it is `partial`: only `id` is known, `READY` patches the very same +instance with the `flags`, and `fetch()` with everything else. It lives on the client, not in the cache. + +```ts +client.application.partial; // true until fetch() or edit() resolves +await client.application.fetch(); // GET /applications/@me +client.application.name; // "Wolf" +client.application.owner; // a Team, a User, or null +client.application.iconURL(); + +await client.application.edit({ description: "Awoo", tags: ["wolf"] }); +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.commands`, `application.emojis` and the entitlements will be added to `ClientApplication` +by their own features, there are no placeholders for them. + ### Messages `Message` follows discord.js: `attachments`, `embeds`, `mentions` (`MessageMentions`), `reactions` diff --git a/packages/plugin-gateway/src/GatewayClient.ts b/packages/plugin-gateway/src/GatewayClient.ts index 4d71957c..1ade6b37 100644 --- a/packages/plugin-gateway/src/GatewayClient.ts +++ b/packages/plugin-gateway/src/GatewayClient.ts @@ -48,6 +48,7 @@ import { ThreadManager } from "./managers/ThreadManager.js"; import { ThreadMemberManager } from "./managers/ThreadMemberManager.js"; import { UserManager } from "./managers/UserManager.js"; import type { BaseInvite } from "./structures/invites/BaseInvite.js"; +import { ClientApplication } from "./structures/applications/ClientApplication.js"; import { ClientUser } from "./structures/users/ClientUser.js"; import { createInvite } from "./structures/invites/GroupDMInvite.js"; import { bindClient, type StructureMixin } from "./structures/Structure.js"; @@ -391,6 +392,18 @@ export class GatewayClient extends Client { */ public user: ClientUser | null = null; + /** + * The application of the bot, like discord.js's `Client#application`. + * + * @remarks + * Unlike discord.js, it is never `null`: it is built from {@link GatewayClientOptions.clientId} when the client is, + * so it exists before `READY` and without any gateway connection. It is then partial, see + * {@link ClientApplication.partial}: `READY` patches the same instance with the application's flags, and + * {@link ClientApplication.fetch} with the rest. It is kept on the client, not in the cache: it is a single small + * object, and every process gets the same `READY`. + */ + public readonly application: ClientApplication; + public readonly users: UserManager; public readonly guilds: GuildManager; public readonly channels: ChannelManager; @@ -525,6 +538,7 @@ export class GatewayClient extends Client { this.#intents = Number(options.intents); // The base client validated it already, and scrubs it from `this.options`. this.#token = (options.discordToken ?? process.env.DISCORD_TOKEN)!; + this.application = bindClient(new ClientApplication({ id: this.id }), this); this.users = new UserManager(this); this.guilds = new GuildManager(this); this.channels = new ChannelManager(this); diff --git a/packages/plugin-gateway/src/structures/applications/Application.ts b/packages/plugin-gateway/src/structures/applications/Application.ts new file mode 100644 index 00000000..d188b69f --- /dev/null +++ b/packages/plugin-gateway/src/structures/applications/Application.ts @@ -0,0 +1,84 @@ +import type { BaseImageURLOptions } from "@discordjs/rest"; +import type { APIApplication } from "discord-api-types/v10"; +import { cdn } from "../../util/cdn.js"; +import { kData, snowflakeTimestamp, Structure } from "../Structure.js"; + +/** + * The raw fields of an application: its ID, and any of the others. `READY` only carries the ID and flags, so a + * partial application holds just these. + */ +export type ApplicationData = Pick & Partial>; + +/** + * The public attributes of an application, like discord.js's `Application`. + * + * @typeParam Data The raw application data this structure wraps. + */ +export class Application extends Structure { + public get id(): string { + return this[kData].id; + } + + /** + * The name of the application, or `null` while it is not known. + */ + public get name(): string | null { + return this[kData].name ?? null; + } + + /** + * The description of the application, or `null` if it has none or is not known. + */ + public get description(): string | null { + return this[kData].description ?? null; + } + + /** + * The hash of the application's icon, or `null` if it has none or is not known. + */ + public get icon(): string | null { + return this[kData].icon ?? null; + } + + /** + * The hash of the application's cover image, or `null` if it has none or is not known. + */ + public get cover(): string | null { + return this[kData].cover_image ?? null; + } + + public get createdTimestamp(): number { + return snowflakeTimestamp(this.id); + } + + public get createdAt(): Date { + return new Date(this.createdTimestamp); + } + + /** + * Gets the URL of the application's icon, or `null` if it has none. + * + * @param options The image options. + */ + public iconURL(options?: BaseImageURLOptions): string | null { + const { icon } = this[kData]; + return icon ? cdn.appIcon(this.id, icon, options) : null; + } + + /** + * Gets the URL of the application's cover image, or `null` if it has none. + * + * @param options The image options. + */ + public coverURL(options?: BaseImageURLOptions): string | null { + const { cover_image: cover } = this[kData]; + return cover ? cdn.appIcon(this.id, cover, options) : null; + } + + /** + * The name of the application, its ID while the name is not known. + */ + public toString(): string { + return this.name ?? this.id; + } +} diff --git a/packages/plugin-gateway/src/structures/applications/ClientApplication.ts b/packages/plugin-gateway/src/structures/applications/ClientApplication.ts new file mode 100644 index 00000000..2819b258 --- /dev/null +++ b/packages/plugin-gateway/src/structures/applications/ClientApplication.ts @@ -0,0 +1,361 @@ +import type { + APIApplication, + APIApplicationRoleConnectionMetadata, + ApplicationIntegrationType, + ApplicationRoleConnectionMetadataType, + ApplicationWebhookEventStatus, + ApplicationWebhookEventType, + LocalizationMap, + OAuth2Scopes, + RESTPatchCurrentApplicationJSONBody, +} from "discord-api-types/v10"; +import { resolveImageOption, type ImageResolvable } from "../../util/DataResolver.js"; +import { cachedGuild } from "../../util/cache.js"; +import { ApplicationFlagsBitField, type ApplicationFlagsResolvable } from "../../util/flags.js"; +import { PermissionsBitField, type PermissionResolvable } from "../../util/PermissionsBitField.js"; +import type { Guild } from "../guilds/Guild.js"; +import { bindClient, kClient, kData, kPatch } from "../Structure.js"; +import { User } from "../users/User.js"; +import { Application } from "./Application.js"; +import { Team } from "./Team.js"; + +/** + * The install parameters of an application: the scopes and permissions it asks for when it is added. + */ +export interface ApplicationInstallParams { + scopes: OAuth2Scopes[]; + permissions: Readonly; +} + +/** + * The install parameters of an application for each integration type (guild or user install). + */ +export type ApplicationIntegrationTypesConfig = { + [Type in ApplicationIntegrationType]?: { oauth2InstallParams: ApplicationInstallParams | null }; +}; + +/** + * The install parameters to set, with permissions in any form a `PermissionsBitField` resolves. + */ +export interface ApplicationInstallParamsOptions { + scopes: OAuth2Scopes[]; + permissions: PermissionResolvable; +} + +/** + * The options to edit the application with. + * + * @remarks + * Omitted fields are left unchanged. Images are data URIs (`data:image/png;base64,...`) or anything `resolveImage` + * reads (contents, a path, a URL, a stream, a blob), `null` removes them. + */ +export interface ClientApplicationEditOptions { + customInstallURL?: string; + description?: string; + eventWebhooksURL?: string | null; + eventWebhooksStatus?: ApplicationWebhookEventStatus; + eventWebhooksTypes?: ApplicationWebhookEventType[]; + flags?: ApplicationFlagsResolvable; + icon?: ImageResolvable | null; + coverImage?: ImageResolvable | null; + interactionsEndpointURL?: string | null; + roleConnectionsVerificationURL?: string | null; + tags?: string[]; + installParams?: ApplicationInstallParamsOptions; + integrationTypesConfig?: { + [Type in ApplicationIntegrationType]?: { + oauth2InstallParams?: ApplicationInstallParamsOptions; + }; + }; +} + +/** + * A role connection metadata record of an application, with camelCased fields like discord.js. + */ +export interface ApplicationRoleConnectionMetadata { + name: string; + nameLocalizations?: LocalizationMap; + description: string; + descriptionLocalizations?: LocalizationMap; + key: string; + type: ApplicationRoleConnectionMetadataType; +} + +function toRoleConnectionMetadata( + record: APIApplicationRoleConnectionMetadata, +): ApplicationRoleConnectionMetadata { + return { + name: record.name, + nameLocalizations: record.name_localizations, + description: record.description, + descriptionLocalizations: record.description_localizations, + key: record.key, + type: record.type, + }; +} + +function fromInstallParams( + params: NonNullable, +): ApplicationInstallParams { + return { + scopes: params.scopes, + permissions: new PermissionsBitField(BigInt(params.permissions)).freeze(), + }; +} + +function toInstallParams( + params: ApplicationInstallParamsOptions, +): NonNullable { + return { + scopes: params.scopes, + permissions: PermissionsBitField.resolve(params.permissions).toString(), + }; +} + +/** + * The application of the bot, the entry point for everything owned by the application rather than by a guild, like + * discord.js's `ClientApplication`. + * + * @remarks + * Unlike discord.js, `client.application` exists from the construction of the client, with only its ID known until + * `READY` patches in the flags, and {@link ClientApplication.fetch} the rest: check {@link ClientApplication.partial} + * before relying on the other fields. + */ +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; + + /** + * Whether only the ID and flags of the application are known: it has neither been fetched nor edited yet. + */ + public get partial(): boolean { + return this[kData].name === undefined; + } + + /** + * The flags of the application. + */ + public get flags(): Readonly { + return new ApplicationFlagsBitField(this[kData].flags ?? 0).freeze(); + } + + /** + * The tags describing the application, at most five. + */ + public get tags(): string[] { + return [...(this[kData].tags ?? [])] as string[]; + } + + /** + * The scopes and permissions the application asks for when it is added to a guild, or `null` if it sets none. + */ + public get installParams(): ApplicationInstallParams | null { + const params = this[kData].install_params; + return params ? fromInstallParams(params) : null; + } + + /** + * The install parameters of the application for each integration type, or `null` if it sets none. + */ + public get integrationTypesConfig(): ApplicationIntegrationTypesConfig | null { + const config = this[kData].integration_types_config; + if (!config) return null; + + const result: ApplicationIntegrationTypesConfig = {}; + for (const [type, value] of Object.entries(config)) { + const params = value.oauth2_install_params; + result[Number(type) as ApplicationIntegrationType] = { + oauth2InstallParams: params ? fromInstallParams(params) : null, + }; + } + return result; + } + + /** + * The URL the application is installed through, instead of Discord's own install link. + */ + public get customInstallURL(): string | null { + return this[kData].custom_install_url ?? null; + } + + /** + * The team owning the application, else the user who does, `null` while it is not known. + */ + public get owner(): Team | User | null { + const { team, owner } = this[kData]; + if (!this.#owner || this.#owner.team !== team || this.#owner.user !== owner) { + let value: Team | User | null = null; + if (team) { + value = new Team(team); + } else if (owner) { + value = new User(owner); + } + // Bound to this client, so that the team's members and their users are as well. + const client = this[kClient]; + if (value && client) bindClient(value, client); + this.#owner = { team, user: owner, value }; + } + return this.#owner.value; + } + + /** + * Whether anyone can add the bot to a guild, or `null` if it is not known. + */ + public get botPublic(): boolean | null { + return this[kData].bot_public ?? null; + } + + /** + * Whether the bot only joins guilds after the full OAuth2 code grant flow, or `null` if it is not known. + */ + public get botRequireCodeGrant(): boolean | null { + return this[kData].bot_require_code_grant ?? null; + } + + /** + * The ID of the guild linked to the application, or `null` if there is none. + */ + public get guildId(): string | null { + return this[kData].guild_id ?? null; + } + + /** + * The guild linked to the application, from the cache. + */ + public get guild(): Guild | null { + return this.lazyRelation("guild", (client) => cachedGuild(client, this.guildId)); + } + + /** + * The approximate number of guilds the bot is in, or `null` if it is not known. + */ + public get approximateGuildCount(): number | null { + return this[kData].approximate_guild_count ?? null; + } + + /** + * The approximate number of users who installed the application, or `null` if it is not known. + */ + public get approximateUserInstallCount(): number | null { + return this[kData].approximate_user_install_count ?? null; + } + + /** + * The URL interactions are sent to, or `null` if there is none. + */ + public get interactionsEndpointURL(): string | null { + return this[kData].interactions_endpoint_url ?? null; + } + + /** + * The URL of the role connections verification, or `null` if there is none. + */ + public get roleConnectionsVerificationURL(): string | null { + return this[kData].role_connections_verification_url ?? null; + } + + /** + * The URL event webhooks are sent to, or `null` if there is none. + */ + public get eventWebhooksURL(): string | null { + return this[kData].event_webhooks_url ?? null; + } + + /** + * Whether event webhooks are enabled, or `null` if it is not known. + */ + public get eventWebhooksStatus(): ApplicationWebhookEventStatus | null { + return this[kData].event_webhooks_status ?? null; + } + + /** + * The event types sent to {@link ClientApplication.eventWebhooksURL}, or `null` if it is not known. + */ + public get eventWebhooksTypes(): ApplicationWebhookEventType[] | null { + const types = this[kData].event_webhooks_types; + return types ? [...types] : null; + } + + /** + * Fetches the application from the API, and patches this structure with it. + * + * @returns This application. + */ + public async fetch(): Promise { + const data = await this.client.api.applications.getCurrent(); + return this[kPatch](data); + } + + /** + * Edits the application, and patches this structure with the result. + * + * @param options The fields to edit. + * @returns This application. + */ + public async edit(options: ClientApplicationEditOptions): Promise { + const body: RESTPatchCurrentApplicationJSONBody = { + custom_install_url: options.customInstallURL, + description: options.description, + event_webhooks_url: options.eventWebhooksURL, + event_webhooks_status: options.eventWebhooksStatus, + event_webhooks_types: options.eventWebhooksTypes, + flags: + options.flags === undefined + ? undefined + : Number(ApplicationFlagsBitField.resolve(options.flags)), + icon: await resolveImageOption(options.icon), + cover_image: await resolveImageOption(options.coverImage), + interactions_endpoint_url: options.interactionsEndpointURL, + role_connections_verification_url: options.roleConnectionsVerificationURL, + tags: options.tags as RESTPatchCurrentApplicationJSONBody["tags"], + install_params: options.installParams && toInstallParams(options.installParams), + integration_types_config: + options.integrationTypesConfig && + Object.fromEntries( + Object.entries(options.integrationTypesConfig).map(([type, config]) => [ + type, + { + oauth2_install_params: + config.oauth2InstallParams && toInstallParams(config.oauth2InstallParams), + }, + ]), + ), + }; + for (const key of Object.keys(body) as (keyof typeof body)[]) { + if (body[key] === undefined) delete body[key]; + } + const data = await this.client.api.applications.editCurrent(body); + return this[kPatch](data); + } + + /** + * Fetches the role connection metadata records of the application. + */ + public async fetchRoleConnectionMetadataRecords(): Promise { + const records = await this.client.api.roleConnections.getMetadataRecords(this.id); + return records.map(toRoleConnectionMetadata); + } + + /** + * Replaces the role connection metadata records of the application. + * + * @param records The records to set, up to five. Records left out are removed. + * @returns The records as stored. + */ + public async editRoleConnectionMetadataRecords( + records: readonly ApplicationRoleConnectionMetadata[], + ): Promise { + const updated = await this.client.api.roleConnections.updateMetadataRecords( + this.id, + records.map((record) => ({ + key: record.key, + name: record.name, + name_localizations: record.nameLocalizations, + description: record.description, + description_localizations: record.descriptionLocalizations, + type: record.type, + })), + ); + return updated.map(toRoleConnectionMetadata); + } +} diff --git a/packages/plugin-gateway/src/structures/applications/Team.ts b/packages/plugin-gateway/src/structures/applications/Team.ts new file mode 100644 index 00000000..0685e7ff --- /dev/null +++ b/packages/plugin-gateway/src/structures/applications/Team.ts @@ -0,0 +1,83 @@ +import { Collection } from "@discordjs/collection"; +import type { BaseImageURLOptions } from "@discordjs/rest"; +import type { APITeam } from "discord-api-types/v10"; +import { cdn } from "../../util/cdn.js"; +import { bindClient, kClient, kData, snowflakeTimestamp, Structure } from "../Structure.js"; +import { TeamMember } from "./TeamMember.js"; + +/** + * A team of developers owning an application, like discord.js's `Team`. + */ +export class Team extends Structure { + // The members are built once per payload, so that `owner` is one of them and neither changes between reads. + #members: { source: APITeam["members"]; value: Collection } | null = null; + + public get id(): string { + return this[kData].id; + } + + public get name(): string { + return this[kData].name; + } + + /** + * The hash of the team's icon, or `null` if it has none. + */ + public get icon(): string | null { + return this[kData].icon; + } + + /** + * The ID of the user owning the team. + */ + public get ownerId(): string { + return this[kData].owner_user_id; + } + + /** + * The members of the team, by the ID of their user. + */ + public get members(): Collection { + const source = this[kData].members; + if (this.#members?.source !== source) { + const value = new Collection(); + const client = this[kClient]; + for (const data of source) { + const member = new TeamMember(data, { team: this }); + if (client) bindClient(member, client); + value.set(member.id, member); + } + this.#members = { source, value }; + } + return this.#members.value; + } + + /** + * The member owning the team, if it is among {@link Team.members}. + */ + public get owner(): TeamMember | null { + return this.members.get(this.ownerId) ?? null; + } + + public get createdTimestamp(): number { + return snowflakeTimestamp(this.id); + } + + public get createdAt(): Date { + return new Date(this.createdTimestamp); + } + + /** + * Gets the URL of the team's icon, or `null` if it has none. + * + * @param options The image options. + */ + public iconURL(options?: BaseImageURLOptions): string | null { + const { icon } = this[kData]; + return icon ? cdn.teamIcon(this.id, icon, options) : null; + } + + public toString(): string { + return this.name; + } +} diff --git a/packages/plugin-gateway/src/structures/applications/TeamMember.ts b/packages/plugin-gateway/src/structures/applications/TeamMember.ts new file mode 100644 index 00000000..89e66ac7 --- /dev/null +++ b/packages/plugin-gateway/src/structures/applications/TeamMember.ts @@ -0,0 +1,88 @@ +import type { + APITeamMember, + TeamMemberMembershipState, + TeamMemberRole, +} from "discord-api-types/v10"; +import { bindClient, kClient, kData, kRelations, Structure } from "../Structure.js"; +import { User } from "../users/User.js"; +import type { Team } from "./Team.js"; + +/** + * The relations of a {@link TeamMember}, set by its {@link Team}. + */ +export interface TeamMemberRelations { + team?: Team | null; +} + +/** + * A member of a {@link Team}, like discord.js's `TeamMember`. + */ +export class TeamMember extends Structure { + declare public [kRelations]: TeamMemberRelations; + + // The user is built once per payload, so that it stays the same object between reads. + #user: { source: APITeamMember["user"]; value: User } | null = null; + + /** + * @param data The raw team member. + * @param relations The team the member belongs to, set by the team itself. + */ + public constructor(data: APITeamMember, relations: TeamMemberRelations = {}) { + super(data, relations); + } + + /** + * The ID of the member's user. + */ + public get id(): string { + return this[kData].user.id; + } + + /** + * The team the member belongs to, or `null` when the member was built by hand. + */ + public get team(): Team | null { + return this[kRelations].team ?? null; + } + + /** + * The permissions of the member in the team. Discord only ever sends `["*"]`. + */ + public get permissions(): readonly string[] { + return this[kData].permissions; + } + + /** + * Whether the member accepted the invite to the team, or is still invited. + */ + public get membershipState(): TeamMemberMembershipState { + return this[kData].membership_state; + } + + /** + * The role of the member in the team. + */ + public get role(): TeamMemberRole { + return this[kData].role; + } + + /** + * The user of the member. + */ + public get user(): User { + const source = this[kData].user; + if (this.#user?.source !== source) { + const user = new User(source); + const client = this[kClient]; + this.#user = { source, value: client ? bindClient(user, client) : user }; + } + return this.#user.value; + } + + /** + * The mention of the member's user. + */ + public toString(): string { + return `<@${this.id}>`; + } +} diff --git a/packages/plugin-gateway/src/structures/applications/index.ts b/packages/plugin-gateway/src/structures/applications/index.ts new file mode 100644 index 00000000..9c71573d --- /dev/null +++ b/packages/plugin-gateway/src/structures/applications/index.ts @@ -0,0 +1,4 @@ +export * from "./Application.js"; +export * from "./ClientApplication.js"; +export * from "./Team.js"; +export * from "./TeamMember.js"; diff --git a/packages/plugin-gateway/src/structures/index.ts b/packages/plugin-gateway/src/structures/index.ts index 15135a02..c375806d 100644 --- a/packages/plugin-gateway/src/structures/index.ts +++ b/packages/plugin-gateway/src/structures/index.ts @@ -1,3 +1,4 @@ +export * from "./applications/index.js"; export * from "./automoderation/index.js"; export * from "./channels/index.js"; export * from "./components/index.js"; diff --git a/packages/plugin-gateway/src/util/dispatch.ts b/packages/plugin-gateway/src/util/dispatch.ts index d6d9fe70..c6a277c5 100644 --- a/packages/plugin-gateway/src/util/dispatch.ts +++ b/packages/plugin-gateway/src/util/dispatch.ts @@ -154,6 +154,8 @@ export const DispatchHandlers: { [Type in GatewayDispatchEvents]?: AnyDispatchHa build: (client, data, _state, shardId) => { const user = new ClientUser(data.user); client.user = user; + // Patched in place, so that the references user code holds to `client.application` stay valid. + client.application[kPatch](data.application); return [shardId, user]; }, }, diff --git a/packages/plugin-gateway/tests/applications.test.ts b/packages/plugin-gateway/tests/applications.test.ts new file mode 100644 index 00000000..b7831750 --- /dev/null +++ b/packages/plugin-gateway/tests/applications.test.ts @@ -0,0 +1,325 @@ +import { WebSocketShardEvents } from "@discordjs/ws"; +import { container } from "@wolfstar/http-framework"; +import { createInMemoryCache } from "@wolfstar/plugin-cache"; +import { + ApplicationFlags, + ApplicationIntegrationType, + ApplicationRoleConnectionMetadataType, + GatewayDispatchEvents, + GatewayIntentBits, + GatewayOpcodes, + OAuth2Scopes, + Routes, + TeamMemberMembershipState, + TeamMemberRole, + type APIApplication, + type APITeam, + type GatewayDispatchPayload, +} from "discord-api-types/v10"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + ApplicationFlagsBitField, + ClientApplication, + GatewayClient, + Team, + TeamMember, + User, +} from "../src/index.js"; + +const clientId = "266624760782258186"; +const ownerId = "100000000000000010"; + +const owner = { + id: ownerId, + 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 team(extra: Partial = {}): APITeam { + return { + id: "300000000000000030", + name: "Pack", + icon: "teamicon", + owner_user_id: ownerId, + members: [ + { + user: owner, + team_id: "300000000000000030", + membership_state: TeamMemberMembershipState.Accepted, + permissions: ["*"], + role: TeamMemberRole.Admin, + }, + ], + ...extra, + }; +} + +function application(extra: Partial = {}): APIApplication { + return { + id: clientId, + name: "Wolf", + icon: "icon", + description: "A wolf", + bot_public: true, + bot_require_code_grant: false, + summary: "", + verify_key: "key", + team: null, + flags: ApplicationFlags.GatewayMessageContent, + flags_new: "0", + cover_image: "cover", + ...extra, + } as APIApplication; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("client.application", () => { + test("GIVEN a new client THEN it holds a partial application with the client ID, before READY", () => { + const client = createClient(); + + expect(client.application).toBeInstanceOf(ClientApplication); + expect(client.application.id).toBe(clientId); + expect(client.application.client).toBe(client); + expect(client.application.partial).toBe(true); + expect(client.application.name).toBeNull(); + expect(client.application.owner).toBeNull(); + expect(client.application.flags.bitField).toBe(0n); + expect(client.application.iconURL()).toBeNull(); + expect(client.application.toString()).toBe(clientId); + }); + + test("GIVEN READY THEN it patches the same instance in place with the flags", async () => { + const client = createClient(); + const before = client.application; + + client.gateway.emit( + WebSocketShardEvents.Dispatch, + { + op: GatewayOpcodes.Dispatch, + s: 1, + t: GatewayDispatchEvents.Ready, + d: { + user: { ...owner, id: clientId }, + guilds: [], + session_id: "s", + application: { id: clientId, flags: ApplicationFlags.GatewayGuildMembers }, + }, + } as GatewayDispatchPayload, + 0, + ); + await client.idle(); + + expect(client.application).toBe(before); + expect(before.partial).toBe(true); + expect(before.flags.has("GatewayGuildMembers")).toBe(true); + expect(before.flags).toBeInstanceOf(ApplicationFlagsBitField); + }); + + test("GIVEN fetch THEN it gets the current application and patches this instance", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue( + application({ + team: team(), + owner, + guild_id: "400000000000000040", + tags: ["wolf", "bot"], + approximate_guild_count: 3, + approximate_user_install_count: 7, + interactions_endpoint_url: "https://example.com/interactions", + custom_install_url: "https://example.com/install", + install_params: { scopes: [OAuth2Scopes.Bot], permissions: "8" }, + integration_types_config: { + [ApplicationIntegrationType.GuildInstall]: { + oauth2_install_params: { scopes: [OAuth2Scopes.Bot], permissions: "2048" }, + }, + [ApplicationIntegrationType.UserInstall]: {}, + }, + }), + ); + + const fetched = await client.application.fetch(); + + expect(get).toHaveBeenCalledWith(Routes.currentApplication(), expect.anything()); + expect(fetched).toBe(client.application); + expect(fetched.partial).toBe(false); + expect(fetched.name).toBe("Wolf"); + expect(fetched.toString()).toBe("Wolf"); + expect(fetched.description).toBe("A wolf"); + expect(fetched.botPublic).toBe(true); + expect(fetched.botRequireCodeGrant).toBe(false); + expect(fetched.guildId).toBe("400000000000000040"); + expect(fetched.tags).toEqual(["wolf", "bot"]); + expect(fetched.approximateGuildCount).toBe(3); + expect(fetched.approximateUserInstallCount).toBe(7); + expect(fetched.interactionsEndpointURL).toBe("https://example.com/interactions"); + expect(fetched.customInstallURL).toBe("https://example.com/install"); + expect(fetched.roleConnectionsVerificationURL).toBeNull(); + expect(fetched.eventWebhooksURL).toBeNull(); + expect(fetched.eventWebhooksStatus).toBeNull(); + expect(fetched.eventWebhooksTypes).toBeNull(); + expect(fetched.installParams?.scopes).toEqual([OAuth2Scopes.Bot]); + expect(fetched.installParams?.permissions.has("Administrator")).toBe(true); + expect( + fetched.integrationTypesConfig?.[ApplicationIntegrationType.GuildInstall]?.oauth2InstallParams + ?.permissions.bitField, + ).toBe(2048n); + expect(fetched.integrationTypesConfig?.[ApplicationIntegrationType.UserInstall]).toEqual({ + oauth2InstallParams: null, + }); + expect(fetched.guild).toBeNull(); + }); + + test("GIVEN CDN hashes THEN it builds the icon and cover URLs", async () => { + const client = createClient(); + vi.spyOn(container.rest, "get").mockResolvedValue(application()); + + await client.application.fetch(); + + expect(client.application.iconURL()).toBe( + `https://cdn.discordapp.com/app-icons/${clientId}/icon.webp`, + ); + expect(client.application.coverURL({ extension: "png", size: 256 })).toBe( + `https://cdn.discordapp.com/app-icons/${clientId}/cover.png?size=256`, + ); + const created = Number((BigInt(clientId) >> 22n) + 1_420_070_400_000n); + expect(client.application.createdTimestamp).toBe(created); + expect(client.application.createdAt).toEqual(new Date(created)); + }); + + test("GIVEN a team application THEN the owner is its team, with members bound to it", async () => { + const client = createClient(); + vi.spyOn(container.rest, "get").mockResolvedValue(application({ team: team(), owner })); + + await client.application.fetch(); + const { owner: value } = client.application; + + expect(value).toBeInstanceOf(Team); + expect(client.application.owner).toBe(value); + const pack = value as Team; + expect(pack.name).toBe("Pack"); + expect(pack.toString()).toBe("Pack"); + expect(pack.iconURL()).toBe(`https://cdn.discordapp.com/team-icons/${pack.id}/teamicon.webp`); + expect(pack.members.size).toBe(1); + expect(pack.owner).toBeInstanceOf(TeamMember); + expect(pack.owner).toBe(pack.members.get(ownerId)); + expect(pack.owner?.team).toBe(pack); + expect(pack.owner?.role).toBe(TeamMemberRole.Admin); + expect(pack.owner?.membershipState).toBe(TeamMemberMembershipState.Accepted); + expect(pack.owner?.permissions).toEqual(["*"]); + expect(pack.owner?.toString()).toBe(`<@${ownerId}>`); + expect(pack.owner?.user).toBeInstanceOf(User); + expect(pack.owner?.user.id).toBe(ownerId); + expect(pack.client).toBe(client); + expect(pack.owner?.client).toBe(client); + expect(pack.owner?.user.client).toBe(client); + }); + + test("GIVEN a user-owned application THEN the owner is a User bound to the client", async () => { + const client = createClient(); + vi.spyOn(container.rest, "get").mockResolvedValue(application({ owner })); + + await client.application.fetch(); + + const value = client.application.owner as User; + expect(value).toBeInstanceOf(User); + expect(value.id).toBe(ownerId); + expect(value.client).toBe(client); + expect(client.application.owner).toBe(value); + }); + + test("GIVEN edit THEN it patches the current application with the resolved options", async () => { + const client = createClient(); + const patch = vi + .spyOn(container.rest, "patch") + .mockResolvedValue(application({ description: "Awoo", tags: ["wolf"] })); + + const edited = await client.application.edit({ + description: "Awoo", + tags: ["wolf"], + flags: ["GatewayMessageContent"], + icon: "data:image/png;base64,AAAA", + coverImage: null, + interactionsEndpointURL: null, + installParams: { scopes: [OAuth2Scopes.Bot], permissions: ["SendMessages"] }, + integrationTypesConfig: { + [ApplicationIntegrationType.GuildInstall]: { + oauth2InstallParams: { scopes: [OAuth2Scopes.Bot], permissions: 8n }, + }, + }, + }); + + expect(patch).toHaveBeenCalledWith(Routes.currentApplication(), { + body: { + description: "Awoo", + tags: ["wolf"], + flags: ApplicationFlags.GatewayMessageContent, + icon: "data:image/png;base64,AAAA", + cover_image: null, + interactions_endpoint_url: null, + install_params: { scopes: [OAuth2Scopes.Bot], permissions: "2048" }, + integration_types_config: { + [ApplicationIntegrationType.GuildInstall]: { + oauth2_install_params: { scopes: [OAuth2Scopes.Bot], permissions: "8" }, + }, + }, + }, + signal: undefined, + }); + expect(edited).toBe(client.application); + expect(edited.description).toBe("Awoo"); + }); + + test("GIVEN edit with no options THEN it sends an empty body", async () => { + const client = createClient(); + const patch = vi.spyOn(container.rest, "patch").mockResolvedValue(application()); + + await client.application.edit({}); + + expect(patch).toHaveBeenCalledWith(Routes.currentApplication(), { body: {} }); + }); + + test("GIVEN role connection metadata THEN it maps the records to camelCase and back", async () => { + const client = createClient(); + const record = { + type: ApplicationRoleConnectionMetadataType.BooleanEqual, + key: "howls", + name: "Howls", + name_localizations: { fr: "Hurlements" }, + description: "Has howled", + description_localizations: { fr: "A hurlé" }, + }; + vi.spyOn(container.rest, "get").mockResolvedValue([record]); + const put = vi.spyOn(container.rest, "put").mockResolvedValue([record]); + + const expected = { + type: ApplicationRoleConnectionMetadataType.BooleanEqual, + key: "howls", + name: "Howls", + nameLocalizations: { fr: "Hurlements" }, + description: "Has howled", + descriptionLocalizations: { fr: "A hurlé" }, + }; + expect(await client.application.fetchRoleConnectionMetadataRecords()).toEqual([expected]); + expect(await client.application.editRoleConnectionMetadataRecords([expected])).toEqual([ + expected, + ]); + expect(put).toHaveBeenCalledWith(Routes.applicationRoleConnectionMetadata(clientId), { + body: [record], + }); + }); +}); diff --git a/packages/plugin-gateway/tests/types/application.ts b/packages/plugin-gateway/tests/types/application.ts new file mode 100644 index 00000000..0cbce664 --- /dev/null +++ b/packages/plugin-gateway/tests/types/application.ts @@ -0,0 +1,36 @@ +// Type-level test, checked by the root `typecheck` script through `tsconfig.consumption.json`: `client.application` +// is never null, and its members have the types of discord.js's `ClientApplication`. +import type { Collection } from "@discordjs/collection"; +import type { + ApplicationFlagsBitField, + ApplicationInstallParams, + ApplicationRoleConnectionMetadata, + ClientApplication, + GatewayClient, + Guild, + Team, + TeamMember, + User, +} from "../../src/index.js"; + +declare const client: GatewayClient; +declare const team: Team; +declare const other: ClientApplication; + +export const application: ClientApplication = client.application; +export const flags: Readonly = client.application.flags; +export const owner: Team | User | null = client.application.owner; +export const install: ApplicationInstallParams | null = client.application.installParams; +export const guild: Guild | null = client.application.guild; +export const partial: boolean = client.application.partial; +export const name: string | null = client.application.name; +export const icon: string | null = client.application.iconURL(); +export const fetched: Promise = client.application.fetch(); +export const edited: Promise = client.application.edit({ description: "Awoo" }); +export const records: Promise = + client.application.fetchRoleConnectionMetadataRecords(); +export const members: Collection = team.members; +export const teamOwner: TeamMember | null = team.owner; + +// @ts-expect-error `application` is read-only. +client.application = other;