diff --git a/.gitignore b/.gitignore index aa7e0d962..39ac177f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Allows quick testing of changes and keeps stuff like tokens private +debug.ts + .DS_Store .lock diff --git a/src/api/controllers/mod.ts b/src/api/controllers/mod.ts index b11d9f1dd..bdd216388 100644 --- a/src/api/controllers/mod.ts +++ b/src/api/controllers/mod.ts @@ -44,6 +44,10 @@ import { handleInternalGuildRoleDelete, handleInternalGuildRoleUpdate, } from "./roles.ts"; +import { + handleInternalInteractionsCommandCreate, + handleInternalInteractionsCreate, +} from "./interactions.ts"; export let controllers = { READY: handleInternalReady, @@ -63,6 +67,8 @@ export let controllers = { GUILD_ROLE_CREATE: handleInternalGuildRoleCreate, GUILD_ROLE_DELETE: handleInternalGuildRoleDelete, GUILD_ROLE_UPDATE: handleInternalGuildRoleUpdate, + INTERACTION_CREATE: handleInternalInteractionsCreate, + APPLICATION_COMMAND_CREATE: handleInternalInteractionsCommandCreate, MESSAGE_CREATE: handleInternalMessageCreate, MESSAGE_DELETE: handleInternalMessageDelete, MESSAGE_DELETE_BULK: handleInternalMessageDeleteBulk, diff --git a/src/api/handlers/webhook.ts b/src/api/handlers/webhook.ts index ed9fe9583..ce42683c0 100644 --- a/src/api/handlers/webhook.ts +++ b/src/api/handlers/webhook.ts @@ -1,16 +1,23 @@ import { RequestManager } from "../../rest/mod.ts"; import { structures } from "../structures/structures.ts"; import { + CreateSlashCommandOptions, + EditSlashCommandOptions, + EditSlashResponseOptions, EditWebhookMessageOptions, Errors, + ExecuteSlashCommandOptions, ExecuteWebhookOptions, MessageCreateOptions, + UpsertSlashCommandOptions, WebhookCreateOptions, WebhookPayload, } from "../../types/types.ts"; import { endpoints } from "../../util/constants.ts"; import { botHasChannelPermissions } from "../../util/permissions.ts"; import { urlToBase64 } from "../../util/utils.ts"; +import { botID } from "../../bot.ts"; +import { cache } from "../../util/cache.ts"; /** Create a new webhook. Requires the MANAGE_WEBHOOKS permission. Returns a webhook object on success. Webhook names follow our naming restrictions that can be found in our Usernames and Nicknames documentation, with the following additional stipulations: * @@ -171,3 +178,139 @@ export function deleteWebhookMessage( endpoints.WEBHOOK_DELETE(webhookID, webhookToken, messageID), ); } + +/** + * There are two kinds of Slash Commands: global commands and guild commands. Global commands are available for every guild that adds your app; guild commands are specific to the guild you specify when making them. Command names are unique per application within each scope (global and guild). That means: + * + * - Your app **cannot** have two global commands with the same name + * - Your app **cannot** have two guild commands within the same name **on the same guild** + * - Your app **can** have a global and guild command with the same name + * - Multiple apps **can** have commands with the same names + * + * Global commands are cached for **1 hour**. That means that new global commands will fan out slowly across all guilds, and will be guaranteed to be updated in an hour. + * Guild commands update **instantly**. We recommend you use guild commands for quick testing, and global commands when they're ready for public use. + */ +export function createSlashCommand(options: CreateSlashCommandOptions) { + // Use ... for content length due to unicode characters and js .length handling + if ([...options.name].length < 2 || [...options.name].length > 32) { + throw new Error(Errors.INVALID_SLASH_NAME); + } + + if ( + [...options.description].length < 1 || [...options.description].length > 100 + ) { + throw new Error(Errors.INVALID_SLASH_DESCRIPTION); + } + + return RequestManager.post( + options.guildID + ? endpoints.COMMANDS_GUILD(botID, options.guildID) + : endpoints.COMMANDS(botID), + { + ...options, + }, + ); +} + +/** Fetch all of the global commands for your application. */ +export function getSlashCommands(guildID?: string) { + // TODO: Should this be a returned as a collection? + return RequestManager.get( + guildID + ? endpoints.COMMANDS_GUILD(botID, guildID) + : endpoints.COMMANDS(botID), + ); +} + +/** + * Edit an existing slash command. If this command did not exist, it will create it. + */ +export function upsertSlashCommand(options: UpsertSlashCommandOptions) { + return RequestManager.post( + options.guildID + ? endpoints.COMMANDS_GUILD_ID(botID, options.id, options.guildID) + : endpoints.COMMANDS_ID(botID, options.id), + { + ...options, + }, + ); +} + +/** Edit an existing slash command. */ +export function editSlashCommand(options: EditSlashCommandOptions) { + return RequestManager.patch( + options.guildID + ? endpoints.COMMANDS_GUILD_ID(botID, options.id, options.guildID) + : endpoints.COMMANDS_ID(botID, options.id), + { + ...options, + }, + ); +} + +/** Deletes a slash command. */ +export function deleteSlashCommand(id: string, guildID?: string) { + if (!guildID) return RequestManager.delete(endpoints.COMMANDS_ID(botID, id)); + return RequestManager.delete(endpoints.COMMANDS_GUILD_ID(botID, id, guildID)); +} + +/** + * Send a response to a users slash command. The command data will have the id and token necessary to respond. + * Interaction `tokens` are valid for **15 minutes** and can be used to send followup messages. + * + * NOTE: By default we will suppress mentions. To enable mentions, just pass any mentions object. + */ +export function executeSlashCommand( + id: string, + token: string, + options: ExecuteSlashCommandOptions, +) { + // If its already been executed, we need to send a followup response + if (cache.executedSlashCommands.has(token)) { + return RequestManager.post(endpoints.WEBHOOK(botID, token), { + ...options, + }); + } + + // Expire in 15 minutes + cache.executedSlashCommands.set(token, id); + setTimeout( + () => cache.executedSlashCommands.delete(token), + Date.now() + 900000, + ); + + // IF NO MENTIONS ARE PROVIDED, FORCE DISABLE MENTIONS + if (!(options.data.allowed_mentions)) { + options.data.allowed_mentions = { parse: [] }; + } + + return RequestManager.post(endpoints.INTERACTION_ID_TOKEN(id, token), { + ...options, + }); +} + +/** To delete your response to a slash command. If a message id is not provided, it will default to deleting the original response. */ +export function deleteSlashResponse( + token: string, + messageID?: string, +) { + if (!messageID) { + return RequestManager.delete( + endpoints.INTERACTION_ORIGINAL_ID_TOKEN(botID, token), + ); + } + return RequestManager.delete( + endpoints.INTERACTION_ID_TOKEN_MESSAGEID(botID, token, messageID), + ); +} + +/** To edit your response to a slash command. If a messageID is not provided it will default to editing the original response. */ +export function editSlashResponse( + token: string, + options: EditSlashResponseOptions, +) { + return RequestManager.patch( + endpoints.INTERACTION_ORIGINAL_ID_TOKEN(botID, token), + options, + ); +} diff --git a/src/controllers/interactions.ts b/src/controllers/interactions.ts new file mode 100644 index 000000000..f808070e1 --- /dev/null +++ b/src/controllers/interactions.ts @@ -0,0 +1,26 @@ +import { DiscordPayload } from "../types/types.ts"; +import { eventHandlers } from "../module/client.ts"; +import { structures } from "../structures/mod.ts"; +import { InteractionCommandPayload } from "../types/types.ts"; + +export async function handleInternalInteractionsCreate(data: DiscordPayload) { + if (data.t !== "INTERACTION_CREATE") return; + + const payload = data.d as InteractionCommandPayload; + + eventHandlers.interactionCreate?.( + { + ...payload, + member: await structures.createMember(payload.member, payload.guild_id), + }, + ); +} + +export async function handleInternalInteractionsCommandCreate( + data: DiscordPayload, +) { + if (data.t !== "APPLICATION_COMMAND_CREATE") return; + + console.log(data); + eventHandlers.interactionCreate?.(data); +} diff --git a/src/interactions/deps.ts b/src/interactions/deps.ts new file mode 100644 index 000000000..e2f90fc92 --- /dev/null +++ b/src/interactions/deps.ts @@ -0,0 +1,2 @@ +export { serve } from "https://deno.land/std@0.81.0/http/server.ts"; +export { verify } from "https://esm.sh/@evan/wasm@0.0.18/target/ed25519/deno.js"; diff --git a/src/interactions/interactions.ts b/src/interactions/interactions.ts new file mode 100644 index 000000000..b58cf05f1 --- /dev/null +++ b/src/interactions/interactions.ts @@ -0,0 +1,133 @@ +import { serve, verify } from "./deps.ts"; +import { + Interaction, + InteractionResponse, + InteractionResponseType, + InteractionType, +} from "./types/mod.ts"; + +/** This variable is a holder for the public key and other configuration */ +const serverOptions = { + publicKey: "", + port: 80, +}; + +/** Theses are the controllers that you can plug into and customize to your needs. */ +export const controllers = { + handlePayload, + handleApplicationCommand, +}; + +export interface StartServerConfig { + /** The public key from your discord bot dashboard at discord.dev */ + publicKey: string; + /** The port number you are wanting to listen to, if you are following the guide, you probably want 80 */ + port: number; + /** The function you would like to provide to handle your commands. */ + handleApplicationCommand?( + payload: Interaction, + ): Promise<{ status?: number; body: InteractionResponse }>; +} + +/** Starts the slash command server */ +export async function startServer( + { port, publicKey, handleApplicationCommand }: StartServerConfig, +) { + serverOptions.publicKey = publicKey; + serverOptions.port = port; + if (handleApplicationCommand) { + controllers.handleApplicationCommand = handleApplicationCommand; + } + + const server = serve({ port: serverOptions.port }); + + for await (const req of server) { + const buffer = await Deno.readAll(req.body); + const signature = req.headers.get("X-Signature-Ed25519"); + const timestamp = req.headers.get("X-Signature-Timestamp"); + + if (!signature || !timestamp) { + req.respond({ status: 400, body: "Bad request" }); + continue; + } + + const isVerified = verifySecurity(buffer, signature!, timestamp!); + if (!isVerified) { + req.respond({ status: 401, body: "Invalid request signature" }); + continue; + } + + try { + const data = JSON.parse(new TextDecoder().decode(buffer)); + const response = await controllers.handlePayload(data); + req.respond( + { status: response.status || 200, body: JSON.stringify(response.body) }, + ); + } catch (error) { + console.error(error); + } + } +} + +async function handlePayload(payload: Interaction) { + switch (payload.type) { + case InteractionType.PING: + return { status: 200, body: { type: InteractionResponseType.PONG } }; + default: // APPLICATION_COMMAND + return controllers.handleApplicationCommand(payload); + } +} + +/** The function that handles your commands. This command can be overriden by you and you can receive the payload and handle accordingly and respond back. The status if not provided will default to 200. */ +async function handleApplicationCommand( + payload: Interaction, +): Promise<{ status?: number; body: InteractionResponse }> { + // Handle the command + if (payload.data?.name === "ping") { + return { + status: 200, + body: { + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "Pong from Discordeno!" }, + }, + }; + } + + return { + status: 200, + body: { + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: + "Whoopsies! Seems the handling for this command is missing. Please contact my developers!", + }, + }, + }; +} + +/** Internal function to verify security. Discord will send bad and good data and this function is important to verify it. If it is not verified properly, Discord will kill your bot. */ +function verifySecurity(buffer: Uint8Array, signature: string, time: string) { + const sig = new Uint8Array(64); + const timestamp = new TextEncoder().encode(time); + + let offset = 0; + const message = new Uint8Array(buffer.length + timestamp.length); + while (offset < 2 * 64) { + sig[offset / 2] = parseInt(signature!.substring(offset, offset += 2), 16); + } + + const slash_key = new Uint8Array(32); + + let keyoffset = 0; + while (keyoffset < 2 * 32) { + slash_key[keyoffset / 2] = parseInt( + serverOptions.publicKey.substring(keyoffset, keyoffset += 2), + 16, + ); + } + + message.set(timestamp); + message.set(buffer, timestamp.length); + + return verify(slash_key, sig, message); +} diff --git a/src/interactions/mod.ts b/src/interactions/mod.ts new file mode 100644 index 000000000..47e0d23e7 --- /dev/null +++ b/src/interactions/mod.ts @@ -0,0 +1,2 @@ +export * from "./interactions.ts"; +export * from "./types/mod.ts"; diff --git a/src/interactions/types/embed.ts b/src/interactions/types/embed.ts new file mode 100644 index 000000000..08522ab54 --- /dev/null +++ b/src/interactions/types/embed.ts @@ -0,0 +1,95 @@ +export interface Embed { + /** The title of the embed */ + title?: string; + /** The type of embed (always rich for webhook embeds) */ + type?: string; + /** The description of embeds */ + description?: string; + /** The url of embed */ + url?: string; + /** The timestap of the embed content */ + timestamp?: string; + /** The color code of the embed */ + color?: number; + /** The footer information */ + footer?: EmbedFooter; + /** The image information */ + image?: EmbedImage; + /** The thumbnail information */ + thumbnail?: EmbedThumbnail; + /** The video information */ + video?: EmbedVideo; + /** Provider information */ + provider?: EmbedProvider; + /** Author information */ + author?: EmbedAuthor; + /** Fields information */ + fields?: EmbedField[]; +} + +export interface EmbedFooter { + /** The text of the footer */ + text: string; + /** The url of the footer icon. Only supports http(s) and attachments */ + icon_url?: string; + /** A proxied url of footer icon */ + proxy_icon_url?: string; +} + +export interface EmbedImage { + /** The source url of image (only supports http(s) and attachments) */ + url?: string; + /** A proxied url of the image */ + proxy_url?: string; + /** The height of image */ + height?: number; + /** The width of the image */ + width?: number; +} + +export interface EmbedThumbnail { + /** The source url of image (only supports http(s) and attachments) */ + url?: string; + /** A proxied url of the thumbnail */ + proxy_url?: string; + /** The height of the thumbnail */ + height?: number; + /** The width of the thumbnail */ + width?: number; +} + +export interface EmbedVideo { + /** The source url of video */ + url?: string; + /** The height of the video */ + height?: number; + /** The width of the video */ + width?: number; +} + +export interface EmbedProvider { + /** The name of the provider */ + name?: string; + /** The url of the provider */ + url?: string; +} + +export interface EmbedAuthor { + /** The name of the author */ + name?: string; + /** The url of the author */ + url?: string; + /** The url of the author icon (supports http(s) and attachments) */ + icon_url?: string; + /** A proxied url of author icon */ + proxy_icon_url?: string; +} + +export interface EmbedField { + /** The name of the field */ + name: string; + /** The value of the field */ + value: string; + /** Whether or not this field should display inline */ + inline?: boolean; +} diff --git a/src/interactions/types/interactions.ts b/src/interactions/types/interactions.ts new file mode 100644 index 000000000..26fe43230 --- /dev/null +++ b/src/interactions/types/interactions.ts @@ -0,0 +1,76 @@ +import { Embed } from "./embed.ts"; +import { AllowedMentions } from "./misc.ts"; +import { MemberCreatePayload } from "./member.ts"; + +export interface Interaction { + /** The id of the interaction */ + id: string; + /** The type of interaction */ + type: InteractionType; + /** The command data payload */ + data?: SlashCommandInteractionData; + /** The id of the guild it was sent from */ + guild_id: string; + /** The id of the channel it was sent from */ + channel_id: string; + /** The Payload of the member it was sent from */ + member: MemberCreatePayload; + /** The token for this interaction */ + token: string; +} + +export interface SlashCommandInteractionData { + /** The id of the command */ + id: string; + /** The name of the command */ + name: string; + /** the params and values from the user */ + options: SlashCommandInteractionDataOption[]; +} + +export interface SlashCommandInteractionDataOption { + /** The name of the parammeter */ + name: string; + /** The value of the pair */ + value?: any; + /** Present if this option is a group or subcommand */ + options?: SlashCommandInteractionDataOption[]; +} + +export interface InteractionResponse { + /** The type of response */ + type: InteractionResponseType; + /** The optional response message */ + data?: SlashCommandCallbackData; +} + +export interface SlashCommandCallbackData { + /** is the response TTS */ + tts?: boolean; + /** message content */ + content: string; + /** supports up to 10 embeds */ + embeds?: Embed[]; + /** allowed mentions for the message */ + allowed_mentions?: AllowedMentions; + /** acceptable values are message flags */ + flags?: number; +} + +export enum InteractionType { + PING = 1, + APPLICATION_COMMAND = 2, +} + +export enum InteractionResponseType { + /** ACK a `Ping` */ + PONG = 1, + /** ACK a command without sending a message, eating the user's input */ + ACKNOWLEDGE = 2, + /** respond with a message, eating the user's input */ + CHANNEL_MESSAGE = 3, + /** respond with a message, showing the user's input */ + CHANNEL_MESSAGE_WITH_SOURCE = 4, + /** ACK a command without sending a message, showing the user's input */ + ACK_WITH_SOURCE = 5, +} diff --git a/src/interactions/types/member.ts b/src/interactions/types/member.ts new file mode 100644 index 000000000..f80c24fdc --- /dev/null +++ b/src/interactions/types/member.ts @@ -0,0 +1,43 @@ +export interface UserPayload { + /** The user's id */ + id: string; + /** the user's username, not unique across the platform */ + username: string; + /** The user's 4 digit discord tag */ + discriminator: string; + /** The user's avatar hash */ + avatar: string | null; + /** Whether the user is a bot */ + bot?: boolean; + /** Whether the user is an official discord system user (part of the urgent message system.) */ + system?: boolean; + /** Whether the user has two factor enabled on their account */ + mfa_enabled?: boolean; + /** the user's chosen language option */ + locale?: string; + /** Whether the email on this account has been verified */ + verified?: boolean; + /** The user's email */ + email?: string; + /** The flags on a user's account. */ + flags?: number; + /** The type of Nitro subscription on a user's account. */ + premium_type?: number; +} + +export interface MemberCreatePayload { + /** The user this guild member represents */ + user: UserPayload; + /** The user's guild nickname if one is set. */ + nick?: string; + /** Array of role ids that the member has */ + roles: string[]; + /** When the user joined the guild. */ + joined_at: string; + /** When the user used their nitro boost on the server. */ + premium_since?: string; + /** Whether the user is deafened in voice channels */ + deaf: boolean; + /** Whether the user is muted in voice channels */ + mute: boolean; +} diff --git a/src/interactions/types/misc.ts b/src/interactions/types/misc.ts new file mode 100644 index 000000000..6312df855 --- /dev/null +++ b/src/interactions/types/misc.ts @@ -0,0 +1,8 @@ +export interface AllowedMentions { + /** An array of allowed mention types to parse from the content. */ + parse: ("roles" | "users" | "everyone")[]; + /** Array of role_ids to mention (Max size of 100) */ + roles?: string[]; + /** Array of user_ids to mention (Max size of 100) */ + users?: string[]; +} diff --git a/src/interactions/types/mod.ts b/src/interactions/types/mod.ts new file mode 100644 index 000000000..051324f1a --- /dev/null +++ b/src/interactions/types/mod.ts @@ -0,0 +1,6 @@ +export * from "./embed.ts"; +export * from "./interactions.ts"; +export * from "./misc.ts"; +export * from "./slash.ts"; +export * from "./member.ts"; +export * from "./webhook.ts"; diff --git a/src/interactions/types/slash.ts b/src/interactions/types/slash.ts new file mode 100644 index 000000000..36386fd7c --- /dev/null +++ b/src/interactions/types/slash.ts @@ -0,0 +1,86 @@ +import { + InteractionResponseType, + SlashCommandCallbackData, +} from "./interactions.ts"; + +export interface CreateSlashCommandOptions { + /** The name of the slash command. */ + name: string; + /** The description of the slash command. */ + description: String; + /** If a guildID is provided, this will be a GUILD command. If none is provided it will be a GLOBAL command. */ + guildID?: string; + /** The options for this command */ + options?: SlashCommandOption[]; +} + +export interface SlashCommand { + /** unique id of the command */ + id: string; + /** unique id of the parent application */ + application_id: string; + /** 3-32 character name */ + name: string; + /** 1-100 character description */ + description: string; + /** the parameters for the command */ + options?: SlashCommandOption[]; +} + +export interface SlashCommandOption { + /** The type of option */ + type: SlashCommandOptionType; + /** 1-32 character name */ + name: string; + /** 1-100 character description*/ + description: string; + /** the first `required` option for the user to complete--only one option can be `default` */ + default?: boolean; + /** if the parameter is required or optional--default `false`*/ + required?: boolean; + /** + * If you specify `choices` for an option, they are the **only** valid values for a user to pick. + * choices for `string` and `int` types for the user to pick from + */ + choices?: SlashCommandOptionChoice[]; + /** if the option is a subcommand or subcommand group type, this nested options will be the parameters */ + options?: SlashCommandOption[]; +} + +export interface SlashCommandOptionChoice { + /** The name of the choice */ + name: string; + /** The value of the choice */ + value: string | number; +} + +export enum SlashCommandOptionType { + SUB_COMMAND = 1, + SUB_COMMAND_GROUP = 2, + STRING = 3, + INTEGER = 4, + BOOLEAN = 5, + USER = 6, + CHANNEL = 7, + ROLE = 8, +} + +export interface EditSlashCommandOptions { + id: string; + guildID?: string; +} + +export interface ExecuteSlashCommandOptions { + type: InteractionResponseType; + data: SlashCommandCallbackData; +} + +export interface EditSlashResponseOptions extends SlashCommandCallbackData { + /** If this is not provided, it will default to editing the original response. */ + messageID?: string; +} + +export interface UpsertSlashCommandOptions { + id: string; + guildID?: string; +} diff --git a/src/interactions/types/webhook.ts b/src/interactions/types/webhook.ts new file mode 100644 index 000000000..896e0faa9 --- /dev/null +++ b/src/interactions/types/webhook.ts @@ -0,0 +1,27 @@ +import { Embed } from "./embed.ts"; + +export interface ExecuteWebhookOptions { + /** waits for server confirmation of message send before response, and returns the created message body (defaults to false; when false a message that is not saved does not return an error) */ + wait?: boolean; + /** the message contents (up to 2000 characters) */ + content?: string; + /** override the default username of the webhook */ + username?: string; + /** override the default avatar of the webhook*/ + avatar_url?: string; + /** true if this is a TTS message */ + tts?: boolean; + /** file contents the contents of the file being sent one of content, file, embeds */ + file?: { blob: unknown; name: string }; + /** array of up to 10 embed objects embedded rich content. */ + embeds?: Embed[]; + /** allowed mentions for the message */ + mentions?: { + /** An array of allowed mention types to parse from the content. */ + parse: ("roles" | "users" | "everyone")[]; + /** Array of role_ids to mention (Max size of 100) */ + roles?: string[]; + /** Array of user_ids to mention (Max size of 100) */ + users?: string[]; + }; +} diff --git a/src/types/discord.ts b/src/types/discord.ts index d183c8dc0..f1da39f39 100644 --- a/src/types/discord.ts +++ b/src/types/discord.ts @@ -12,7 +12,7 @@ export interface DiscordPayload { s?: number; /** The event name for this payload. ONLY for OPCode 0 */ t?: - | "READY" + | "APPLICATION_COMMAND_CREATE" | "CHANNEL_CREATE" | "CHANNEL_DELETE" | "CHANNEL_UPDATE" @@ -29,6 +29,7 @@ export interface DiscordPayload { | "GUILD_ROLE_CREATE" | "GUILD_ROLE_DELETE" | "GUILD_ROLE_UPDATE" + | "INTERACTION_CREATE" | "MESSAGE_CREATE" | "MESSAGE_DELETE" | "MESSAGE_DELETE_BULK" @@ -38,6 +39,7 @@ export interface DiscordPayload { | "MESSAGE_REACTION_REMOVE_ALL" | "MESSAGE_REACTION_REMOVE_EMOJI" | "PRESENCE_UPDATE" + | "READY" | "TYPING_START" | "USER_UPDATE" | "VOICE_STATE_UPDATE" diff --git a/src/types/errors.ts b/src/types/errors.ts index 5ed296520..f0f758977 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -30,6 +30,8 @@ export enum Errors { BOTS_HIGHEST_ROLE_TOO_LOW = "BOTS_HIGHEST_ROLE_TOO_LOW", CHANNEL_NOT_IN_GUILD = "CHANNEL_NOT_IN_GUILD", INVALID_WEBHOOK_NAME = "INVALID_WEBHOOK_NAME", + INVALID_SLASH_NAME = "INVALID_SLASH_NAME", + INVALID_SLASH_DESCRIPTION = "INVALID_SLASH_DESCRIPTION", INVALID_WEBHOOK_OPTIONS = "INVALID_WEBHOOK_OPTIONS", CHANNEL_NOT_FOUND = "CHANNEL_NOT_FOUND", CHANNEL_NOT_TEXT_BASED = "CHANNEL_NOT_TEXT_BASED", diff --git a/src/types/interactions.ts b/src/types/interactions.ts new file mode 100644 index 000000000..986aaf6b8 --- /dev/null +++ b/src/types/interactions.ts @@ -0,0 +1,43 @@ +import { MemberCreatePayload } from "./member.ts"; + +export interface InteractionCommandPayload { + /** id of the interaction */ + id: string; + /** the type of interaction */ + type: InteractionType; + /** the command data payload */ + data?: InteractionData; + /** the guild it was sent from */ + guild_id: string; + /** the channel it was sent from */ + channel_id: string; + /** guild member data for the invoking user */ + member: MemberCreatePayload; + /** a contintuation token for responding to the interaction */ + token: string; +} + +export enum InteractionType { + /** This type is for ACK on webhook only setup. Discord may send these which require. In a sense its a heartbeat. */ + PING = 1, + /** Slash commands */ + APPLICATION_COMMAND, +} + +export interface InteractionData { + /** the ID of the invoked command */ + id: string; + /** the name of the invoked command */ + name: string; + /** the params + values from the user */ + options: InteractionDataOption[]; +} + +export interface InteractionDataOption { + /** the name of the parameter */ + name: string; + /** the value of the pair. present if there was no more options */ + value?: string | number; + /** present if this option is a group or subcommand */ + options?: InteractionDataOption[]; +} diff --git a/src/types/options.ts b/src/types/options.ts index de93ed971..ae21ee07a 100644 --- a/src/types/options.ts +++ b/src/types/options.ts @@ -104,6 +104,8 @@ export interface EventHandlers { cachedMember?: Member, ) => unknown; heartbeat?: () => unknown; + // TODO: FIX THIS + interactionCreate?: (data: unknown) => unknown; messageCreate?: (message: Message) => unknown; messageDelete?: (partial: PartialMessage, message?: Message) => unknown; messageUpdate?: (message: Message, cachedMessage: OldMessage) => unknown; diff --git a/src/types/types.ts b/src/types/types.ts index 30de803b8..6268302ed 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -13,3 +13,4 @@ export * from "./permission.ts"; export * from "./presence.ts"; export * from "./role.ts"; export * from "./webhook.ts"; +export * from "./interactions.ts"; diff --git a/src/types/webhook.ts b/src/types/webhook.ts index 04f9bb8f7..6394f3355 100644 --- a/src/types/webhook.ts +++ b/src/types/webhook.ts @@ -1,5 +1,6 @@ import { AllowedMentions } from "./channel.ts"; import { UserPayload } from "./guild.ts"; +import { InteractionType } from "./interactions.ts"; import { Embed } from "./message.ts"; export interface WebhookPayload { @@ -66,3 +67,153 @@ export interface EditWebhookMessageOptions { embeds?: Embed[]; allowed_mentions?: AllowedMentions; } + +export interface CreateSlashCommandOptions { + /** The name of the slash command. */ + name: string; + /** The description of the slash command. */ + description: String; + /** If a guildID is provided, this will be a GUILD command. If none is provided it will be a GLOBAL command. */ + guildID?: string; + /** The options for this command */ + options?: SlashCommandOption[]; +} + +export interface SlashCommand { + /** unique id of the command */ + id: string; + /** unique id of the parent application */ + application_id: string; + /** 3-32 character name */ + name: string; + /** 1-100 character description */ + description: string; + /** the parameters for the command */ + options?: SlashCommandOption[]; +} + +export interface SlashCommandOption { + /** The type of option */ + type: SlashCommandOptionType; + /** 1-32 character name */ + name: string; + /** 1-100 character description*/ + description: string; + /** the first `required` option for the user to complete--only one option can be `default` */ + default?: boolean; + /** if the parameter is required or optional--default `false`*/ + required?: boolean; + /** + * If you specify `choices` for an option, they are the **only** valid values for a user to pick. + * choices for `string` and `int` types for the user to pick from + */ + choices?: SlashCommandOptionChoice[]; + /** if the option is a subcommand or subcommand group type, this nested options will be the parameters */ + options?: SlashCommandOption[]; +} + +export interface SlashCommandOptionChoice { + /** The name of the choice */ + name: string; + /** The value of the choice */ + value: string | number; +} + +export enum SlashCommandOptionType { + SUB_COMMAND = 1, + SUB_COMMAND_GROUP = 2, + STRING = 3, + INTEGER = 4, + BOOLEAN = 5, + USER = 6, + CHANNEL = 7, + ROLE = 8, +} + +export interface Interaction { + /** The id of the interaction */ + id: string; + /** The type of interaction */ + type: InteractionType; + /** The command data payload */ + data?: SlashCommandInteractionData; + /** The id of the guild it was sent from */ + guild_id: string; + /** The id of the channel it was sent from */ + channel_id: string; + /** The Payload of the member it was sent from */ + member: UserPayload; + /** The token for this interaction */ + token: string; +} + +export interface SlashCommandInteractionData { + /** The id of the command */ + id: string; + /** The name of the command */ + name: string; + /** the params and values from the user */ + options: SlashCommandInteractionDataOption[]; +} + +export interface SlashCommandInteractionDataOption { + /** The name of the parammeter */ + name: string; + /** The value of the pair */ + value?: any; + /** Present if this option is a group or subcommand */ + options?: SlashCommandInteractionDataOption[]; +} + +export interface InteractionResponse { + /** The type of response */ + type: InteractionResponseType; + /** The optional response message */ + data?: SlashCommandCallbackData; +} + +export interface SlashCommandCallbackData { + /** is the response TTS */ + tts?: boolean; + /** message content */ + content: string; + /** supports up to 10 embeds */ + embeds?: Embed[]; + /** allowed mentions for the message */ + allowed_mentions?: AllowedMentions; + /** acceptable values are message flags */ + flags?: number; +} + +export enum InteractionResponseType { + /** ACK a `Ping` */ + PONG = 1, + /** ACK a command without sending a message, eating the user's input */ + ACKNOWLEDGE = 2, + /** respond with a message, eating the user's input */ + CHANNEL_MESSAGE = 3, + /** respond with a message, showing the user's input */ + CHANNEL_MESSAGE_WITH_SOURCE = 4, + /** ACK a command without sending a message, showing the user's input */ + ACK_WITH_SOURCE = 5, +} + +export interface EditSlashCommandOptions { + id: string; + guildID?: string; +} + +export interface ExecuteSlashCommandOptions { + type: InteractionResponseType; + data: SlashCommandCallbackData; +} + +export interface EditSlashResponseOptions extends SlashCommandCallbackData { + /** If this is not provided, it will default to editing the original response. */ + messageID?: string; +} + +export interface UpsertSlashCommandOptions { + id: string; + guildID?: string; +} diff --git a/src/util/cache.ts b/src/util/cache.ts index eef2973bb..fe6f7fe03 100644 --- a/src/util/cache.ts +++ b/src/util/cache.ts @@ -16,6 +16,7 @@ export interface CacheData { unavailableGuilds: Collection; presences: Collection; fetchAllMembersProcessingRequests: Collection; + executedSlashCommands: Collection; } export const cache: CacheData = { @@ -27,4 +28,5 @@ export const cache: CacheData = { unavailableGuilds: new Collection(), presences: new Collection(), fetchAllMembersProcessingRequests: new Collection(), + executedSlashCommands: new Collection(), }; diff --git a/src/util/constants.ts b/src/util/constants.ts index a1fefea0f..8c289e3f7 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -101,6 +101,28 @@ export const endpoints = { WEBHOOK_DELETE: (id: string, token: string, messageID: string) => `${baseEndpoints.BASE_URL}/webhooks/${id}/${token}/messages/${messageID}`, + // Application Endpoints + COMMANDS: (botID: string) => + `${baseEndpoints.BASE_URL}/applications/${botID}/commands`, + COMMANDS_GUILD: (botID: string, id: string) => + `${baseEndpoints.BASE_URL}/applications/${botID}/guilds/${id}/commands`, + COMMANDS_ID: (botID: string, id: string) => + `${baseEndpoints.BASE_URL}/applications/${botID}/commands/${id}`, + COMMANDS_GUILD_ID: (botID: string, id: string, guildID: string) => + `${baseEndpoints.BASE_URL}/applications/${botID}/guilds/${guildID}/commands/${id}`, + + // Interaction Endpoints + INTERACTION_ID_TOKEN: (id: string, token: string) => + `${baseEndpoints.BASE_URL}/interactions/${id}/${token}/callback`, + INTERACTION_ORIGINAL_ID_TOKEN: (id: string, token: string) => + `${baseEndpoints.BASE_URL}/webhooks/${id}/${token}/messages/@original`, + INTERACTION_ID_TOKEN_MESSAGEID: ( + id: string, + token: string, + messageID: string, + ) => + `${baseEndpoints.BASE_URL}/webhooks/${id}/${token}/messages/${messageID}`, + // User endpoints USER: (id: string) => `${baseEndpoints.BASE_URL}/users/${id}`, USER_BOT: `${baseEndpoints.BASE_URL}/users/@me`,