From 11f7e3b6917d2a7770504d0cb764f9af9535cda8 Mon Sep 17 00:00:00 2001 From: RedStar Date: Fri, 9 Oct 2026 13:15:49 +0200 Subject: [PATCH 1/2] feat(plugin-gateway): add ClientApplication and client.application Add the Application, ClientApplication, Team and TeamMember structures, like discord.js. client.application is built from clientId when the client is, so it exists before READY (partial); READY patches the same instance with the flags and fetch() with the rest. Also add fetch, edit and the role connection metadata methods. Refs #208 --- .changeset/gateway-client-application.md | 5 + packages/plugin-gateway/README.md | 28 +- packages/plugin-gateway/src/GatewayClient.ts | 14 + .../structures/applications/Application.ts | 84 ++++ .../applications/ClientApplication.ts | 360 ++++++++++++++++++ .../src/structures/applications/Team.ts | 81 ++++ .../src/structures/applications/TeamMember.ts | 88 +++++ .../src/structures/applications/index.ts | 4 + .../plugin-gateway/src/structures/index.ts | 1 + packages/plugin-gateway/src/util/dispatch.ts | 2 + .../plugin-gateway/tests/applications.test.ts | 322 ++++++++++++++++ .../plugin-gateway/tests/types/application.ts | 36 ++ 12 files changed, 1023 insertions(+), 2 deletions(-) create mode 100644 .changeset/gateway-client-application.md create mode 100644 packages/plugin-gateway/src/structures/applications/Application.ts create mode 100644 packages/plugin-gateway/src/structures/applications/ClientApplication.ts create mode 100644 packages/plugin-gateway/src/structures/applications/Team.ts create mode 100644 packages/plugin-gateway/src/structures/applications/TeamMember.ts create mode 100644 packages/plugin-gateway/src/structures/applications/index.ts create mode 100644 packages/plugin-gateway/tests/applications.test.ts create mode 100644 packages/plugin-gateway/tests/types/application.ts 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..60435cb5 --- /dev/null +++ b/packages/plugin-gateway/src/structures/applications/ClientApplication.ts @@ -0,0 +1,360 @@ +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) { + const user = new User(owner); + const client = this[kClient]; + value = client ? bindClient(user, client) : user; + } + 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..61991390 --- /dev/null +++ b/packages/plugin-gateway/src/structures/applications/Team.ts @@ -0,0 +1,81 @@ +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 { 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(); + for (const data of source) { + const member = new TeamMember(data, { team: this }); + 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..3c9778f2 --- /dev/null +++ b/packages/plugin-gateway/tests/applications.test.ts @@ -0,0 +1,322 @@ +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); + }); + + 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; From ee3a210ffe26dde49b8f1fdd7c361e09a0d26f4b Mon Sep 17 00:00:00 2001 From: RedStar Date: Fri, 9 Oct 2026 13:19:39 +0200 Subject: [PATCH 2/2] fix(plugin-gateway): bind the application's team tree to the client The owner Team, its members and their users were not bound to the client that built them, so their client fell back to the most recently constructed GatewayClient. Bind the team like the owner user, and have Team#members pass the client on to each TeamMember. --- .../src/structures/applications/ClientApplication.ts | 7 ++++--- .../plugin-gateway/src/structures/applications/Team.ts | 4 +++- packages/plugin-gateway/tests/applications.test.ts | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/plugin-gateway/src/structures/applications/ClientApplication.ts b/packages/plugin-gateway/src/structures/applications/ClientApplication.ts index 60435cb5..2819b258 100644 --- a/packages/plugin-gateway/src/structures/applications/ClientApplication.ts +++ b/packages/plugin-gateway/src/structures/applications/ClientApplication.ts @@ -188,10 +188,11 @@ export class ClientApplication extends Application { if (team) { value = new Team(team); } else if (owner) { - const user = new User(owner); - const client = this[kClient]; - value = client ? bindClient(user, client) : user; + 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; diff --git a/packages/plugin-gateway/src/structures/applications/Team.ts b/packages/plugin-gateway/src/structures/applications/Team.ts index 61991390..0685e7ff 100644 --- a/packages/plugin-gateway/src/structures/applications/Team.ts +++ b/packages/plugin-gateway/src/structures/applications/Team.ts @@ -2,7 +2,7 @@ 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 { kData, snowflakeTimestamp, Structure } from "../Structure.js"; +import { bindClient, kClient, kData, snowflakeTimestamp, Structure } from "../Structure.js"; import { TeamMember } from "./TeamMember.js"; /** @@ -41,8 +41,10 @@ export class Team extends Structure { 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 }; diff --git a/packages/plugin-gateway/tests/applications.test.ts b/packages/plugin-gateway/tests/applications.test.ts index 3c9778f2..b7831750 100644 --- a/packages/plugin-gateway/tests/applications.test.ts +++ b/packages/plugin-gateway/tests/applications.test.ts @@ -224,6 +224,9 @@ describe("client.application", () => { 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 () => {