Merge remote-tracking branch 'upstream/main' into main

This commit is contained in:
ITOH
2021-03-07 21:02:16 +01:00
17 changed files with 366 additions and 90 deletions

3
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,3 @@
* @ayntee @Skillz4Killz
*.ts @ayntee @Skillz4Killz @itohatweb

View File

@@ -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,

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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;
}

View File

@@ -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,
}

View File

@@ -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 */

View File

@@ -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<string, DiscordUser>;
/** the IDs and partial Member objects */
members?: Record<string, Omit<DiscordMember, "user" | "deaf" | "mute">>;
/** the IDs and Role objects */
roles?: Record<string, DiscordRole>;
/** the IDs and partial Channel objects */
channels?: Record<
string,
Pick<DiscordChannel, "id" | "name" | "type" | "permission_overwrites">
>;
}
export interface DiscordInteractionDataOption {

View File

@@ -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;
}

View File

@@ -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.) */

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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 */

View File

@@ -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[];
}

View File

@@ -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}$/;

View File

@@ -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<string, any>) {
if (isObject(obj)) {
// deno-lint-ignore no-explicit-any
const convertedObject: Record<string, any> = {};
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<string, any>) {
if (isObject(obj)) {
// deno-lint-ignore no-explicit-any
const convertedObject: Record<string, any> = {};
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;
}

73
test/utils.test.ts Normal file
View File

@@ -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);
},
});