From a9d14c4c01a9787256872187d5e929bb43185a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Susa=C3=B1a?= Date: Tue, 27 Aug 2024 21:18:16 -0400 Subject: [PATCH] feat: Entry Points (#256) * feat: entry points types * fix: update attachment * feat: more types and command * chore: apply formatting * feat: entry interaction * chore: apply formatting * feat: entry commands * feat: end * fix: build * fix: typing * fix: entry point in handler * fix: build --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/transpile.yml | 3 - src/api/Routes/interactions.ts | 14 +- src/api/api.ts | 4 +- src/api/shared.ts | 2 +- src/builders/Attachment.ts | 26 ++-- src/client/base.ts | 7 + src/client/httpclient.ts | 4 +- src/commands/applications/chat.ts | 3 +- src/commands/applications/entryPoint.ts | 71 ++++++++++ src/commands/applications/entrycontext.ts | 130 ++++++++++++++++++ src/commands/decorators.ts | 53 ++++--- src/commands/handle.ts | 51 ++++++- src/commands/handler.ts | 14 +- src/commands/index.ts | 2 + src/structures/Interaction.ts | 102 ++++++++++++-- src/structures/channels.ts | 4 +- .../_applicationCommands/chatInput.ts | 12 +- .../_interactions/applicationCommands.ts | 51 ++++++- src/types/payloads/_interactions/responses.ts | 78 +++++++++++ src/types/rest/channel.ts | 22 +-- src/types/rest/interactions.ts | 49 +++++-- src/types/rest/webhook.ts | 2 +- 22 files changed, 594 insertions(+), 110 deletions(-) create mode 100644 src/commands/applications/entryPoint.ts create mode 100644 src/commands/applications/entrycontext.ts diff --git a/.github/workflows/transpile.yml b/.github/workflows/transpile.yml index 4cd2ea7..ea8a428 100644 --- a/.github/workflows/transpile.yml +++ b/.github/workflows/transpile.yml @@ -28,6 +28,3 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - - name: Build - run: npx tsc diff --git a/src/api/Routes/interactions.ts b/src/api/Routes/interactions.ts index 3ed1ff1..75621fe 100644 --- a/src/api/Routes/interactions.ts +++ b/src/api/Routes/interactions.ts @@ -1,11 +1,21 @@ -import type { RESTPostAPIInteractionCallbackJSONBody } from '../../types'; +import type { + RESTPostAPIInteractionCallbackJSONBody, + RESTPostAPIInteractionCallbackQuery, + RESTPostAPIInteractionCallbackResult, +} from '../../types'; import type { ProxyRequestMethod } from '../Router'; import type { RestArguments } from '../api'; export interface InteractionRoutes { interactions: (id: string) => (token: string) => { callback: { - post(args: RestArguments): Promise; + post( + args: RestArguments< + ProxyRequestMethod.Post, + RESTPostAPIInteractionCallbackJSONBody, + RESTPostAPIInteractionCallbackQuery + >, + ): Promise; }; }; } diff --git a/src/api/api.ts b/src/api/api.ts index f976219..a0821e3 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -347,9 +347,9 @@ export class ApiHandler { const fileKey = file.key ?? `files[${index}]`; if (isBufferLike(file.data)) { - formData.append(fileKey, new Blob([file.data], { type: file.contentType }), file.name); + formData.append(fileKey, new Blob([file.data], { type: file.contentType }), file.filename); } else { - formData.append(fileKey, new Blob([`${file.data}`], { type: file.contentType }), file.name); + formData.append(fileKey, new Blob([`${file.data}`], { type: file.contentType }), file.filename); } } diff --git a/src/api/shared.ts b/src/api/shared.ts index 0bded82..48d8a28 100644 --- a/src/api/shared.ts +++ b/src/api/shared.ts @@ -24,7 +24,7 @@ export interface RawFile { contentType?: string; data: Buffer | Uint8Array | boolean | number | string; key?: string; - name: string; + filename: string; } export interface ApiRequestOptions { diff --git a/src/builders/Attachment.ts b/src/builders/Attachment.ts index c9ee24b..cb35e8b 100644 --- a/src/builders/Attachment.ts +++ b/src/builders/Attachment.ts @@ -17,7 +17,7 @@ export type AttachmentResolvable = | Attachment; export type AttachmentDataType = keyof AttachmentResolvableMap; export interface AttachmentData { - name: string; + filename: string; description: string; resolvable: AttachmentResolvable; type: AttachmentDataType; @@ -40,7 +40,7 @@ export class AttachmentBuilder { * @param data - The partial attachment data. */ constructor( - public data: Partial = { name: `${randomBytes?.(8)?.toString('base64url') || 'default'}.jpg` }, + public data: Partial = { filename: `${randomBytes?.(8)?.toString('base64url') || 'default'}.jpg` }, ) {} /** @@ -51,7 +51,7 @@ export class AttachmentBuilder { * attachment.setName('example.jpg'); */ setName(name: string): this { - this.data.name = name; + this.data.filename = name; return this; } @@ -93,10 +93,10 @@ export class AttachmentBuilder { setSpoiler(spoiler: boolean): this { if (spoiler === this.spoiler) return this; if (!spoiler) { - this.data.name = this.data.name!.slice('SPOILER_'.length); + this.data.filename = this.data.filename!.slice('SPOILER_'.length); return this; } - this.data.name = `SPOILER_${this.data.name}`; + this.data.filename = `SPOILER_${this.data.filename}`; return this; } @@ -104,7 +104,7 @@ export class AttachmentBuilder { * Gets whether the attachment is a spoiler. */ get spoiler(): boolean { - return this.data.name?.startsWith('SPOILER_') ?? false; + return this.data.filename?.startsWith('SPOILER_') ?? false; } /** @@ -127,11 +127,11 @@ export function resolveAttachment( if ('id' in resolve) return resolve; if (resolve instanceof AttachmentBuilder) { - const data = resolve.toJSON(); - return { filename: data.name, description: data.description }; + const { filename, description } = resolve.toJSON(); + return { filename, description }; } - return { filename: resolve.name, description: resolve.description }; + return { filename: resolve.filename, description: resolve.description }; } /** @@ -143,9 +143,9 @@ export async function resolveFiles(resources: (AttachmentBuilder | RawFile | Att const data = await Promise.all( resources.map(async (resource, i) => { if (resource instanceof AttachmentBuilder) { - const { type, resolvable, name } = resource.toJSON(); + const { type, resolvable, filename } = resource.toJSON(); const resolve = await resolveAttachmentData(resolvable, type); - return { ...resolve, key: `files[${i}]`, name } as RawFile; + return { ...resolve, key: `files[${i}]`, filename } as RawFile; } if (resource instanceof Attachment) { const resolve = await resolveAttachmentData(resource.url, 'url'); @@ -153,14 +153,14 @@ export async function resolveFiles(resources: (AttachmentBuilder | RawFile | Att data: resolve.data, contentType: resolve.contentType, key: `files[${i}]`, - name: resource.filename, + filename: resource.filename, } as RawFile; } return { data: resource.data, contentType: resource.contentType, key: `files[${i}]`, - name: resource.name, + filename: resource.filename, } as RawFile; }), ); diff --git a/src/client/base.ts b/src/client/base.ts index a664d6b..c600ae6 100644 --- a/src/client/base.ts +++ b/src/client/base.ts @@ -43,6 +43,7 @@ import { LangsHandler } from '../langs/handler'; import type { ChatInputCommandInteraction, ComponentInteraction, + EntryPointInteraction, MessageCommandInteraction, ModalSubmitInteraction, UserCommandInteraction, @@ -320,6 +321,11 @@ export class BaseClient { const commands = this.commands!.values; const filter = filterSplit(commands, command => !command.guildId); + if (this.commands?.entryPoint) { + // @ts-expect-error + filter.expect.push(this.commands.entryPoint); + } + if (!cachePath || (await this.shouldUploadCommands(cachePath))) await this.proxy.applications(applicationId).commands.put({ body: filter.expect @@ -419,6 +425,7 @@ export interface BaseClientOptions { | MessageCommandInteraction | ComponentInteraction | ModalSubmitInteraction + | EntryPointInteraction | When, ) => {}; globalMiddlewares?: readonly (keyof RegisteredMiddlewares)[]; diff --git a/src/client/httpclient.ts b/src/client/httpclient.ts index f735bbf..5f50c84 100644 --- a/src/client/httpclient.ts +++ b/src/client/httpclient.ts @@ -28,9 +28,9 @@ export class HttpClient extends BaseClient { for (const [index, file] of files.entries()) { const fileKey = file.key ?? `files[${index}]`; if (isBufferLike(file.data)) { - response.append(fileKey, new Blob([file.data], { type: file.contentType }), file.name); + response.append(fileKey, new Blob([file.data], { type: file.contentType }), file.filename); } else { - response.append(fileKey, new Blob([`${file.data}`], { type: file.contentType }), file.name); + response.append(fileKey, new Blob([`${file.data}`], { type: file.contentType }), file.filename); } } if (body) { diff --git a/src/commands/applications/chat.ts b/src/commands/applications/chat.ts index 3598399..21bc396 100644 --- a/src/commands/applications/chat.ts +++ b/src/commands/applications/chat.ts @@ -10,6 +10,7 @@ import { } from '../../types'; import type { ComponentContext, + EntryPointContext, MenuCommandContext, ModalContext, PermissionStrings, @@ -201,7 +202,7 @@ export class BaseCommand { /** @internal */ static __runMiddlewares( - context: CommandContext<{}, never> | ComponentContext | MenuCommandContext | ModalContext, + context: CommandContext<{}, never> | ComponentContext | MenuCommandContext | ModalContext | EntryPointContext, middlewares: (keyof RegisteredMiddlewares)[], global: boolean, ): Promise<{ error?: string; pass?: boolean }> { diff --git a/src/commands/applications/entryPoint.ts b/src/commands/applications/entryPoint.ts new file mode 100644 index 0000000..b6c5f88 --- /dev/null +++ b/src/commands/applications/entryPoint.ts @@ -0,0 +1,71 @@ +import { magicImport, type PermissionStrings } from '../../common'; +import { + ApplicationCommandType, + type EntryPointCommandHandlerType, + type ApplicationIntegrationType, + type InteractionContextType, + type LocaleString, +} from '../../types'; +import type { RegisteredMiddlewares } from '../decorators'; +import type { EntryPointContext } from './entrycontext'; +import type { ExtraProps, UsingClient } from './shared'; + +export abstract class EntryPointCommand { + middlewares: (keyof RegisteredMiddlewares)[] = []; + + __filePath?: string; + __t?: { name: string | undefined; description: string | undefined }; + + name!: string; + type = ApplicationCommandType.PrimaryEntryPoint; + nsfw?: boolean; + integrationTypes: ApplicationIntegrationType[] = []; + contexts: InteractionContextType[] = []; + description!: string; + botPermissions?: bigint; + dm?: boolean; + handler!: EntryPointCommandHandlerType; + name_localizations?: Partial>; + description_localizations?: Partial>; + + props: ExtraProps = {}; + + toJSON() { + return { + handler: this.handler, + name: this.name, + type: this.type, + nsfw: this.nsfw, + default_member_permissions: null, + guild_id: null, + description: this.description, + name_localizations: this.name_localizations, + description_localizations: this.description_localizations, + dm_permission: this.dm, + contexts: this.contexts, + integration_types: this.integrationTypes, + }; + } + + async reload() { + delete require.cache[this.__filePath!]; + const __tempCommand = await magicImport(this.__filePath!).then(x => x.default ?? x); + + Object.setPrototypeOf(this, __tempCommand.prototype); + } + + abstract run?(context: EntryPointContext): any; + onAfterRun?(context: EntryPointContext, error: unknown | undefined): any; + onRunError(context: EntryPointContext, error: unknown): any { + context.client.logger.fatal(`${this.name}.`, context.author.id, error); + } + onMiddlewaresError(context: EntryPointContext, error: string): any { + context.client.logger.fatal(`${this.name}.`, context.author.id, error); + } + onBotPermissionsFail(context: EntryPointContext, permissions: PermissionStrings): any { + context.client.logger.fatal(`${this.name}.`, context.author.id, permissions); + } + onInternalError(client: UsingClient, command: EntryPointCommand, error?: unknown): any { + client.logger.fatal(command.name, error); + } +} diff --git a/src/commands/applications/entrycontext.ts b/src/commands/applications/entrycontext.ts new file mode 100644 index 0000000..4c2b39f --- /dev/null +++ b/src/commands/applications/entrycontext.ts @@ -0,0 +1,130 @@ +import { MessageFlags } from '../../types'; +import type { ReturnCache } from '../..'; +import type { + InteractionCreateBodyRequest, + InteractionMessageUpdateBodyRequest, + ModalCreateBodyRequest, + UnionToTuple, + When, +} from '../../common'; +import type { AllChannels, EntryPointInteraction } from '../../structures'; +import { BaseContext } from '../basecontext'; +import type { RegisteredMiddlewares } from '../decorators'; +import type { CommandMetadata, ExtendContext, GlobalMetadata, UsingClient } from './shared'; +import type { + GuildMemberStructure, + GuildStructure, + MessageStructure, + WebhookMessageStructure, +} from '../../client/transformers'; +import type { EntryPointCommand } from './entryPoint'; + +export interface EntryPointContext extends BaseContext, ExtendContext {} + +export class EntryPointContext extends BaseContext { + constructor( + readonly client: UsingClient, + readonly interaction: EntryPointInteraction, + readonly shardId: number, + readonly command: EntryPointCommand, + ) { + super(client); + } + + metadata: CommandMetadata> = {} as never; + globalMetadata: GlobalMetadata = {}; + + get t() { + return this.client.t(this.interaction.locale ?? this.client.langs!.defaultLang ?? 'en-US'); + } + + get fullCommandName() { + return this.command.name; + } + + write( + body: InteractionCreateBodyRequest, + fetchReply?: FR, + ): Promise> { + return this.interaction.write(body, fetchReply); + } + + modal(body: ModalCreateBodyRequest) { + return this.interaction.modal(body); + } + + deferReply(ephemeral = false) { + return this.interaction.deferReply(ephemeral ? MessageFlags.Ephemeral : undefined); + } + + editResponse(body: InteractionMessageUpdateBodyRequest) { + return this.interaction.editResponse(body); + } + + deleteResponse() { + return this.interaction.deleteResponse(); + } + + editOrReply( + body: InteractionCreateBodyRequest | InteractionMessageUpdateBodyRequest, + fetchReply?: FR, + ): Promise> { + return this.interaction.editOrReply(body as InteractionCreateBodyRequest, fetchReply); + } + + fetchResponse() { + return this.interaction.fetchResponse(); + } + + channel(mode?: 'rest' | 'flow'): Promise; + channel(mode?: 'cache'): ReturnCache; + channel(mode: 'cache' | 'rest' | 'flow' = 'cache') { + if (this.interaction?.channel && mode === 'cache') + return this.client.cache.adapter.isAsync ? Promise.resolve(this.interaction.channel) : this.interaction.channel; + return this.client.channels.fetch(this.channelId, mode === 'rest'); + } + + me(mode?: 'rest' | 'flow'): Promise; + me(mode?: 'cache'): ReturnCache; + me(mode: 'cache' | 'rest' | 'flow' = 'cache') { + if (!this.guildId) + return mode === 'cache' ? (this.client.cache.adapter.isAsync ? Promise.resolve() : undefined) : Promise.resolve(); + switch (mode) { + case 'cache': + return this.client.cache.members?.get(this.client.botId, this.guildId); + default: + return this.client.members.fetch(this.guildId, this.client.botId, mode === 'rest'); + } + } + + guild(mode?: 'rest' | 'flow'): Promise | undefined>; + guild(mode?: 'cache'): ReturnCache | undefined>; + guild(mode: 'cache' | 'rest' | 'flow' = 'cache') { + if (!this.guildId) + return ( + mode === 'cache' ? (this.client.cache.adapter.isAsync ? Promise.resolve() : undefined) : Promise.resolve() + ) as any; + switch (mode) { + case 'cache': + return this.client.cache.guilds?.get(this.guildId); + default: + return this.client.guilds.fetch(this.guildId, mode === 'rest'); + } + } + + get guildId() { + return this.interaction.guildId; + } + + get channelId() { + return this.interaction.channelId!; + } + + get author() { + return this.interaction.user; + } + + get member() { + return this.interaction.member; + } +} diff --git a/src/commands/decorators.ts b/src/commands/decorators.ts index bb53277..f2e7aaa 100644 --- a/src/commands/decorators.ts +++ b/src/commands/decorators.ts @@ -1,6 +1,7 @@ import { ApplicationCommandType, ApplicationIntegrationType, + type EntryPointCommandHandlerType, InteractionContextType, PermissionFlagsBits, type LocaleString, @@ -11,36 +12,28 @@ import type { DefaultLocale, ExtraProps, IgnoreCommand, MiddlewareContext } from export interface RegisteredMiddlewares {} -type DeclareOptions = - | { - name: string; - description: string; - botPermissions?: PermissionStrings | bigint; - defaultMemberPermissions?: PermissionStrings | bigint; - guildId?: string[]; - nsfw?: boolean; - integrationTypes?: (keyof typeof ApplicationIntegrationType)[]; - contexts?: (keyof typeof InteractionContextType)[]; - ignore?: IgnoreCommand; - aliases?: string[]; - props?: ExtraProps; - } - | (Omit< - { - name: string; - description: string; - botPermissions?: PermissionStrings | bigint; - defaultMemberPermissions?: PermissionStrings | bigint; - guildId?: string[]; - nsfw?: boolean; - integrationTypes?: (keyof typeof ApplicationIntegrationType)[]; - contexts?: (keyof typeof InteractionContextType)[]; - props?: ExtraProps; - }, - 'type' | 'description' - > & { +export type CommandDeclareOptions = + | DecoratorDeclareOptions + | (Omit & { type: ApplicationCommandType.User | ApplicationCommandType.Message; + }) + | (Omit & { + type: ApplicationCommandType.PrimaryEntryPoint; + handler: EntryPointCommandHandlerType; }); +export interface DecoratorDeclareOptions { + name: string; + description: string; + botPermissions?: PermissionStrings | bigint; + defaultMemberPermissions?: PermissionStrings | bigint; + guildId?: string[]; + nsfw?: boolean; + integrationTypes?: (keyof typeof ApplicationIntegrationType)[]; + contexts?: (keyof typeof InteractionContextType)[]; + ignore?: IgnoreCommand; + aliases?: string[]; + props?: ExtraProps; +} export function Locales({ name: names, @@ -154,7 +147,7 @@ export function Middlewares(cbs: readonly (keyof RegisteredMiddlewares)[]) { }; } -export function Declare(declare: DeclareOptions) { +export function Declare(declare: CommandDeclareOptions) { return (target: T) => class extends target { name = declare.name; @@ -177,6 +170,7 @@ export function Declare(declare: DeclareOptions) { guildId?: string[]; ignore?: IgnoreCommand; aliases?: string[]; + handler?: EntryPointCommandHandlerType; constructor(...args: any[]) { super(...args); if ('description' in declare) this.description = declare.description; @@ -184,6 +178,7 @@ export function Declare(declare: DeclareOptions) { if ('guildId' in declare) this.guildId = declare.guildId; if ('ignore' in declare) this.ignore = declare.ignore; if ('aliases' in declare) this.aliases = declare.aliases; + if ('handler' in declare) this.handler = declare.handler; // check if all properties are valid } }; diff --git a/src/commands/handle.ts b/src/commands/handle.ts index 226732a..e18a6ed 100644 --- a/src/commands/handle.ts +++ b/src/commands/handle.ts @@ -28,6 +28,8 @@ import { type SeyfertIntegerOption, type SeyfertNumberOption, type SeyfertStringOption, + EntryPointContext, + type EntryPointCommand, } from '.'; import { AutocompleteInteraction, @@ -38,6 +40,7 @@ import { type MessageCommandInteraction, type UserCommandInteraction, type __InternalReplyFunction, + type EntryPointInteraction, } from '../structures'; import type { PermissionsBitField } from '../structures/extra/Permissions'; import { ComponentContext, ModalContext } from '../components'; @@ -132,6 +135,34 @@ export class HandleCommand { } } + async entryPoint(command: EntryPointCommand, interaction: EntryPointInteraction, context: EntryPointContext) { + if (command.botPermissions && interaction.appPermissions) { + const permissions = this.checkPermissions(interaction.appPermissions, command.botPermissions); + if (permissions) return command.onBotPermissionsFail?.(context, permissions); + } + + const resultGlobal = await this.runGlobalMiddlewares(command, context); + if (typeof resultGlobal === 'boolean') return; + const resultMiddle = await this.runMiddlewares(command, context); + if (typeof resultMiddle === 'boolean') return; + + try { + try { + await command.run!(context); + await command.onAfterRun?.(context, undefined); + } catch (error) { + await command.onRunError(context, error); + await command.onAfterRun?.(context, error); + } + } catch (error) { + try { + await command.onInternalError(this.client, command, error); + } catch { + // pass + } + } + } + async chatInput( command: Command | SubCommand, interaction: ChatInputCommandInteraction, @@ -214,6 +245,16 @@ export class HandleCommand { this.contextMenuUser(data.command, data.interaction, data.context); break; } + case ApplicationCommandType.PrimaryEntryPoint: { + const command = this.client.commands?.entryPoint; + if (!command?.run) return; + const interaction = BaseInteraction.from(this.client, body, __reply) as EntryPointInteraction; + const context = new EntryPointContext(this.client, interaction, shardId, command); + const extendContext = this.client.options?.context?.(interaction) ?? {}; + Object.assign(context, extendContext); + await this.entryPoint(command, interaction, context); + break; + } case ApplicationCommandType.ChatInput: { const parentCommand = this.getCommand(body.data); const optionsResolver = this.makeResolver( @@ -442,7 +483,7 @@ export class HandleCommand { ); } - getCommand(data: { + getCommand(data: { guild_id?: string; name: string; }): T | undefined { @@ -487,8 +528,8 @@ export class HandleCommand { } async runGlobalMiddlewares( - command: Command | ContextMenuCommand | SubCommand, - context: CommandContext<{}, never> | MenuCommandContext, + command: Command | ContextMenuCommand | SubCommand | EntryPointCommand, + context: CommandContext<{}, never> | MenuCommandContext | EntryPointContext, ) { try { const resultRunGlobalMiddlewares = await BaseCommand.__runMiddlewares( @@ -513,8 +554,8 @@ export class HandleCommand { } async runMiddlewares( - command: Command | ContextMenuCommand | SubCommand, - context: CommandContext<{}, never> | MenuCommandContext, + command: Command | ContextMenuCommand | SubCommand | EntryPointCommand, + context: CommandContext<{}, never> | MenuCommandContext | EntryPointContext, ) { try { const resultRunMiddlewares = await BaseCommand.__runMiddlewares( diff --git a/src/commands/handler.ts b/src/commands/handler.ts index 96a4844..4caedf8 100644 --- a/src/commands/handler.ts +++ b/src/commands/handler.ts @@ -17,9 +17,11 @@ import { Command, type CommandOption, SubCommand } from './applications/chat'; import { ContextMenuCommand } from './applications/menu'; import type { UsingClient } from './applications/shared'; import { promises } from 'node:fs'; +import type { EntryPointCommand } from '.'; export class CommandHandler extends BaseHandler { values: (Command | ContextMenuCommand)[] = []; + entryPoint: EntryPointCommand | null = null; protected filter = (path: string) => path.endsWith('.js') || (!path.endsWith('.d.ts') && path.endsWith('.ts')); @@ -290,15 +292,17 @@ export class CommandHandler extends BaseHandler { } } this.stablishContextCommandDefaults(commandInstance); - this.values.push(commandInstance); this.parseLocales(commandInstance); + if ('handler' in commandInstance) { + this.entryPoint = commandInstance as EntryPointCommand; + } else this.values.push(commandInstance); } } return this.values; } - parseLocales(command: Command | SubCommand | ContextMenuCommand) { + parseLocales(command: InstanceType) { this.parseGlobalLocales(command); if (command instanceof ContextMenuCommand) { this.parseContextMenuLocales(command); @@ -322,7 +326,7 @@ export class CommandHandler extends BaseHandler { return command; } - parseGlobalLocales(command: Command | SubCommand | ContextMenuCommand) { + parseGlobalLocales(command: InstanceType) { if (command.__t) { command.name_localizations = {}; command.description_localizations = {}; @@ -488,7 +492,7 @@ export class CommandHandler extends BaseHandler { return file.default ? [file.default] : undefined; } - onCommand(file: HandleableCommand): Command | SubCommand | ContextMenuCommand | false { + onCommand(file: HandleableCommand): InstanceType | false { return new file(); } @@ -501,6 +505,6 @@ export type FileLoaded = { default?: NulleableCoalising; } & Record>; -export type HandleableCommand = new () => Command | SubCommand | ContextMenuCommand; +export type HandleableCommand = new () => Command | SubCommand | ContextMenuCommand | EntryPointCommand; export type SeteableCommand = new () => Extract, SubCommand>; export type HandleableSubCommand = new () => SubCommand; diff --git a/src/commands/index.ts b/src/commands/index.ts index 42124e9..380829a 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -5,5 +5,7 @@ export * from './applications/chatcontext'; export * from './applications/menu'; export * from './applications/menucontext'; export * from './applications/options'; +export * from './applications/entryPoint'; +export * from './applications/entrycontext'; export * from './decorators'; export * from './optionresolver'; diff --git a/src/structures/Interaction.ts b/src/structures/Interaction.ts index 1802937..d57f942 100644 --- a/src/structures/Interaction.ts +++ b/src/structures/Interaction.ts @@ -36,23 +36,28 @@ import { type MessageFlags, type RESTPostAPIInteractionCallbackJSONBody, type RESTAPIAttachment, + type APIEntryPointCommandInteraction, + type InteractionCallbackData, + type InteractionCallbackResourceActivity, + type RESTPostAPIInteractionCallbackResult, } from '../types'; import type { RawFile } from '../api'; import { ActionRow, Embed, Modal, PollBuilder, resolveAttachment, resolveFiles } from '../builders'; import type { ContextOptionsResolved, UsingClient } from '../commands'; -import type { - ObjectToLower, - OmitInsert, - ToClass, - When, - ComponentInteractionMessageUpdate, - InteractionCreateBodyRequest, - InteractionMessageUpdateBodyRequest, - MessageCreateBodyRequest, - MessageUpdateBodyRequest, - MessageWebhookCreateBodyRequest, - ModalCreateBodyRequest, +import { + type ObjectToLower, + type OmitInsert, + type ToClass, + type When, + type ComponentInteractionMessageUpdate, + type InteractionCreateBodyRequest, + type InteractionMessageUpdateBodyRequest, + type MessageCreateBodyRequest, + type MessageUpdateBodyRequest, + type MessageWebhookCreateBodyRequest, + type ModalCreateBodyRequest, + toCamelCase, } from '../common'; import { channelFrom, type AllChannels } from './'; import { DiscordBase } from './extra/DiscordBase'; @@ -75,6 +80,7 @@ export type ReplyInteractionBody = type: InteractionResponseType.ChannelMessageWithSource | InteractionResponseType.UpdateMessage; data: InteractionCreateBodyRequest | InteractionMessageUpdateBodyRequest | ComponentInteractionMessageUpdate; } + | { type: InteractionResponseType.LaunchActivity } | Exclude; export type __InternalReplyFunction = (_: { body: APIInteractionResponse; files?: RawFile[] }) => Promise; @@ -161,6 +167,8 @@ export class BaseInteraction< : [], }, }; + case InteractionResponseType.LaunchActivity: + return body; default: return body; } @@ -168,6 +176,7 @@ export class BaseInteraction< static transformBody( body: + | InteractionCreateBodyRequest | InteractionMessageUpdateBodyRequest | MessageUpdateBodyRequest | MessageCreateBodyRequest @@ -192,9 +201,9 @@ export class BaseInteraction< ...resolveAttachment(x), })) ?? undefined; } else if (files?.length) { - payload.attachments = files?.map((x, id) => ({ + payload.attachments = files?.map(({ filename }, id) => ({ id, - filename: x.name, + filename, })) as RESTAPIAttachment[]; } return payload as T; @@ -279,6 +288,10 @@ export class BaseInteraction< return false; } + isEntryPoint(): this is EntryPointInteraction { + return false; + } + static from(client: UsingClient, gateway: GatewayInteractionCreateDispatchData, __reply?: __InternalReplyFunction) { switch (gateway.type) { case InteractionType.ApplicationCommandAutocomplete: @@ -296,6 +309,8 @@ export class BaseInteraction< return new UserCommandInteraction(client, gateway as APIUserApplicationCommandInteraction, __reply); case ApplicationCommandType.Message: return new MessageCommandInteraction(client, gateway as APIMessageApplicationCommandInteraction, __reply); + case ApplicationCommandType.PrimaryEntryPoint: + return new EntryPointInteraction(client, gateway as APIEntryPointCommandInteraction, __reply); } // biome-ignore lint/suspicious/noFallthroughSwitchClause: bad interaction between biome and ts-server case InteractionType.MessageComponent: @@ -345,6 +360,7 @@ export type AllInteractions = | ComponentInteraction | SelectMenuInteraction | ModalSubmitInteraction + | EntryPointInteraction | BaseInteraction; export interface AutocompleteInteraction @@ -478,6 +494,64 @@ export class ApplicationCommandInteraction< } } +/** + * Seyfert don't support activities, so this interaction is blank + */ +export class EntryPointInteraction extends ApplicationCommandInteraction< + FromGuild, + APIEntryPointCommandInteraction +> { + async withReponse(data?: InteractionCreateBodyRequest) { + let body = { type: InteractionResponseType.LaunchActivity } as const; + + if (data) { + let { files, ...rest } = data; + files = files ? await resolveFiles(files) : undefined; + body = BaseInteraction.transformBody(rest, files, this.client); + } + const response = (await this.client.proxy + .interactions(this.id)(this.token) + .callback.post({ + body, + query: { with_response: true }, + })) as RESTPostAPIInteractionCallbackResult; + + const result: Partial = { + interaction: toCamelCase(response.interaction), + }; + + if (response.resource) { + if (response.resource.type !== InteractionResponseType.LaunchActivity) { + result.resource = { + type: response.resource.type, + message: Transformers.WebhookMessage(this.client, response.resource.message as any, this.id, this.token), + }; + } else { + result.resource = { + type: response.resource.type, + activityInstance: response.resource.activity_instance!, + }; + } + } + + return result as EntryPointWithResponseResult; + } + + isEntryPoint(): this is EntryPointInteraction { + return true; + } +} + +export interface EntryPointWithResponseResult { + interaction: ObjectToLower; + resource?: + | { type: InteractionResponseType.LaunchActivity; activityInstance: InteractionCallbackResourceActivity } + | { + type: Exclude; + message: WebhookMessageStructure; + }; +} + export interface ComponentInteraction extends ObjectToLower< Omit< diff --git a/src/structures/channels.ts b/src/structures/channels.ts index cf3684d..a33609a 100644 --- a/src/structures/channels.ts +++ b/src/structures/channels.ts @@ -294,9 +294,9 @@ export class MessagesMethods extends DiscordBase { ...resolveAttachment(x), })) ?? undefined; } else if (files?.length) { - payload.attachments = files?.map((x, id) => ({ + payload.attachments = files?.map(({ filename }, id) => ({ id, - filename: x.name, + filename, })) as RESTAPIAttachment[]; } return payload as T; diff --git a/src/types/payloads/_interactions/_applicationCommands/chatInput.ts b/src/types/payloads/_interactions/_applicationCommands/chatInput.ts index eaa604a..3401750 100644 --- a/src/types/payloads/_interactions/_applicationCommands/chatInput.ts +++ b/src/types/payloads/_interactions/_applicationCommands/chatInput.ts @@ -1,5 +1,9 @@ import type { APIInteractionDataResolved } from '../../index'; -import type { APIApplicationCommandInteractionWrapper, ApplicationCommandType } from '../applicationCommands'; +import type { + APIApplicationCommandInteractionWrapper, + APIEntryPointInteractionData, + ApplicationCommandType, +} from '../applicationCommands'; import type { APIDMInteractionWrapper, APIGuildInteractionWrapper } from '../base'; import type { APIApplicationCommandAttachmentOption, @@ -128,3 +132,9 @@ export type APIChatInputApplicationCommandDMInteraction = */ export type APIChatInputApplicationCommandGuildInteraction = APIGuildInteractionWrapper; + +/** + * Documentation goes brrrrrr + * @unstable + */ +export type APIEntryPointCommandInteraction = APIApplicationCommandInteractionWrapper; diff --git a/src/types/payloads/_interactions/applicationCommands.ts b/src/types/payloads/_interactions/applicationCommands.ts index 3fdcb5c..b01ab52 100644 --- a/src/types/payloads/_interactions/applicationCommands.ts +++ b/src/types/payloads/_interactions/applicationCommands.ts @@ -5,6 +5,7 @@ import type { APIChatInputApplicationCommandGuildInteraction, APIChatInputApplicationCommandInteraction, APIChatInputApplicationCommandInteractionData, + APIEntryPointCommandInteraction, } from './_applicationCommands/chatInput'; import type { APIContextMenuDMInteraction, @@ -12,6 +13,7 @@ import type { APIContextMenuInteraction, APIContextMenuInteractionData, } from './_applicationCommands/contextMenu'; +import type { APIBaseApplicationCommandInteractionData } from './_applicationCommands/internals'; import type { APIBaseInteraction } from './base'; import type { InteractionType } from './responses'; @@ -92,28 +94,58 @@ export interface APIApplicationCommand { /** * Installation context(s) where the command is available, only for globally-scoped commands. Defaults to `GUILD_INSTALL ([0])` * - * @unstable */ integration_types?: ApplicationIntegrationType[]; /** * Interaction context(s) where the command can be used, only for globally-scoped commands. By default, all interaction context types included for new commands `[0,1,2]`. * - * @unstable */ contexts?: InteractionContextType[] | null; /** * Autoincrementing version identifier updated during substantial record changes */ version: Snowflake; + + /** + * Determines whether the interaction is handled by the app's interactions handler or by Discord + */ + handler?: EntryPointCommandHandlerType; } /** * https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-types */ export enum ApplicationCommandType { + /** + * Slash commands; a text-based command that shows up when a user types / + */ ChatInput = 1, + /** + * A UI-based command that shows up when you right click or tap on a user + */ User, + /** + * A UI-based command that shows up when you right click or tap on a message + */ Message, + /** + * A UI-based command that represents the primary way to invoke an app's Activity + */ + PrimaryEntryPoint, +} + +/** + * https://discord.com/developers/docs/interactions/application-commands#application-command-object-entry-point-command-handler-types + */ +export enum EntryPointCommandHandlerType { + /** + * The app handles the interaction using an interaction token + */ + AppHandler = 1, + /** + * Discord handles the interaction by launching an Activity and sending a follow-up message without coordinating with the app + */ + DiscordLaunchActivity, } /** @@ -148,12 +180,20 @@ export enum InteractionContextType { PrivateChannel = 2, } +/** + * Documentation goes brrrrrr + * @unstable + */ +export interface APIEntryPointInteractionData + extends Omit, 'guild_id'> {} + /** * https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-data */ export type APIApplicationCommandInteractionData = | APIChatInputApplicationCommandInteractionData - | APIContextMenuInteractionData; + | APIContextMenuInteractionData + | APIEntryPointInteractionData; /** * https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object @@ -170,7 +210,10 @@ export type APIApplicationCommandInteractionWrapper[]; } + +/** + * https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-callback-interaction-callback-object + */ +export interface InteractionCallbackData { + id: string; + type: T; + /** + * Instance ID of the Activity if one was launched or joined + */ + activity_instance_id?: string; + /** + * ID of the message that was created by the interaction + */ + response_message_id?: string; + /** + * Whether or not the message is in a loading state + */ + response_message_loading?: boolean; + /** + * Whether or not the response message was ephemeral + */ + response_message_ephemeral?: boolean; +} + +/** + * https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-callback-interaction-callback-resource-object + */ +export interface InteractionCallbackResourceActivity { + /** + * Instance ID of the Activity if one was launched or joined. + */ + id: string; +} + +/** + * https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-callback-interaction-callback-activity-instance-resource + */ +export interface InteractionCallbackResource { + type: T; + /** + * Represents the Activity launched by this interaction. + */ + activity_instance?: InteractionCallbackResourceActivity; + /** + * Message created by the interaction. + */ + message?: Omit & { flags?: MessageFlags }; +} + +/** + * https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-callback-interaction-callback-response-object + */ +export interface InteractionCallbackResponse { + interaction: InteractionCallbackData; + resource?: InteractionCallbackResource; +} + +/** + * https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-callback-interaction-callback-response-object + */ +export type APIInteractionCallbackLaunchActivity = InteractionCallbackResponse & { + resource?: Omit, 'message'>; +}; + +export type APIInteractionCallbackMessage = InteractionCallbackResponse & { + resource?: Omit, 'activity_instance'>; +}; diff --git a/src/types/rest/channel.ts b/src/types/rest/channel.ts index 6888cd1..cec88df 100644 --- a/src/types/rest/channel.ts +++ b/src/types/rest/channel.ts @@ -22,6 +22,7 @@ import type { ThreadChannelType, APIThreadMember, APIThreadList, + APIAttachment, } from '../payloads'; import type { AddUndefinedToPossiblyUndefinedPropertiesOfInterface, StrictPartial } from '../utils'; import type { RESTAPIPollCreate } from './poll'; @@ -248,22 +249,11 @@ export type APIMessageReferenceSend = AddUndefinedToPossiblyUndefinedPropertiesO }; /** - * https://discord.com/developers/docs/resources/channel#attachment-object + * https://discord.com/developers/docs/resources/message#attachment-object */ -export interface RESTAPIAttachment { - /** - * Attachment id or a number that matches `n` in `files[n]` - */ - id: Snowflake | number; - /** - * Name of the file - */ - filename?: string | undefined; - /** - * Description of the file - */ - description?: string | undefined; -} +export type RESTAPIAttachment = Partial< + Pick +>; /** * https://discord.com/developers/docs/resources/channel#create-message @@ -444,7 +434,7 @@ export interface RESTPatchAPIChannelMessageJSONBody { * * Starting with API v10, the `attachments` array must contain all attachments that should be present after edit, including **retained and new** attachments provided in the request body. * - * See https://discord.com/developers/docs/resources/channel#attachment-object + * See https://discord.com/developers/docs/resources/message#attachment-object */ attachments?: RESTAPIAttachment[] | undefined; /** diff --git a/src/types/rest/interactions.ts b/src/types/rest/interactions.ts index dcb8f4e..5df4233 100644 --- a/src/types/rest/interactions.ts +++ b/src/types/rest/interactions.ts @@ -2,9 +2,12 @@ import type { APIApplicationCommand, APIApplicationCommandPermission, APIGuildApplicationCommandPermissions, + APIInteractionCallbackLaunchActivity, + APIInteractionCallbackMessage, APIInteractionResponse, APIInteractionResponseCallbackData, ApplicationCommandType, + EntryPointCommandHandlerType, } from '../payloads'; import type { AddUndefinedToPossiblyUndefinedPropertiesOfInterface, NonNullableFields, StrictPartial } from '../utils'; import type { @@ -53,6 +56,7 @@ type RESTPostAPIBaseApplicationCommandsJSONBody = AddUndefinedToPossiblyUndefine | 'name_localized' | 'type' | 'version' + | 'handler' > & Partial< NonNullableFields> & @@ -75,12 +79,22 @@ export interface RESTPostAPIContextMenuApplicationCommandsJSONBody extends RESTP type: ApplicationCommandType.Message | ApplicationCommandType.User; } +/** + * https://discord.com/developers/docs/interactions/application-commands#create-global-application-command + */ +export interface RESTPostAPIEntryPointApplicationCommandsJSONBody extends RESTPostAPIBaseApplicationCommandsJSONBody { + type: ApplicationCommandType.PrimaryEntryPoint; + description: string; + handler: EntryPointCommandHandlerType; +} + /** * https://discord.com/developers/docs/interactions/application-commands#create-global-application-command */ export type RESTPostAPIApplicationCommandsJSONBody = | RESTPostAPIChatInputApplicationCommandsJSONBody - | RESTPostAPIContextMenuApplicationCommandsJSONBody; + | RESTPostAPIContextMenuApplicationCommandsJSONBody + | RESTPostAPIEntryPointApplicationCommandsJSONBody; /** * https://discord.com/developers/docs/interactions/application-commands#create-global-application-command @@ -112,15 +126,17 @@ export type RESTPutAPIApplicationCommandsResult = APIApplicationCommand[]; */ export type RESTGetAPIApplicationGuildCommandsQuery = RESTGetAPIApplicationCommandsQuery; -/** - * https://discord.com/developers/docs/interactions/application-commands#get-guild-application-commands - */ -export type RESTGetAPIApplicationGuildCommandsResult = Omit[]; +export type RESTAPIApplicationGuildCommand = Omit; /** * https://discord.com/developers/docs/interactions/application-commands#get-guild-application-commands */ -export type RESTGetAPIApplicationGuildCommandResult = Omit; +export type RESTGetAPIApplicationGuildCommandsResult = RESTAPIApplicationGuildCommand[]; + +/** + * https://discord.com/developers/docs/interactions/application-commands#get-guild-application-commands + */ +export type RESTGetAPIApplicationGuildCommandResult = RESTAPIApplicationGuildCommand; /** * https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command @@ -132,7 +148,7 @@ export type RESTPostAPIApplicationGuildCommandsJSONBody = /** * https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command */ -export type RESTPostAPIApplicationGuildCommandsResult = Omit; +export type RESTPostAPIApplicationGuildCommandsResult = RESTAPIApplicationGuildCommand; /** * https://discord.com/developers/docs/interactions/application-commands#edit-guild-application-command @@ -145,7 +161,7 @@ export type RESTPatchAPIApplicationGuildCommandJSONBody = StrictPartial< /** * https://discord.com/developers/docs/interactions/application-commands#edit-guild-application-command */ -export type RESTPatchAPIApplicationGuildCommandResult = Omit; +export type RESTPatchAPIApplicationGuildCommandResult = RESTAPIApplicationGuildCommand; /** * https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-guild-application-commands @@ -160,13 +176,28 @@ export type RESTPutAPIApplicationGuildCommandsJSONBody = ( /** * https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-guild-application-commands */ -export type RESTPutAPIApplicationGuildCommandsResult = Omit[]; +export type RESTPutAPIApplicationGuildCommandsResult = RESTAPIApplicationGuildCommand[]; /** * https://discord.com/developers/docs/interactions/receiving-and-responding#create-interaction-response */ export type RESTPostAPIInteractionCallbackJSONBody = APIInteractionResponse; +/** + * https://discord.com/developers/docs/interactions/receiving-and-responding#create-interaction-response-query-string-params + */ +export type RESTPostAPIInteractionCallbackQuery = { + /** + * Whether to include a RESTPostAPIInteractionCallbackResult as the response instead of a 204. + */ + with_response?: boolean; +}; + +/** + * https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-callback + */ +export type RESTPostAPIInteractionCallbackResult = APIInteractionCallbackLaunchActivity | APIInteractionCallbackMessage; + /** * https://discord.com/developers/docs/interactions/receiving-and-responding#create-interaction-response */ diff --git a/src/types/rest/webhook.ts b/src/types/rest/webhook.ts index e047d6f..be0dddd 100644 --- a/src/types/rest/webhook.ts +++ b/src/types/rest/webhook.ts @@ -264,7 +264,7 @@ export type RESTPatchAPIWebhookWithTokenMessageJSONBody = AddUndefinedToPossibly * * Starting with API v10, the `attachments` array must contain all attachments that should be present after edit, including **retained and new** attachments provided in the request body. * - * See https://discord.com/developers/docs/resources/channel#attachment-object + * See https://discord.com/developers/docs/resources/message#attachment-object */ attachments?: RESTAPIAttachment[] | undefined; };