diff --git a/deno.json b/deno.json index b115d2b..d010978 100644 --- a/deno.json +++ b/deno.json @@ -1,8 +1,5 @@ { "fmt": { - "files": { - "exclude": ["vendor"] - }, "options": { "indentWidth": 4, "lineWidth": 120 diff --git a/handlers/Actions.ts b/handlers/Actions.ts index c7f1580..bc7c813 100644 --- a/handlers/Actions.ts +++ b/handlers/Actions.ts @@ -33,6 +33,7 @@ import type { import type { Snowflake } from "../util/Snowflake.ts"; import type { Session } from "../session/Session.ts"; import type { Channel } from "../structures/channels/ChannelFactory.ts"; +import type { Interaction } from "../structures/interactions/InteractionFactory.ts"; import ChannelFactory from "../structures/channels/ChannelFactory.ts"; import GuildChannel from "../structures/channels/GuildChannel.ts"; import ThreadChannel from "../structures/channels/ThreadChannel.ts"; @@ -40,9 +41,9 @@ import ThreadMember from "../structures/ThreadMember.ts"; import Member from "../structures/Member.ts"; import Message from "../structures/Message.ts"; import User from "../structures/User.ts"; -import Guild from "../structures/guilds/Guild.ts"; -import Interaction from "../structures/interactions/Interaction.ts"; -import { Integration } from "../structures/Integration.ts" +import Integration from "../structures/Integration.ts" +import Guild from "../structures/guilds/Guild.ts"; +import InteractionFactory from "../structures/interactions/InteractionFactory.ts"; export type RawHandler = (...args: [Session, number, T]) => void; export type Handler = (...args: T) => unknown; @@ -110,12 +111,7 @@ export const GUILD_ROLE_DELETE: RawHandler = (session, _ }; export const INTERACTION_CREATE: RawHandler = (session, _shardId, interaction) => { - session.unrepliedInteractions.add(BigInt(interaction.id)); - - // could be improved - setTimeout(() => session.unrepliedInteractions.delete(BigInt(interaction.id)), 15 * 60 * 1000); - - session.emit("interactionCreate", new Interaction(session, interaction)); + session.emit("interactionCreate", InteractionFactory.from(session, interaction)); }; export const CHANNEL_CREATE: RawHandler = (session, _shardId, channel) => { diff --git a/mod.ts b/mod.ts index 93499a6..679d419 100644 --- a/mod.ts +++ b/mod.ts @@ -42,7 +42,14 @@ export * from "./structures/builders/MessageButton.ts"; export * from "./structures/builders/MessageSelectMenu.ts"; export * from "./structures/builders/SelectMenuOptionBuilder.ts"; -export * from "./structures/interactions/Interaction.ts"; +export * from "./structures/interactions/AutoCompleteInteraction.ts"; +export * from "./structures/interactions/BaseInteraction.ts"; +export * from "./structures/interactions/CommandInteraction.ts"; +export * from "./structures/interactions/CommandInteractionOptionResolver.ts"; +export * from "./structures/interactions/ComponentInteraction.ts"; +export * from "./structures/interactions/InteractionFactory.ts"; +export * from "./structures/interactions/ModalSubmitInteraction.ts"; +export * from "./structures/interactions/PingInteraction.ts"; export * from "./session/Session.ts"; diff --git a/session/Session.ts b/session/Session.ts index 6b25993..50b9e61 100644 --- a/session/Session.ts +++ b/session/Session.ts @@ -39,8 +39,6 @@ export class Session extends EventEmitter { rest: ReturnType; gateway: ReturnType; - unrepliedInteractions: Set = new Set(); - #botId: Snowflake; #applicationId?: Snowflake; diff --git a/structures/Integration.ts b/structures/Integration.ts index 45a0de7..9a3a90c 100644 --- a/structures/Integration.ts +++ b/structures/Integration.ts @@ -75,4 +75,6 @@ export class Integration implements Model { user?: User; account: IntegrationAccount; application?: IntegrationApplication; -} \ No newline at end of file +} + +export default Integration; diff --git a/structures/Message.ts b/structures/Message.ts index d13e095..ecf94b0 100644 --- a/structures/Message.ts +++ b/structures/Message.ts @@ -48,6 +48,7 @@ export interface CreateMessage { allowedMentions?: AllowedMentions; files?: FileContent[]; messageReference?: CreateMessageReference; + tts?: boolean; } /** @@ -270,6 +271,7 @@ export class Message implements Model { } : undefined, embeds: options.embeds, + tts: options.tts, }, ); diff --git a/structures/Webhook.ts b/structures/Webhook.ts index 6a85a22..c17a7ff 100644 --- a/structures/Webhook.ts +++ b/structures/Webhook.ts @@ -1,9 +1,13 @@ import type { Model } from "./Base.ts"; import type { Session } from "../session/Session.ts"; import type { Snowflake } from "../util/Snowflake.ts"; -import type { DiscordWebhook, WebhookTypes } from "../vendor/external.ts"; +import type { DiscordMessage, DiscordWebhook, WebhookTypes } from "../vendor/external.ts"; +import type { WebhookOptions } from "../util/Routes.ts"; +import type { CreateMessage } from "./Message.ts"; import { iconHashToBigInt } from "../util/hash.ts"; import User from "./User.ts"; +import Message from "./Message.ts"; +import * as Routes from "../util/Routes.ts"; export class Webhook implements Model { constructor(session: Session, data: DiscordWebhook) { @@ -42,6 +46,62 @@ export class Webhook implements Model { channelId?: Snowflake; guildId?: Snowflake; user?: User; + + async execute(options?: WebhookOptions & CreateMessage & { avatarUrl?: string, username?: string }) { + if (!this.token) { + return; + } + + const data = { + content: options?.content, + embeds: options?.embeds, + tts: options?.tts, + allowed_mentions: options?.allowedMentions, + // @ts-ignore: TODO: component builder or something + components: options?.components, + file: options?.files, + }; + + const message = await this.session.rest.sendRequest(this.session.rest, { + url: Routes.WEBHOOK(this.id, this.token!, { + wait: options?.wait, + threadId: options?.threadId, + }), + method: "POST", + payload: this.session.rest.createRequestBody(this.session.rest, { + method: "POST", + body: { + ...data, + }, + }), + }); + + return (options?.wait ?? true) ? new Message(this.session, message) : undefined; + } + + async fetch() { + const message = await this.session.rest.runMethod( + this.session.rest, + "GET", + Routes.WEBHOOK_TOKEN(this.id, this.token), + ); + + return new Webhook(this.session, message); + } + + async fetchMessage(messageId: Snowflake) { + if (!this.token) { + return; + } + + const message = await this.session.rest.runMethod( + this.session.rest, + "GET", + Routes.WEBHOOK_MESSAGE(this.id, this.token, messageId), + ); + + return new Message(this.session, message); + } } export default Webhook; diff --git a/structures/channels/BaseChannel.ts b/structures/channels/BaseChannel.ts index fc88e18..deb1809 100644 --- a/structures/channels/BaseChannel.ts +++ b/structures/channels/BaseChannel.ts @@ -7,6 +7,7 @@ import type VoiceChannel from "./VoiceChannel.ts"; import type DMChannel from "./DMChannel.ts"; import type NewsChannel from "./NewsChannel.ts"; import type ThreadChannel from "./ThreadChannel.ts"; +import type StageChannel from "./StageChannel.ts"; import { ChannelTypes } from "../../vendor/external.ts"; import { textBasedChannels } from "./TextChannel.ts"; @@ -43,6 +44,10 @@ export abstract class BaseChannel implements Model { return this.type === ChannelTypes.GuildPublicThread || this.type === ChannelTypes.GuildPrivateThread; } + isStage(): this is StageChannel { + return this.type === ChannelTypes.GuildStageVoice; + } + toString(): string { return `<#${this.id}>`; } diff --git a/structures/interactions/AutoCompleteInteraction.ts b/structures/interactions/AutoCompleteInteraction.ts new file mode 100644 index 0000000..063b407 --- /dev/null +++ b/structures/interactions/AutoCompleteInteraction.ts @@ -0,0 +1,40 @@ + +import type { Model } from "../Base.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { ApplicationCommandTypes, DiscordInteraction, InteractionTypes } from "../../vendor/external.ts"; +import type { ApplicationCommandOptionChoice } from "./CommandInteraction.ts"; +import { InteractionResponseTypes } from "../../vendor/external.ts"; +import BaseInteraction from "./BaseInteraction.ts"; +import * as Routes from "../../util/Routes.ts"; + +export class AutoCompleteInteraction extends BaseInteraction implements Model { + constructor(session: Session, data: DiscordInteraction) { + super(session, data); + this.type = data.type as number; + this.commandId = data.data!.id; + this.commandName = data.data!.name; + this.commandType = data.data!.type; + this.commandGuildId = data.data!.guild_id; + } + + override type: InteractionTypes.ApplicationCommandAutocomplete; + commandId: Snowflake; + commandName: string; + commandType: ApplicationCommandTypes; + commandGuildId?: Snowflake; + + async respond(choices: ApplicationCommandOptionChoice[]) { + await this.session.rest.runMethod( + this.session.rest, + "POST", + Routes.INTERACTION_ID_TOKEN(this.id, this.token), + { + data: { choices }, + type: InteractionResponseTypes.ApplicationCommandAutocompleteResult, + } + ); + } +} + +export default AutoCompleteInteraction; diff --git a/structures/interactions/BaseInteraction.ts b/structures/interactions/BaseInteraction.ts new file mode 100644 index 0000000..a93ca8f --- /dev/null +++ b/structures/interactions/BaseInteraction.ts @@ -0,0 +1,84 @@ +import type { Model } from "../Base.ts"; +import type { Session } from "../../session/Session.ts"; +import type { DiscordInteraction } from "../../vendor/external.ts"; +import type CommandInteraction from "./CommandInteraction.ts"; +import type PingInteraction from "./PingInteraction.ts"; +import { InteractionTypes } from "../../vendor/external.ts"; +import { Snowflake } from "../../util/Snowflake.ts"; +import User from "../User.ts"; +import Member from "../Member.ts"; +import Permsisions from "../Permissions.ts"; + +export abstract class BaseInteraction implements Model { + constructor(session: Session, data: DiscordInteraction) { + this.session = session; + this.id = data.id; + this.token = data.token; + this.type = data.type; + this.guildId = data.guild_id; + this.channelId = data.channel_id; + this.applicationId = data.application_id; + this.version = data.version; + + // @ts-expect-error: vendor error + const perms = data.app_permissions as string; + + if (perms) { + this.appPermissions = new Permsisions(BigInt(perms)); + } + + if (!data.guild_id) { + this.user = new User(session, data.user!); + } else { + this.member = new Member(session, data.member!, data.guild_id); + } + } + + readonly session: Session; + readonly id: Snowflake; + readonly token: string; + + type: InteractionTypes; + guildId?: Snowflake; + channelId?: Snowflake; + applicationId?: Snowflake; + user?: User; + member?: Member; + appPermissions?: Permsisions; + + readonly version: 1; + + get createdTimestamp() { + return Snowflake.snowflakeToTimestamp(this.id); + } + + get createdAt() { + return new Date(this.createdTimestamp); + } + + isCommand(): this is CommandInteraction { + return this.type === InteractionTypes.ApplicationCommand; + } + + isAutoComplete() { + return this.type === InteractionTypes.ApplicationCommandAutocomplete; + } + + isComponent() { + return this.type === InteractionTypes.MessageComponent; + } + + isPing(): this is PingInteraction { + return this.type === InteractionTypes.Ping; + } + + isModalSubmit() { + return this.type === InteractionTypes.ModalSubmit; + } + + inGuild() { + return !!this.guildId; + } +} + +export default BaseInteraction; diff --git a/structures/interactions/CommandInteraction.ts b/structures/interactions/CommandInteraction.ts new file mode 100644 index 0000000..0d25e91 --- /dev/null +++ b/structures/interactions/CommandInteraction.ts @@ -0,0 +1,150 @@ +import type { Model } from "../Base.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { ApplicationCommandTypes, DiscordMemberWithUser, DiscordInteraction, InteractionTypes } from "../../vendor/external.ts"; +import type { CreateMessage } from "../Message.ts"; +import type { MessageFlags } from "../../util/shared/flags.ts"; +import { InteractionResponseTypes } from "../../vendor/external.ts"; +import BaseInteraction from "./BaseInteraction.ts"; +import CommandInteractionOptionResolver from "./CommandInteractionOptionResolver.ts"; +import Attachment from "../Attachment.ts"; +import User from "../User.ts"; +import Member from "../Member.ts"; +import Message from "../Message.ts"; +import Role from "../Role.ts"; +import Webhook from "../Webhook.ts"; +import * as Routes from "../../util/Routes.ts"; + +/** + * @link https://discord.com/developers/docs/interactions/slash-commands#interaction-response + * */ +export interface InteractionResponse { + type: InteractionResponseTypes; + data?: InteractionApplicationCommandCallbackData; +} + +/** + * @link https://discord.com/developers/docs/interactions/slash-commands#interaction-response-interactionapplicationcommandcallbackdata + * */ +export interface InteractionApplicationCommandCallbackData extends Pick { + customId?: string; + title?: string; + // components?: MessageComponents; + flags?: MessageFlags; + choices?: ApplicationCommandOptionChoice[]; +} + +/** + * @link https://discord.com/developers/docs/interactions/slash-commands#applicationcommandoptionchoice + * */ +export interface ApplicationCommandOptionChoice { + name: string; + value: string | number; +} + +export class CommandInteraction extends BaseInteraction implements Model { + constructor(session: Session, data: DiscordInteraction) { + super(session, data); + this.type = data.type as number; + this.commandId = data.data!.id; + this.commandName = data.data!.name; + this.commandType = data.data!.type; + this.commandGuildId = data.data!.guild_id; + this.options = new CommandInteractionOptionResolver(data.data!.options ?? []); + + this.resolved = { + users: new Map(), + members: new Map(), + roles: new Map(), + attachments: new Map(), + messages: new Map(), + }; + + if (data.data!.resolved?.users) { + for (const [id, u] of Object.entries(data.data!.resolved.users)) { + this.resolved.users.set(id, new User(session, u)); + } + } + + if (data.data!.resolved?.members && !!super.guildId) { + for (const [id, m] of Object.entries(data.data!.resolved.members)) { + this.resolved.members.set(id, new Member(session, m as DiscordMemberWithUser, super.guildId!)); + } + } + + if (data.data!.resolved?.roles && !!super.guildId) { + for (const [id, r] of Object.entries(data.data!.resolved.roles)) { + this.resolved.roles.set(id, new Role(session, r, super.guildId!)); + } + } + + if (data.data!.resolved?.attachments) { + for (const [id, a] of Object.entries(data.data!.resolved.attachments)) { + this.resolved.attachments.set(id, new Attachment(session, a)); + } + } + + if (data.data!.resolved?.messages) { + for (const [id, m] of Object.entries(data.data!.resolved.messages)) { + this.resolved.messages.set(id, new Message(session, m)); + } + } + } + + override type: InteractionTypes.ApplicationCommand; + commandId: Snowflake; + commandName: string; + commandType: ApplicationCommandTypes; + commandGuildId?: Snowflake; + resolved: { + users: Map; + members: Map; + roles: Map; + attachments: Map; + messages: Map; + }; + options: CommandInteractionOptionResolver; + responded = false; + + async sendFollowUp(options: InteractionApplicationCommandCallbackData): Promise { + const message = await Webhook.prototype.execute.call({ + id: this.applicationId!, + token: this.token, + session: this.session, + }, options); + + return message!; + } + + async respond({ type, data: options }: InteractionResponse): Promise { + const data = { + content: options?.content, + custom_id: options?.customId, + file: options?.files, + allowed_mentions: options?.allowedMentions, + flags: options?.flags, + chocies: options?.choices, + embeds: options?.embeds, + title: options?.title, + }; + + if (!this.respond) { + await this.session.rest.sendRequest(this.session.rest, { + url: Routes.INTERACTION_ID_TOKEN(this.id, this.token), + method: "POST", + payload: this.session.rest.createRequestBody(this.session.rest, { + method: "POST", + body: { type, data, file: options?.files }, + headers: { "Authorization": "" }, + }), + }); + + this.responded = true; + return; + } + + return this.sendFollowUp(data); + } +} + +export default CommandInteraction; diff --git a/structures/interactions/CommandInteractionOptionResolver.ts b/structures/interactions/CommandInteractionOptionResolver.ts new file mode 100644 index 0000000..a654566 --- /dev/null +++ b/structures/interactions/CommandInteractionOptionResolver.ts @@ -0,0 +1,233 @@ +import type { DiscordInteractionDataOption, DiscordInteractionDataResolved } from '../../vendor/external.ts'; +import { ApplicationCommandOptionTypes } from "../../vendor/external.ts"; + +export function transformOasisInteractionDataOption(o: DiscordInteractionDataOption): CommandInteractionOption { + const output: CommandInteractionOption = { ...o, Otherwise: o.value as string | boolean | number | undefined }; + + switch (o.type) { + case ApplicationCommandOptionTypes.String: + output.String = o.value as string; + break; + case ApplicationCommandOptionTypes.Number: + output.Number = o.value as number; + break; + case ApplicationCommandOptionTypes.Integer: + output.Integer = o.value as number; + break; + case ApplicationCommandOptionTypes.Boolean: + output.Boolean = o.value as boolean; + break; + case ApplicationCommandOptionTypes.Role: + output.Role = BigInt(o.value as string); + break; + case ApplicationCommandOptionTypes.User: + output.User = BigInt(o.value as string); + break; + case ApplicationCommandOptionTypes.Channel: + output.Channel = BigInt(o.value as string); + break; + + case ApplicationCommandOptionTypes.Mentionable: + case ApplicationCommandOptionTypes.SubCommand: + case ApplicationCommandOptionTypes.SubCommandGroup: + default: + output.Otherwise = o.value as string | boolean | number | undefined; + } + + return output; +} + +export interface CommandInteractionOption extends Omit { + Attachment?: string; + Boolean?: boolean; + User?: bigint; + Role?: bigint; + Number?: number; + Integer?: number; + Channel?: bigint; + String?: string; + Mentionable?: string; + Otherwise: string | number | boolean | bigint | undefined; +} + +/** + * Utility class to get the resolved options for a command + * It is really typesafe + * @example const option = ctx.options.getStringOption("name"); + */ +export class CommandInteractionOptionResolver { + #subcommand?: string; + #group?: string; + + hoistedOptions: CommandInteractionOption[]; + resolved?: DiscordInteractionDataResolved; + + constructor(options?: DiscordInteractionDataOption[], resolved?: DiscordInteractionDataResolved) { + this.hoistedOptions = options?.map(transformOasisInteractionDataOption) ?? []; + + // warning: black magic do not edit and thank djs authors + + if (this.hoistedOptions[0]?.type === ApplicationCommandOptionTypes.SubCommandGroup) { + this.#group = this.hoistedOptions[0].name; + this.hoistedOptions = (this.hoistedOptions[0].options ?? []).map(transformOasisInteractionDataOption); + } + + if (this.hoistedOptions[0]?.type === ApplicationCommandOptionTypes.SubCommand) { + this.#subcommand = this.hoistedOptions[0].name; + this.hoistedOptions = (this.hoistedOptions[0].options ?? []).map(transformOasisInteractionDataOption); + } + + this.resolved = resolved; + } + + private getTypedOption( + name: string | number, + type: ApplicationCommandOptionTypes, + properties: Array, + required: boolean, + ) { + const option = this.get(name, required); + + if (!option) { + return; + } + + if (option.type !== type) { + // pass + } + + if (required === true && properties.every((prop) => typeof option[prop] === "undefined")) { + throw new TypeError(`Properties ${properties.join(', ')} are missing in option ${name}`); + } + + return option; + } + + get(name: string | number, required: true): CommandInteractionOption; + get(name: string | number, required: boolean): CommandInteractionOption | undefined; + get(name: string | number, required?: boolean) { + const option = this.hoistedOptions.find((o) => + typeof name === 'number' ? o.name === name.toString() : o.name === name + ); + + if (!option) { + if (required && name in this.hoistedOptions.map((o) => o.name)) { + throw new TypeError('Option marked as required was undefined'); + } + + return; + } + + return option; + } + + /** searches for a string option */ + getString(name: string | number, required: true): string; + getString(name: string | number, required?: boolean): string | undefined; + getString(name: string | number, required = false) { + const option = this.getTypedOption(name, ApplicationCommandOptionTypes.String, ['Otherwise'], required); + + return option?.Otherwise ?? undefined; + } + + /** searches for a number option */ + getNumber(name: string | number, required: true): number; + getNumber(name: string | number, required?: boolean): number | undefined; + getNumber(name: string | number, required = false) { + const option = this.getTypedOption(name, ApplicationCommandOptionTypes.Number, ['Otherwise'], required); + + return option?.Otherwise ?? undefined; + } + + /** searhces for an integer option */ + getInteger(name: string | number, required: true): number; + getInteger(name: string | number, required?: boolean): number | undefined; + getInteger(name: string | number, required = false) { + const option = this.getTypedOption(name, ApplicationCommandOptionTypes.Integer, ['Otherwise'], required); + + return option?.Otherwise ?? undefined; + } + + /** searches for a boolean option */ + getBoolean(name: string | number, required: true): boolean; + getBoolean(name: string | number, required?: boolean): boolean | undefined; + getBoolean(name: string | number, required = false) { + const option = this.getTypedOption(name, ApplicationCommandOptionTypes.Boolean, ['Otherwise'], required); + + return option?.Otherwise ?? undefined; + } + + /** searches for a user option */ + getUser(name: string | number, required: true): bigint; + getUser(name: string | number, required?: boolean): bigint | undefined; + getUser(name: string | number, required = false) { + const option = this.getTypedOption(name, ApplicationCommandOptionTypes.User, ['Otherwise'], required); + + return option?.Otherwise ?? undefined; + } + + /** searches for a channel option */ + getChannel(name: string | number, required: true): bigint; + getChannel(name: string | number, required?: boolean): bigint | undefined; + getChannel(name: string | number, required = false) { + const option = this.getTypedOption(name, ApplicationCommandOptionTypes.Channel, ['Otherwise'], required); + + return option?.Otherwise ?? undefined; + } + + /** searches for a mentionable-based option */ + getMentionable(name: string | number, required: true): string; + getMentionable(name: string | number, required?: boolean): string | undefined; + getMentionable(name: string | number, required = false) { + const option = this.getTypedOption(name, ApplicationCommandOptionTypes.Mentionable, ['Otherwise'], required); + + return option?.Otherwise ?? undefined; + } + + /** searches for a mentionable-based option */ + getRole(name: string | number, required: true): bigint; + getRole(name: string | number, required?: boolean): bigint | undefined; + getRole(name: string | number, required = false) { + const option = this.getTypedOption(name, ApplicationCommandOptionTypes.Role, ['Otherwise'], required); + + return option?.Otherwise ?? undefined; + } + + /** searches for an attachment option */ + getAttachment(name: string | number, required: true): string; + getAttachment(name: string | number, required?: boolean): string | undefined; + getAttachment(name: string | number, required = false) { + const option = this.getTypedOption(name, ApplicationCommandOptionTypes.Attachment, ['Otherwise'], required); + + return option?.Otherwise ?? undefined; + } + + /** searches for the focused option */ + getFocused(full = false) { + const focusedOption = this.hoistedOptions.find((option) => option.focused); + + if (!focusedOption) { + throw new TypeError('No option found'); + } + + return full ? focusedOption : focusedOption.Otherwise; + } + + getSubCommand(required = true) { + if (required && !this.#subcommand) { + throw new TypeError('Option marked as required was undefined'); + } + + return [this.#subcommand, this.hoistedOptions]; + } + + getSubCommandGroup(required = false) { + if (required && !this.#group) { + throw new TypeError('Option marked as required was undefined'); + } + + return [this.#group, this.hoistedOptions]; + } +} + +export default CommandInteractionOptionResolver; diff --git a/structures/interactions/ComponentInteraction.ts b/structures/interactions/ComponentInteraction.ts new file mode 100644 index 0000000..a85524f --- /dev/null +++ b/structures/interactions/ComponentInteraction.ts @@ -0,0 +1,45 @@ +import type { Model } from "../Base.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { DiscordInteraction, InteractionTypes } from "../../vendor/external.ts"; +import { MessageComponentTypes } from "../../vendor/external.ts"; +import BaseInteraction from "./BaseInteraction.ts"; +import Message from "../Message.ts"; + +export class ComponentInteraction extends BaseInteraction implements Model { + constructor(session: Session, data: DiscordInteraction) { + super(session, data); + this.type = data.type as number; + this.componentType = data.data!.component_type!; + this.customId = data.data!.custom_id; + this.targetId = data.data!.target_id; + this.values = data.data!.values; + this.message = new Message(session, data.message!); + } + + override type: InteractionTypes.MessageComponent; + componentType: MessageComponentTypes; + customId?: string; + targetId?: Snowflake; + values?: string[]; + message: Message; + responded = false; + + isButton() { + return this.componentType === MessageComponentTypes.Button; + } + + isActionRow() { + return this.componentType === MessageComponentTypes.ActionRow; + } + + isTextInput() { + return this.componentType === MessageComponentTypes.InputText; + } + + isSelectMenu() { + return this.componentType === MessageComponentTypes.SelectMenu; + } +} + +export default ComponentInteraction; diff --git a/structures/interactions/Interaction.ts b/structures/interactions/Interaction.ts deleted file mode 100644 index 5ed2b94..0000000 --- a/structures/interactions/Interaction.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Model } from "../Base.ts"; -import type { Snowflake } from "../../util/Snowflake.ts"; -import type { Session } from "../../session/Session.ts"; -import type { - DiscordInteraction, - DiscordMessage, - FileContent, - InteractionResponseTypes, - InteractionTypes, -} from "../../vendor/external.ts"; -import type { MessageFlags } from "../../util/shared/flags.ts"; -import type { AllowedMentions } from "../Message.ts"; -import User from "../User.ts"; -import Message from "../Message.ts"; -import Member from "../Member.ts"; -import * as Routes from "../../util/Routes.ts"; - -export interface InteractionResponse { - type: InteractionResponseTypes; - data?: InteractionApplicationCommandCallbackData; -} - -export interface InteractionApplicationCommandCallbackData { - content?: string; - tts?: boolean; - allowedMentions?: AllowedMentions; - files?: FileContent[]; - customId?: string; - title?: string; - // components?: Component[]; - flags?: MessageFlags; - choices?: ApplicationCommandOptionChoice[]; -} - -/** https://discord.com/developers/docs/interactions/slash-commands#applicationcommandoptionchoice */ -export interface ApplicationCommandOptionChoice { - name: string; - value: string | number; -} - -// TODO: abstract Interaction, CommandInteraction, ComponentInteraction, PingInteraction, etc - -export class Interaction implements Model { - constructor(session: Session, data: DiscordInteraction) { - this.session = session; - this.id = data.id; - this.token = data.token; - this.type = data.type; - this.guildId = data.guild_id; - this.channelId = data.channel_id; - this.applicationId = data.application_id; - this.locale = data.locale; - this.data = data.data; - - if (!data.guild_id) { - this.user = new User(session, data.user!); - } else { - this.member = new Member(session, data.member!, data.guild_id); - } - } - - readonly session: Session; - readonly id: Snowflake; - readonly token: string; - - type: InteractionTypes; - guildId?: Snowflake; - channelId?: Snowflake; - applicationId?: Snowflake; - locale?: string; - // deno-lint-ignore no-explicit-any - data: any; - user?: User; - member?: Member; - - async respond({ type, data }: InteractionResponse) { - const toSend = { - tts: data?.tts, - title: data?.title, - flags: data?.flags, - content: data?.content, - choices: data?.choices, - custom_id: data?.customId, - allowed_mentions: data?.allowedMentions - ? { - users: data.allowedMentions.users, - roles: data.allowedMentions.roles, - parse: data.allowedMentions.parse, - replied_user: data.allowedMentions.repliedUser, - } - : { parse: [] }, - }; - - if (this.session.unrepliedInteractions.delete(BigInt(this.id))) { - await this.session.rest.sendRequest( - this.session.rest, - { - url: Routes.INTERACTION_ID_TOKEN(this.id, this.token), - method: "POST", - payload: this.session.rest.createRequestBody(this.session.rest, { - method: "POST", - body: { - type: type, - data: toSend, - file: data?.files, - }, - headers: { - // remove authorization header - Authorization: "", - }, - }), - }, - ); - - return; - } - - const result = await this.session.rest.sendRequest( - this.session.rest, - { - url: Routes.WEBHOOK(this.session.applicationId ?? this.session.botId, this.token), - method: "POST", - payload: this.session.rest.createRequestBody(this.session.rest, { - method: "POST", - body: { - ...toSend, - file: data?.files, - }, - headers: { - // remove authorization header - Authorization: "", - }, - }), - }, - ); - - return new Message(this.session, result); - } - - inGuild(): this is Interaction & { user: undefined; guildId: Snowflake; member: Member } { - return !!this.guildId; - } -} - -export default Interaction; diff --git a/structures/interactions/InteractionFactory.ts b/structures/interactions/InteractionFactory.ts new file mode 100644 index 0000000..386d23d --- /dev/null +++ b/structures/interactions/InteractionFactory.ts @@ -0,0 +1,34 @@ +import type { Session } from "../../session/Session.ts"; +import type { DiscordInteraction } from "../../vendor/external.ts"; +import { InteractionTypes } from "../../vendor/external.ts"; +import CommandInteraction from "./CommandInteraction.ts"; +import ComponentInteraction from "./ComponentInteraction.ts"; +import PingInteraction from "./PingInteraction.ts"; +import AutoCompleteInteraction from "./AutoCompleteInteraction.ts"; +import ModalSubmitInteraction from "./ModalSubmitInteraction.ts"; + +export type Interaction = + | CommandInteraction + | ComponentInteraction + | PingInteraction + | AutoCompleteInteraction + | ModalSubmitInteraction; + +export class InteractionFactory { + static from(session: Session, interaction: DiscordInteraction): Interaction { + switch (interaction.type) { + case InteractionTypes.Ping: + return new PingInteraction(session, interaction); + case InteractionTypes.ApplicationCommand: + return new CommandInteraction(session, interaction); + case InteractionTypes.MessageComponent: + return new ComponentInteraction(session, interaction); + case InteractionTypes.ApplicationCommandAutocomplete: + return new AutoCompleteInteraction(session, interaction); + case InteractionTypes.ModalSubmit: + return new ModalSubmitInteraction(session, interaction); + } + } +} + +export default InteractionFactory; diff --git a/structures/interactions/ModalSubmitInteraction.ts b/structures/interactions/ModalSubmitInteraction.ts new file mode 100644 index 0000000..ff14a18 --- /dev/null +++ b/structures/interactions/ModalSubmitInteraction.ts @@ -0,0 +1,50 @@ + +import type { Model } from "../Base.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { DiscordInteraction, InteractionTypes, MessageComponentTypes, DiscordMessageComponents } from "../../vendor/external.ts"; +import BaseInteraction from "./BaseInteraction.ts"; +import Message from "../Message.ts"; + +export class ModalSubmitInteraction extends BaseInteraction implements Model { + constructor(session: Session, data: DiscordInteraction) { + super(session, data); + this.type = data.type as number; + this.componentType = data.data!.component_type!; + this.customId = data.data!.custom_id; + this.targetId = data.data!.target_id; + this.values = data.data!.values; + + this.components = data.data?.components?.map(ModalSubmitInteraction.transformComponent); + + if (data.message) { + this.message = new Message(session, data.message); + } + } + + override type: InteractionTypes.MessageComponent; + componentType: MessageComponentTypes; + customId?: string; + targetId?: Snowflake; + values?: string[]; + message?: Message; + components; + + static transformComponent(component: DiscordMessageComponents[number]) { + return { + type: component.type, + components: component.components.map((component) => { + return { + customId: component.custom_id, + value: (component as typeof component & { value: string }).value, + }; + }), + }; + } + + inMessage(): this is ModalSubmitInteraction & { message: Message } { + return !!this.message; + } +} + +export default ModalSubmitInteraction; diff --git a/structures/interactions/PingInteraction.ts b/structures/interactions/PingInteraction.ts new file mode 100644 index 0000000..438ac01 --- /dev/null +++ b/structures/interactions/PingInteraction.ts @@ -0,0 +1,38 @@ + +import type { Model } from "../Base.ts"; +import type { Snowflake } from "../../util/Snowflake.ts"; +import type { Session } from "../../session/Session.ts"; +import type { ApplicationCommandTypes, DiscordInteraction, InteractionTypes } from "../../vendor/external.ts"; +import { InteractionResponseTypes } from "../../vendor/external.ts"; +import BaseInteraction from "./BaseInteraction.ts"; +import * as Routes from "../../util/Routes.ts"; + +export class PingInteraction extends BaseInteraction implements Model { + constructor(session: Session, data: DiscordInteraction) { + super(session, data); + this.type = data.type as number; + this.commandId = data.data!.id; + this.commandName = data.data!.name; + this.commandType = data.data!.type; + this.commandGuildId = data.data!.guild_id; + } + + override type: InteractionTypes.Ping; + commandId: Snowflake; + commandName: string; + commandType: ApplicationCommandTypes; + commandGuildId?: Snowflake; + + async pong() { + await this.session.rest.runMethod( + this.session.rest, + "POST", + Routes.INTERACTION_ID_TOKEN(this.id, this.token), + { + type: InteractionResponseTypes.Pong, + } + ); + } +} + +export default PingInteraction; diff --git a/util/Routes.ts b/util/Routes.ts index 2748cbc..e928d63 100644 --- a/util/Routes.ts +++ b/util/Routes.ts @@ -144,11 +144,26 @@ export function INTERACTION_ID_TOKEN(interactionId: Snowflake, token: string) { return `/interactions/${interactionId}/${token}/callback`; } -export function WEBHOOK(webhookId: Snowflake, token: string, options?: { wait?: boolean; threadId?: Snowflake }) { - let url = `/webhooks/${webhookId}/${token}?`; +export function WEBHOOK_MESSAGE(webhookId: Snowflake, token: string, messageId: Snowflake) { + return `/webhooks/${webhookId}/${token}/messages/${messageId}`; +} - if (options?.wait !== undefined) url += `wait=${options.wait}`; - if (options?.threadId) url += `threadId=${options.threadId}`; +export function WEBHOOK_TOKEN(webhookId: Snowflake, token?: string) { + if (!token) return `/webhooks/${webhookId}`; + return `/webhooks/${webhookId}/${token}`; +} + +export interface WebhookOptions { + wait?: boolean; + threadId?: Snowflake; +} + +export function WEBHOOK(webhookId: Snowflake, token: string, options?: WebhookOptions) { + let url = `/webhooks/${webhookId}/${token}`; + + if (options?.wait) url += `?wait=${options.wait}`; + if (options?.threadId) url += `?threadId=${options.threadId}`; + if (options?.wait && options.threadId) url += `?wait=${options.wait}&threadId=${options.threadId}`; return url; }