diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..070268509 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +* @ayntee @Skillz4Killz + +*.ts @ayntee @Skillz4Killz @itohatweb diff --git a/src/api/handlers/channel.ts b/src/api/handlers/channel.ts index e22b410ca..5e1b772d1 100644 --- a/src/api/handlers/channel.ts +++ b/src/api/handlers/channel.ts @@ -321,6 +321,20 @@ export async function createInvite( throw new Error(Errors.MISSING_CREATE_INSTANT_INVITE); } + if (options.max_age && (options.max_age > 604800 || options.max_age < 0)) { + console.log( + `The max age for invite created in ${channelID} was not between 0-604800. Using default values instead.`, + ); + options.max_age = undefined; + } + + if (options.max_uses && (options.max_uses > 100 || options.max_uses < 0)) { + console.log( + `The max uses for invite created in ${channelID} was not between 0-100. Using default values instead.`, + ); + options.max_uses = undefined; + } + const result = await RequestManager.post( endpoints.CHANNEL_INVITES(channelID), options, diff --git a/src/api/handlers/guild.ts b/src/api/handlers/guild.ts index b59c2258b..9aff87160 100644 --- a/src/api/handlers/guild.ts +++ b/src/api/handlers/guild.ts @@ -529,9 +529,11 @@ export async function swapRoles(guildID: string, rolePositons: PositionSwap) { } /** Check how many members would be removed from the server in a prune operation. Requires the KICK_MEMBERS permission */ -export async function getPruneCount(guildID: string, options: PruneOptions) { - if (options.days < 1) throw new Error(Errors.PRUNE_MIN_DAYS); - if (options.days > 30) throw new Error(Errors.PRUNE_MAX_DAYS); +export async function getPruneCount(guildID: string, options?: PruneOptions) { + if (options?.days && options.days < 1) throw new Error(Errors.PRUNE_MIN_DAYS); + if (options?.days && options.days > 30) { + throw new Error(Errors.PRUNE_MAX_DAYS); + } const hasPerm = await botHasPermission(guildID, ["KICK_MEMBERS"]); if (!hasPerm) { @@ -540,16 +542,23 @@ export async function getPruneCount(guildID: string, options: PruneOptions) { const result = await RequestManager.get( endpoints.GUILD_PRUNE(guildID), - { ...options, include_roles: options.roles.join(",") }, + { ...options, include_roles: options?.roles?.join(",") }, ) as PrunePayload; return result.pruned; } -/** Begin pruning all members in the given time period */ -export async function pruneMembers(guildID: string, options: PruneOptions) { - if (options.days < 1) throw new Error(Errors.PRUNE_MIN_DAYS); - if (options.days > 30) throw new Error(Errors.PRUNE_MAX_DAYS); +/** + * Begin a prune operation. Requires the KICK_MEMBERS permission. Returns an object with one 'pruned' key indicating the number of members that were removed in the prune operation. For large guilds it's recommended to set the computePruneCount option to false, forcing 'pruned' to null. Fires multiple Guild Member Remove Gateway events. + * + * By default, prune will not remove users with roles. You can optionally include specific roles in your prune by providing the roles (resolved to include_roles internally) parameter. Any inactive user that has a subset of the provided role(s) will be included in the prune and users with additional roles will not. + */ +export async function pruneMembers( + guildID: string, + { roles, computePruneCount, ...options }: PruneOptions, +) { + if (options.days && options.days < 1) throw new Error(Errors.PRUNE_MIN_DAYS); + if (options.days && options.days > 30) throw new Error(Errors.PRUNE_MAX_DAYS); const hasPerm = await botHasPermission(guildID, ["KICK_MEMBERS"]); if (!hasPerm) { @@ -558,7 +567,11 @@ export async function pruneMembers(guildID: string, options: PruneOptions) { const result = await RequestManager.post( endpoints.GUILD_PRUNE(guildID), - { ...options, include_roles: options.roles.join(",") }, + { + ...options, + compute_prune_count: computePruneCount, + include_roles: roles, + }, ); return result; diff --git a/src/api/handlers/message.ts b/src/api/handlers/message.ts index 5cd28d190..24c6579bb 100644 --- a/src/api/handlers/message.ts +++ b/src/api/handlers/message.ts @@ -1,6 +1,7 @@ import { botID } from "../../bot.ts"; import { RequestManager } from "../../rest/request_manager.ts"; import { + DiscordGetReactionsParams, Errors, MessageContent, MessageCreateOptions, @@ -264,9 +265,14 @@ export async function removeReactionEmoji( } /** Get a list of users that reacted with this emoji. */ -export async function getReactions(message: Message, reaction: string) { +export async function getReactions( + message: Message, + reaction: string, + options?: DiscordGetReactionsParams, +) { const result = (await RequestManager.get( endpoints.CHANNEL_MESSAGE_REACTION(message.channelID, message.id, reaction), + options, )) as UserPayload[]; return Promise.all(result.map(async (res) => { diff --git a/src/api/handlers/webhook.ts b/src/api/handlers/webhook.ts index fdad0ec4b..1d2b56d8e 100644 --- a/src/api/handlers/webhook.ts +++ b/src/api/handlers/webhook.ts @@ -6,10 +6,13 @@ import { EditSlashResponseOptions, EditWebhookMessageOptions, Errors, - ExecuteSlashCommandOptions, ExecuteWebhookOptions, MessageCreateOptions, SlashCommand, + SlashCommandOption, + SlashCommandOptionChoice, + SlashCommandOptionType, + SlashCommandResponseOptions, UpsertSlashCommandOptions, UpsertSlashCommandsOptions, WebhookCreateOptions, @@ -17,7 +20,7 @@ import { WebhookPayload, } from "../../types/mod.ts"; import { cache } from "../../util/cache.ts"; -import { endpoints } from "../../util/constants.ts"; +import { endpoints, SLASH_COMMANDS_NAME_REGEX } from "../../util/constants.ts"; import { botHasChannelPermissions } from "../../util/permissions.ts"; import { urlToBase64 } from "../../util/utils.ts"; import { structures } from "../structures/mod.ts"; @@ -252,9 +255,10 @@ export async function editWebhookMessage( const result = await RequestManager.patch( endpoints.WEBHOOK_MESSAGE(webhookID, webhookToken, messageID), { ...options, allowed_mentions: options.allowed_mentions }, - ); + ) as MessageCreateOptions; - return result; + const message = await structures.createMessage(result); + return message; } export async function deleteWebhookMessage( @@ -269,6 +273,82 @@ export async function deleteWebhookMessage( return result; } +function validateSlashOptionChoices( + choices: SlashCommandOptionChoice[], + optionType: SlashCommandOptionType, +) { + for (const choice of choices) { + if ([...choice.name].length < 1 || [...choice.name].length > 100) { + throw new Error(Errors.INVALID_SLASH_OPTIONS_CHOICES); + } + + if ( + (optionType === SlashCommandOptionType.STRING && + (typeof choice.value !== "string" || choice.value.length < 1 || + choice.value.length > 100)) || + (optionType === SlashCommandOptionType.INTEGER && + typeof choice.value !== "number") + ) { + throw new Error(Errors.INVALID_SLASH_OPTIONS_CHOICES); + } + } +} + +function validateSlashOptions(options: SlashCommandOption[]) { + for (const option of options) { + if ( + (option.choices?.length && option.choices.length > 25) || + option.type !== SlashCommandOptionType.STRING && + option.type !== SlashCommandOptionType.INTEGER + ) { + throw new Error(Errors.INVALID_SLASH_OPTIONS_CHOICES); + } + + if ( + ([...option.name].length < 1 || [...option.name].length > 32) || + ([...option.description].length < 1 || + [...option.description].length > 100) + ) { + throw new Error(Errors.INVALID_SLASH_OPTIONS_CHOICES); + } + + if (option.choices) { + validateSlashOptionChoices(option.choices, option.type); + } + } +} + +function validateSlashCommands( + commands: UpsertSlashCommandOptions[], + create = false, +) { + for (const command of commands) { + if ( + (command.name && !SLASH_COMMANDS_NAME_REGEX.test(command.name)) || + (create && !command.name) + ) { + throw new Error(Errors.INVALID_SLASH_NAME); + } + + if ( + (command.description && + ([...command.description].length < 1 || + [...command.description].length > 100)) || + (create && !command.description) + ) { + throw new Error(Errors.INVALID_SLASH_DESCRIPTION); + } + + if (command.options?.length) { + if (command.options.length > 25) { + throw new Error(Errors.INVALID_SLASH_OPTIONS); + } + + validateSlashOptions(command.options); + } + } +} + /** * 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: * @@ -281,16 +361,7 @@ export async function deleteWebhookMessage( * Guild commands update **instantly**. We recommend you use guild commands for quick testing, and global commands when they're ready for public use. */ export async 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); - } + validateSlashCommands([options], true); const result = await RequestManager.post( options.guildID @@ -335,16 +406,7 @@ export async function upsertSlashCommand( options: UpsertSlashCommandOptions, guildID?: string, ) { - // 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); - } + validateSlashCommands([options]); const result = await RequestManager.patch( guildID @@ -369,26 +431,13 @@ export async function upsertSlashCommands( options: UpsertSlashCommandsOptions[], guildID?: string, ) { - const data = options.map((option) => { - // Use ... for content length due to unicode characters and js .length handling - if ([...option.name].length < 2 || [...option.name].length > 32) { - throw new Error(Errors.INVALID_SLASH_NAME); - } - - if ( - [...option.description].length < 1 || [...option.description].length > 100 - ) { - throw new Error(Errors.INVALID_SLASH_DESCRIPTION); - } - - return option; - }); + validateSlashCommands(options); const result = await RequestManager.put( guildID ? endpoints.COMMANDS_GUILD(applicationID, guildID) : endpoints.COMMANDS(applicationID), - data, + options, ); return result; @@ -404,8 +453,7 @@ export async function editSlashCommand( options: EditSlashCommandOptions, guildID?: string, ) { - // Use ... for content length due to unicode characters and js .length handling - if ([...options.name].length < 2 || [...options.name].length > 32) { + if (!SLASH_COMMANDS_NAME_REGEX.test(options.name)) { throw new Error(Errors.INVALID_SLASH_NAME); } @@ -448,7 +496,7 @@ export function deleteSlashCommand(id: string, guildID?: string) { export async function executeSlashCommand( id: string, token: string, - options: ExecuteSlashCommandOptions, + options: SlashCommandResponseOptions, ) { // If its already been executed, we need to send a followup response if (cache.executedSlashCommands.has(token)) { @@ -464,6 +512,11 @@ export async function executeSlashCommand( 900000, ); + // If the user wants this as a private message mark it ephemeral + if (options.private) { + options.data.flags = 64; + } + // If no mentions are provided, force disable mentions if (!(options.data.allowed_mentions)) { options.data.allowed_mentions = { parse: [] }; @@ -547,5 +600,11 @@ export async function editSlashResponse( options, ); - return result; + // If the original message was edited, this will not return a message + if (!options.messageID) return result; + + const message = await structures.createMessage( + result as MessageCreateOptions, + ); + return message; } diff --git a/src/interactions/types/interactions.ts b/src/interactions/types/interactions.ts index 295015e34..e9d291a3c 100644 --- a/src/interactions/types/interactions.ts +++ b/src/interactions/types/interactions.ts @@ -66,12 +66,8 @@ export enum InteractionType { 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 */ + /** 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, + /** ACK an interaction and edit to a response later, the user sees a loading state */ + DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, } diff --git a/src/types/api/embed.ts b/src/types/api/embed.ts index 2188c3c11..0535d4516 100644 --- a/src/types/api/embed.ts +++ b/src/types/api/embed.ts @@ -56,6 +56,8 @@ export interface DiscordEmbedThumbnail { export interface DiscordEmbedVideo { /** source url of video */ url?: string; + /** a proxied url of the video */ + proxy_url?: string; /** height of video */ height?: number; /** width of video */ diff --git a/src/types/api/interaction.ts b/src/types/api/interaction.ts index d9cb62f7c..993409fc9 100644 --- a/src/types/api/interaction.ts +++ b/src/types/api/interaction.ts @@ -1,4 +1,9 @@ -import { DiscordMember } from "./mod.ts"; +import { + DiscordChannel, + DiscordMember, + DiscordRole, + DiscordUser, +} from "./mod.ts"; export interface DiscordInteractionCommand { /** id of the interaction */ @@ -31,8 +36,24 @@ export interface DiscordInteractionData { id: string; /** the name of the invoked command */ name: string; + /** converted users + roles + channels */ + resolved?: DiscordApplicationCommandInteractionDataResolved; /** the params + values from the user */ - options: DiscordInteractionDataOption[]; + options?: DiscordInteractionDataOption[]; +} + +export interface DiscordApplicationCommandInteractionDataResolved { + /** the IDs and User objects */ + users?: Record; + /** the IDs and partial Member objects */ + members?: Record>; + /** the IDs and Role objects */ + roles?: Record; + /** the IDs and partial Channel objects */ + channels?: Record< + string, + Pick + >; } export interface DiscordInteractionDataOption { diff --git a/src/types/api/member.ts b/src/types/api/member.ts index e17403cb0..3eaf32740 100644 --- a/src/types/api/member.ts +++ b/src/types/api/member.ts @@ -83,7 +83,7 @@ export interface DiscordBaseMember { } /** https://discord.com/developers/docs/resources/guild#guild-member-object-guild-member-structure */ -export interface DiscordMember { +export interface DiscordMember extends DiscordBaseMember { /** the user this guild member represents */ user?: DiscordUser; } diff --git a/src/types/channel.ts b/src/types/channel.ts index ad3202a95..3743c2ebc 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -154,11 +154,12 @@ export interface GetMessagesAround extends GetMessages { around: string; } +// TODO: v11 change to camelcase export interface CreateInviteOptions { - /** Duration of invite in seconds before expiry, or 0 for never. Defaults to 86400 (24 hours) */ - "max_age": number; - /** Max number of uses or 0 for unlimited. Default 0 */ - "max_uses": number; + /** Duration of invite in seconds before expiry, or 0 for never. Between 0-604800 (7 days). Defaults to 86400 (24 hours). */ + "max_age"?: number; + /** Max number of uses or 0 for unlimited. Between 0-100. Default 0 */ + "max_uses"?: number; /** Whether this invite only grants temporary membership. */ temporary: boolean; /** If true, don't try to reuse a similar invite (useful for creating many unique one time use invites.) */ diff --git a/src/types/errors.ts b/src/types/errors.ts index cc64e5b3c..f3895022a 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -20,6 +20,8 @@ export enum Errors { // Interaction Errors INVALID_SLASH_DESCRIPTION = "INVALID_SLASH_DESCRIPTION", INVALID_SLASH_NAME = "INVALID_SLASH_NAME", + INVALID_SLASH_OPTIONS = "INVALID_SLASH_OPTIONS", + INVALID_SLASH_OPTIONS_CHOICES = "INVALID_SLASH_OPTIONS_CHOICES", // Webhook Errors INVALID_WEBHOOK_NAME = "INVALID_WEBHOOK_NAME", INVALID_WEBHOOK_OPTIONS = "INVALID_WEBHOOK_OPTIONS", diff --git a/src/types/guild.ts b/src/types/guild.ts index 74f45390d..e31b0147f 100644 --- a/src/types/guild.ts +++ b/src/types/guild.ts @@ -547,10 +547,42 @@ export interface PrunePayload { } export interface PruneOptions { - /** number of days to count prune for (1 - 30). Defaults to 7 days. */ - days: number; - /** Include members with these role ids */ - roles: string[]; + /** Number of days to prune (1-30). Default: 7 */ + days?: + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15 + | 16 + | 17 + | 18 + | 19 + | 20 + | 21 + | 22 + | 23 + | 24 + | 25 + | 26 + | 27 + | 28 + | 29 + | 30; + /** Whether 'pruned' is returned, discouraged for large guilds. Default: true */ + computePruneCount?: boolean; + /** Role(s) to include */ + roles?: string[]; } export interface VoiceState { diff --git a/src/types/message.ts b/src/types/message.ts index 24f036a95..86f0e4d63 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -99,6 +99,8 @@ export interface EmbedThumbnail { export interface EmbedVideo { /** The source url of video */ url?: string; + /** a proxied url of the video */ + proxy_url?: string; /** The height of the video */ height?: number; /** The width of the video */ diff --git a/src/types/webhook.ts b/src/types/webhook.ts index d5a680785..925632135 100644 --- a/src/types/webhook.ts +++ b/src/types/webhook.ts @@ -191,21 +191,17 @@ export interface SlashCommandCallbackData { embeds?: Embed[]; /** allowed mentions for the message */ "allowed_mentions"?: AllowedMentions; - /** acceptable values are message flags */ + /** acceptable values are message flags, set to 64 to make your response ephemeral */ 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 */ + /** 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, + /** ACK an interaction and edit to a response later, the user sees a loading state */ + DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, } // TODO: remove this interface for v11 @@ -224,27 +220,27 @@ export interface ExecuteSlashCommandOptions { data: SlashCommandCallbackData; } +export interface SlashCommandResponseOptions + extends ExecuteSlashCommandOptions { + /** Whether to make this response visible ONLY to the user who used this command. It will also be deleted after some time. */ + private?: boolean; +} + export interface EditSlashResponseOptions extends SlashCommandCallbackData { /** If this is not provided, it will default to editing the original response. */ messageID?: string; } export interface UpsertSlashCommandOptions { - /** 3-32 character command name */ - name: string; + /** 1-32 character name matching ^[\w-]{1,32}$ */ + name?: string; /** 1-100 character description */ - description: string; + description?: string; /** The parameters for the command */ - options?: SlashCommandOption[]; + options?: SlashCommandOption[] | null; } -export interface UpsertSlashCommandsOptions { +export interface UpsertSlashCommandsOptions extends UpsertSlashCommandOptions { /** The id of the command */ id: string; - /** 3-32 character command name */ - name: string; - /** 1-100 character description */ - description: string; - /** The parameters for the command */ - options?: SlashCommandOption[]; } diff --git a/src/util/constants.ts b/src/util/constants.ts index afad4433c..af1d8ef57 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -175,3 +175,5 @@ export const endpoints = { // oAuth2 OAUTH2_APPLICATION: `${baseEndpoints.BASE_URL}/oauth2/applications/@me`, }; + +export const SLASH_COMMANDS_NAME_REGEX = /^[\w-]{1,32}$/; diff --git a/src/util/utils.ts b/src/util/utils.ts index a2febf9b3..1b2bb9b1f 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -60,3 +60,57 @@ export const formatImageURL = ( return `${url}.${format || (url.includes("/a_") ? "gif" : "jpg")}?size=${size}`; }; + +function camelToSnakeCase(text: string) { + return text.replace(/ID|[A-Z]/g, ($1) => { + if ($1 === "ID") return "_id"; + return `_${$1.toLowerCase()}`; + }); +} + +function snakeToCamelCase(text: string) { + return text.replace(/_id|([-_][a-z])/ig, ($1) => { + if ($1 === "_id") return "ID"; + return $1.toUpperCase().replace("_", ""); + }); +} + +function isObject(obj: unknown) { + return obj === Object(obj) && !Array.isArray(obj) && + typeof obj !== "function"; +} +// deno-lint-ignore no-explicit-any +export function camelKeysToSnakeCase(obj: Record) { + if (isObject(obj)) { + // deno-lint-ignore no-explicit-any + const convertedObject: Record = {}; + Object.keys(obj) + .forEach((key) => { + convertedObject[camelToSnakeCase(key)] = camelKeysToSnakeCase( + obj[key], + ); + }); + return convertedObject; + } else if (Array.isArray(obj)) { + obj = obj.map((element) => camelKeysToSnakeCase(element)); + } + return obj; +} + +// deno-lint-ignore no-explicit-any +export function snakeKeysToCamelCase(obj: Record) { + if (isObject(obj)) { + // deno-lint-ignore no-explicit-any + const convertedObject: Record = {}; + Object.keys(obj) + .forEach((key) => { + convertedObject[snakeToCamelCase(key)] = snakeKeysToCamelCase( + obj[key], + ); + }); + return convertedObject; + } else if (Array.isArray(obj)) { + obj = obj.map((element) => snakeKeysToCamelCase(element)); + } + return obj; +} diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 000000000..d2df243c3 --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,73 @@ +import { camelKeysToSnakeCase, snakeKeysToCamelCase } from "../mod.ts"; +import { assertEquals } from "./deps.ts"; + +const testSnakeObject = { + // deno-lint-ignore camelcase + hello_world: "hello_world", + // deno-lint-ignore camelcase + the_universe: { + blue_planet: { + water: "is_blue", + dirt: "isDirty", + }, + moon: { + earth_moon: { + is_round: true, + }, + other_moon: { + is_round: 0, + }, + }, + arrays: ["one_two", { moo_cow: { boo: true } }], + test_the_id: "123123123123", + }, +}; + +const testCamelObject = { + helloWorld: "hello_world", + theUniverse: { + bluePlanet: { + water: "is_blue", + dirt: "isDirty", + }, + moon: { + earthMoon: { + isRound: true, + }, + otherMoon: { + isRound: 0, + }, + }, + arrays: ["one_two", { mooCow: { boo: true } }], + testTheID: "123123123123", + }, +}; + +const someOther = { + helloWorld: 1, +}; + +const someElseOther = { + // deno-lint-ignore camelcase + hello_world: 1, +}; + +Deno.test({ + name: "[utils] snakeKeysToCamelCase: assert convertion", + fn() { + const result = snakeKeysToCamelCase(testSnakeObject); + assertEquals(result, testCamelObject); + const resultTwo = snakeKeysToCamelCase(someOther); + assertEquals(resultTwo, someOther); + }, +}); + +Deno.test({ + name: "[utils] camelKeysToSnakeCase: assert convertion", + fn() { + const result = camelKeysToSnakeCase(testCamelObject); + assertEquals(result, testSnakeObject); + const resultTwo = camelKeysToSnakeCase(someElseOther); + assertEquals(resultTwo, someElseOther); + }, +});