diff --git a/src/bot.ts b/src/bot.ts index 637df063d..2f023e0a1 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -5,13 +5,14 @@ import { DiscordGatewayIntents } from "./types/gateway/gateway_intents.ts"; import { snowflakeToBigint } from "./util/bigint.ts"; import { GATEWAY_VERSION } from "./util/constants.ts"; import { ws } from "./ws/ws.ts"; +import { dispatchRequirements } from "./util/dispatch_requirements.ts"; // deno-lint-ignore prefer-const export let secretKey = ""; export let botId = 0n; export let applicationId = 0n; -export let eventHandlers: EventHandlers = {}; +export let eventHandlers: EventHandlers = { dispatchRequirements }; export let proxyWSURL = `wss://gateway.discord.gg`; diff --git a/src/cache.ts b/src/cache.ts index 02f30ca09..745ee39df 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,4 +1,5 @@ // deno-lint-ignore-file require-await no-explicit-any prefer-const +import { botId } from "./bot.ts"; import type { DiscordenoChannel } from "./structures/channel.ts"; import type { DiscordenoGuild } from "./structures/guild.ts"; import type { DiscordenoMember } from "./structures/member.ts"; @@ -10,17 +11,17 @@ import { Collection } from "./util/collection.ts"; export const cache = { isReady: false, /** All of the guild objects the bot has access to, mapped by their Ids */ - guilds: new Collection(), + guilds: new Collection([], { sweeper: { filter: guildSweeper, interval: 3600000 } }), /** All of the channel objects the bot has access to, mapped by their Ids */ channels: new Collection(), /** All of the message objects the bot has cached since the bot acquired `READY` state, mapped by their Ids */ - messages: new Collection(), + messages: new Collection([], { sweeper: { filter: messageSweeper, interval: 300000 } }), /** All of the member objects that have been cached since the bot acquired `READY` state, mapped by their Ids */ - members: new Collection(), + members: new Collection([], { sweeper: { filter: memberSweeper, interval: 300000 } }), /** All of the unavailable guilds, mapped by their Ids (id, timestamp) */ unavailableGuilds: new Collection(), /** All of the presence update objects received in PRESENCE_UPDATE gateway event, mapped by their user Id */ - presences: new Collection(), + presences: new Collection([], { sweeper: { filter: () => true, interval: 300000 } }), fetchAllMembersProcessingRequests: new Collection< string, (value: Collection | PromiseLike>) => void @@ -31,8 +32,43 @@ export const cache = { this.guilds.reduce((a, b) => [...a, ...b.emojis.map((e) => [e.id, e])], [] as any[]) ); }, + activeGuildIds: new Set(), + dispatchedGuildIds: new Set(), + dispatchedChannelIds: new Set(), }; +function messageSweeper(message: DiscordenoMessage) { + // DM messages aren't needed + if (!message.guildId) return true; + + // Only delete messages older than 10 minutes + return Date.now() - message.timestamp > 600000; +} + +function memberSweeper(member: DiscordenoMember) { + // Don't sweep the bot else strange things will happen + if (member.id === botId) return false; + + // Only sweep members who were not active the last 30 minutes + return member.cachedAt - Date.now() < 1800000; +} + +function guildSweeper(guild: DiscordenoGuild) { + // Reset activity for next interval + if (!cache.activeGuildIds.delete(guild.id)) return false; + + guild.channels.forEach((channel) => { + cache.channels.delete(channel.id); + cache.dispatchedChannelIds.add(channel.id); + }); + + // This is inactive guild. Not a single thing has happened for atleast 30 minutes. + // Not a reaction, not a message, not any event! + cache.dispatchedGuildIds.add(guild.id); + + return true; +} + export let cacheHandlers = { /** Deletes all items from the cache */ async clear(table: TableName) { diff --git a/src/handlers/misc/READY.ts b/src/handlers/misc/READY.ts index 8bee0fbdf..1109fb1d5 100644 --- a/src/handlers/misc/READY.ts +++ b/src/handlers/misc/READY.ts @@ -38,7 +38,7 @@ function checkReady(payload: Ready, shard: DiscordenoShard) { // Check if all guilds were loaded if (!shard.unavailableGuildIds.size) return loaded(shard); - // If the last GUILD_CREATE has been received before 5 seconds if so most likely the remaining guilds are unavailable + // If the last GUILD_CREATE was received 5 seconds ago, the remaining guilds are most likely not available if (shard.lastAvailable + 5000 < Date.now()) { eventHandlers.shardFailedToLoad?.(shard.id, shard.unavailableGuildIds); // Force execute the loaded function to prevent infinite loop diff --git a/src/helpers/mod.ts b/src/helpers/mod.ts index 5d928852c..639177c4e 100644 --- a/src/helpers/mod.ts +++ b/src/helpers/mod.ts @@ -114,8 +114,9 @@ import { getGuildTemplates } from "./templates/get_guild_templates.ts"; import { getTemplate } from "./templates/get_template.ts"; import { syncGuildTemplate } from "./templates/sync_guild_template.ts"; // Type Guards -import { isActionRow } from "./type_guards/is_action_row.ts"; import { isButton } from "./type_guards/is_button.ts"; +import { isSelectMenu } from "./type_guards/is_select_menu.ts"; + import { createWebhook } from "./webhooks/create_webhook.ts"; import { deleteWebhook } from "./webhooks/delete_webhook.ts"; import { deleteWebhookMessage } from "./webhooks/delete_webhook_message.ts"; @@ -239,8 +240,8 @@ export { guildBannerURL, guildIconURL, guildSplashURL, - isActionRow, isButton, + isSelectMenu, isChannelSynced, kick, kickMember, diff --git a/src/helpers/type_guards/is_action_row.ts b/src/helpers/type_guards/is_action_row.ts deleted file mode 100644 index 145f89a60..000000000 --- a/src/helpers/type_guards/is_action_row.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ActionRow } from "../../types/messages/components/action_row.ts"; -import type { MessageComponent } from "../../types/messages/components/message_components.ts"; -import { MessageComponentTypes } from "../../types/messages/components/message_component_types.ts"; - -/** A type guard function to tell if it is a action row component */ -export function isActionRow(component: MessageComponent): component is ActionRow { - return component.type === MessageComponentTypes.ActionRow; -} diff --git a/src/helpers/type_guards/is_button.ts b/src/helpers/type_guards/is_button.ts index 89483cd94..9b00f106c 100644 --- a/src/helpers/type_guards/is_button.ts +++ b/src/helpers/type_guards/is_button.ts @@ -1,8 +1,8 @@ import type { ButtonComponent } from "../../types/messages/components/button_component.ts"; -import type { MessageComponent } from "../../types/messages/components/message_components.ts"; +import type { ActionRoleComponents } from "../../types/messages/components/message_components.ts"; import { MessageComponentTypes } from "../../types/messages/components/message_component_types.ts"; /** A type guard function to tell if it is a button component */ -export function isButton(component: MessageComponent): component is ButtonComponent { +export function isButton(component: ActionRoleComponents): component is ButtonComponent { return component.type === MessageComponentTypes.Button; } diff --git a/src/helpers/type_guards/is_select_menu.ts b/src/helpers/type_guards/is_select_menu.ts new file mode 100644 index 000000000..7643a0d0c --- /dev/null +++ b/src/helpers/type_guards/is_select_menu.ts @@ -0,0 +1,8 @@ +import type { ActionRoleComponents } from "../../types/messages/components/message_components.ts"; +import { MessageComponentTypes } from "../../types/messages/components/message_component_types.ts"; +import type { SelectMenuComponent } from "../../types/messages/components/select_menu.ts"; + +/** A type guard function to tell if it is a button component */ +export function isSelectMenu(component: ActionRoleComponents): component is SelectMenuComponent { + return component.type === MessageComponentTypes.SelectMenu; +} diff --git a/src/rest/process_queue.ts b/src/rest/process_queue.ts index bc39fef09..df7ddecf2 100644 --- a/src/rest/process_queue.ts +++ b/src/rest/process_queue.ts @@ -44,8 +44,13 @@ export async function processQueue(id: string) { // IF THIS IS A GET REQUEST, CHANGE THE BODY TO QUERY PARAMETERS const query = queuedRequest.request.method.toUpperCase() === "GET" && queuedRequest.payload.body - ? Object.entries(queuedRequest.payload.body) - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value as string)}`) + ? Object.keys(queuedRequest.payload.body) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent( + (queuedRequest.payload.body as Record)[key] + )}` + ) .join("&") : ""; const urlToUse = diff --git a/src/structures/member.ts b/src/structures/member.ts index 7166f34ea..127f397a8 100644 --- a/src/structures/member.ts +++ b/src/structures/member.ts @@ -169,6 +169,7 @@ export async function createDiscordenoMember( /** The guild related data mapped by guild id */ guilds: createNewProp(new Collection()), bitfield: createNewProp(bitfield), + cachedAt: createNewProp(Date.now()), }); const cached = await cacheHandlers.get("members", snowflakeToBigint(user.id)); @@ -210,6 +211,8 @@ export interface DiscordenoMember extends Omit; /** Holds all the boolean toggles. */ bitfield: bigint; + /** When the member has been cached the last time. */ + cachedAt: number; // GETTERS /** The avatar url using the default format and size. */ @@ -230,7 +233,9 @@ export interface DiscordenoMember extends Omit & { joinedAt?: number; premiumSince?: number; diff --git a/src/types/discordeno/errors.ts b/src/types/discordeno/errors.ts index 8bddc36ca..e26da1f58 100644 --- a/src/types/discordeno/errors.ts +++ b/src/types/discordeno/errors.ts @@ -98,4 +98,16 @@ export enum Errors { COMPONENT_LABEL_TOO_BIG = "COMPONENT_LABEL_TOO_BIG", COMPONENT_CUSTOM_ID_TOO_BIG = "COMPONENT_CUSTOM_ID_TOO_BIG", BUTTON_REQUIRES_CUSTOM_ID = "BUTTON_REQUIRES_CUSTOM_ID", + COMPONENT_SELECT_MUST_BE_ALONE = "COMPONENT_SELECT_MUST_BE_ALONE", + COMPONENT_PLACEHOLDER_TOO_BIG = "COMPONENT_PLACEHOLDER_TOO_BIG", + COMPONENT_SELECT_MINVALUE_TOO_LOW = "COMPONENT_SELECT_MINVALUE_TOO_LOW", + COMPONENT_SELECT_MINVALUE_TOO_MANY = "COMPONENT_SELECT_MINVALUE_TOO_MANY", + COMPONENT_SELECT_MAXVALUE_TOO_LOW = "COMPONENT_SELECT_MAXVALUE_TOO_LOW", + COMPONENT_SELECT_MAXVALUE_TOO_MANY = "COMPONENT_SELECT_MAXVALUE_TOO_MANY", + COMPONENT_SELECT_OPTIONS_TOO_LOW = "COMPONENT_SELECT_OPTIONS_TOO_LOW", + COMPONENT_SELECT_OPTIONS_TOO_MANY = "COMPONENT_SELECT_OPTIONS_TOO_MANY", + SELECT_OPTION_LABEL_TOO_BIG = "SELECT_OPTION_LABEL_TOO_BIG", + SELECT_OPTION_VALUE_TOO_BIG = "SELECT_OPTION_VALUE_TOO_BIG", + SELECT_OPTION_TOO_MANY_DEFAULTS = "SELECT_OPTION_TOO_MANY_DEFAULTS", + COMPONENT_SELECT_MIN_HIGHER_THAN_MAX = "COMPONENT_SELECT_MIN_HIGHER_THAN_MAX", } diff --git a/src/types/interactions/commands/application_command_interaction_data.ts b/src/types/interactions/commands/application_command_interaction_data.ts index e209cd208..1be81169a 100644 --- a/src/types/interactions/commands/application_command_interaction_data.ts +++ b/src/types/interactions/commands/application_command_interaction_data.ts @@ -11,8 +11,4 @@ export interface ApplicationCommandInteractionData { resolved?: ApplicationCommandInteractionDataResolved; /** The params + values from the user */ options?: ApplicationCommandInteractionDataOption[]; - /** with the value you defined for this component */ - customId?: string; - /** The type of this component */ - componentType?: 2; } diff --git a/src/types/interactions/interaction.ts b/src/types/interactions/interaction.ts index 09dc367cc..aa56a91a1 100644 --- a/src/types/interactions/interaction.ts +++ b/src/types/interactions/interaction.ts @@ -3,17 +3,32 @@ import { User } from "../users/user.ts"; import { ApplicationCommandInteractionData } from "./commands/application_command_interaction_data.ts"; import { InteractionGuildMember } from "./interaction_guild_member.ts"; import { DiscordInteractionTypes } from "./interaction_types.ts"; +import { SelectMenuData } from "../messages/components/select_data.ts"; +import { ButtonData } from "../messages/components/button_data.ts"; /** https://discord.com/developers/docs/interactions/slash-commands#interaction */ -export interface Interaction { +export interface Interaction extends BaseInteraction { + /** The command data payload */ + data?: ApplicationCommandInteractionData | ButtonData | SelectMenuData; +} + +export interface SlashCommandInteraction extends BaseInteraction { + type: DiscordInteractionTypes.ApplicationCommand; + data?: ApplicationCommandInteractionData; +} + +export interface ComponentInteraction extends BaseInteraction { + type: DiscordInteractionTypes.MessageComponent; + data?: ButtonData | SelectMenuData; +} + +export interface BaseInteraction { /** Id of the interaction */ id: string; /** Id of the application this interaction is for */ applicationId: string; /** The type of interaction */ type: DiscordInteractionTypes; - /** The command data payload */ - data?: ApplicationCommandInteractionData; /** The guild it was sent from */ guildId?: string; /** The channel it was sent from */ diff --git a/src/types/messages/components/action_row.ts b/src/types/messages/components/action_row.ts index 27a4a26e2..09e9f2a8d 100644 --- a/src/types/messages/components/action_row.ts +++ b/src/types/messages/components/action_row.ts @@ -1,9 +1,15 @@ import { ButtonComponent } from "./button_component.ts"; +import { SelectMenuComponent } from "./select_menu.ts"; // TODO: add docs link export interface ActionRow { /** Action rows are a group of buttons. */ type: 1; - /** The button components */ - components: ButtonComponent[]; + /** The components in this row */ + components: + | [SelectMenuComponent | ButtonComponent] + | [ButtonComponent, ButtonComponent] + | [ButtonComponent, ButtonComponent, ButtonComponent] + | [ButtonComponent, ButtonComponent, ButtonComponent, ButtonComponent] + | [ButtonComponent, ButtonComponent, ButtonComponent, ButtonComponent, ButtonComponent]; } diff --git a/src/types/messages/components/button_data.ts b/src/types/messages/components/button_data.ts new file mode 100644 index 000000000..6080abfe8 --- /dev/null +++ b/src/types/messages/components/button_data.ts @@ -0,0 +1,6 @@ +export interface ButtonData { + /** with the value you defined for this component */ + customId: string; + /** The type of this component */ + componentType: 2; +} diff --git a/src/types/messages/components/message_component_types.ts b/src/types/messages/components/message_component_types.ts index 18e37875e..410653c0b 100644 --- a/src/types/messages/components/message_component_types.ts +++ b/src/types/messages/components/message_component_types.ts @@ -4,6 +4,8 @@ export enum DiscordMessageComponentTypes { ActionRow = 1, /** A button! */ Button, + /** A select menu. */ + SelectMenu, } export type MessageComponentTypes = DiscordMessageComponentTypes; diff --git a/src/types/messages/components/message_components.ts b/src/types/messages/components/message_components.ts index 045b0f75a..d0d9e5d8d 100644 --- a/src/types/messages/components/message_components.ts +++ b/src/types/messages/components/message_components.ts @@ -1,6 +1,7 @@ import { ActionRow } from "./action_row.ts"; import { ButtonComponent } from "./button_component.ts"; +import { SelectMenuComponent } from "./select_menu.ts"; -export type MessageComponent = ActionRow | ButtonComponent; +export type ActionRoleComponents = ButtonComponent | SelectMenuComponent; -export type MessageComponents = MessageComponent[]; +export type MessageComponents = ActionRow[]; diff --git a/src/types/messages/components/select_data.ts b/src/types/messages/components/select_data.ts new file mode 100644 index 000000000..c347bd0a9 --- /dev/null +++ b/src/types/messages/components/select_data.ts @@ -0,0 +1,10 @@ +import { DiscordMessageComponentTypes } from "./message_component_types.ts"; + +export interface SelectMenuData { + /** The type of component */ + componentType: DiscordMessageComponentTypes.SelectMenu; + /** The custom id provided for this component. */ + customId: string; + /** The values chosen by the user. */ + values: string[]; +} diff --git a/src/types/messages/components/select_menu.ts b/src/types/messages/components/select_menu.ts new file mode 100644 index 000000000..6f2afbd52 --- /dev/null +++ b/src/types/messages/components/select_menu.ts @@ -0,0 +1,16 @@ +import { DiscordMessageComponentTypes } from "./message_component_types.ts"; +import { SelectOption } from "./select_option.ts"; + +export interface SelectMenuComponent { + type: DiscordMessageComponentTypes.SelectMenu; + /** A custom identifier for this component. Maximum 100 characters. */ + customId: string; + /** A custom placeholder text if nothing is selected. Maximum 100 characters. */ + placeholder?: string; + /** The minimum number of items that must be selected. Default 1. Between 1-25. */ + minValues?: number; + /** The maximum number of items that can be selected. Default 1. Between 1-25. */ + maxValues?: number; + /** The choices! Maximum of 25 items. */ + options: SelectOption[]; +} diff --git a/src/types/messages/components/select_option.ts b/src/types/messages/components/select_option.ts new file mode 100644 index 000000000..390226a31 --- /dev/null +++ b/src/types/messages/components/select_option.ts @@ -0,0 +1,21 @@ +export interface SelectOption { + /** The user-facing name of the option. Maximum 25 characters. */ + label: string; + /** The dev-defined value of the option. Maximum 100 characters. */ + value: string; + /** An additional description of the option. Maximum 50 characters. */ + description?: string; + /** The id, name, and animated properties of an emoji. */ + emoji?: + | string + | { + /** Emoji id */ + id?: string; + /** Emoji name */ + name?: string; + /** Whether this emoji is animated */ + animated?: boolean; + }; + /** Will render this option as already-selected by default. */ + default: boolean; +} diff --git a/src/types/messages/mod.ts b/src/types/messages/mod.ts index 3b700f956..2d8486264 100644 --- a/src/types/messages/mod.ts +++ b/src/types/messages/mod.ts @@ -6,6 +6,9 @@ export * from "./components/button_component.ts"; export * from "./components/button_styles.ts"; export * from "./components/message_component_types.ts"; export * from "./components/message_components.ts"; +export * from "./components/select_data.ts"; +export * from "./components/select_menu.ts"; +export * from "./components/select_option.ts"; export * from "./create_message.ts"; export * from "./edit_message.ts"; export * from "./get_messages.ts"; diff --git a/src/util/collection.ts b/src/util/collection.ts index 97173eebb..894542077 100644 --- a/src/util/collection.ts +++ b/src/util/collection.ts @@ -31,6 +31,18 @@ export class Collection extends Map { return clearInterval(this.sweeper?.intervalId); } + changeSweeperInterval(newInterval: number) { + if (!this.sweeper) return; + + this.startSweeper({ filter: this.sweeper.filter, interval: newInterval }); + } + + changeSweeperFilter(newFilter: (value: V, key: K) => boolean | Promise) { + if (!this.sweeper) return; + + this.startSweeper({ filter: newFilter, interval: this.sweeper.interval }); + } + set(key: K, value: V) { // When this collection is maxSizeed make sure we can add first if ((this.maxSize || this.maxSize === 0) && this.size >= this.maxSize) { diff --git a/src/util/dispatch_requirements.ts b/src/util/dispatch_requirements.ts new file mode 100644 index 000000000..90221e515 --- /dev/null +++ b/src/util/dispatch_requirements.ts @@ -0,0 +1,93 @@ +import { botId } from "../bot.ts"; +import { cache } from "../cache.ts"; +import { getChannels } from "../helpers/channels/get_channels.ts"; +import { getGuild } from "../helpers/guilds/get_guild.ts"; +import { getMember } from "../helpers/members/get_member.ts"; +import { structures } from "../structures/mod.ts"; +import type { DiscordGatewayPayload } from "../types/gateway/gateway_payload.ts"; +import type { Guild } from "../types/guilds/guild.ts"; +import { snowflakeToBigint } from "./bigint.ts"; +import { delay } from "./utils.ts"; + +const processing = new Set(); + +export async function dispatchRequirements(data: DiscordGatewayPayload, shardId: number) { + if (!cache.isReady) return; + + // DELETE MEANS WE DONT NEED TO FETCH. CREATE SHOULD HAVE DATA TO CACHE + if (data.t && ["GUILD_CREATE", "GUILD_DELETE"].includes(data.t)) return; + + const id = snowflakeToBigint( + (data.t && ["GUILD_UPDATE"].includes(data.t) + ? // deno-lint-ignore no-explicit-any + (data.d as any)?.id + : // deno-lint-ignore no-explicit-any + (data.d as any)?.guild_id) ?? "" + ); + + if (!id || cache.activeGuildIds.has(id)) return; + + // If this guild is in cache, it has not been swept and we can cancel + if (cache.guilds.has(id)) { + cache.activeGuildIds.add(id); + return; + } + + if (processing.has(id)) { + console.info(`[DISPATCH] New Guild ID already being processed: ${id} in ${data.t} event`); + + let runs = 0; + do { + await delay(500); + runs++; + } while (processing.has(id) && runs < 40); + + if (!processing.has(id)) return; + + return console.warn(`[DISPATCH] Already processed guild was not successfully fetched: ${id} in ${data.t} event`); + } + + processing.add(id); + + // New guild id has appeared, fetch all relevant data + console.info(`[DISPATCH] New Guild ID has appeared: ${id} in ${data.t} event`); + + const rawGuild = (await getGuild(id, { + counts: true, + addToCache: false, + }).catch(console.info)) as Guild | undefined; + + if (!rawGuild) { + processing.delete(id); + return console.warn(`[DISPATCH] Guild ID ${id} failed to fetch.`); + } + + console.info(`[DISPATCH] Guild ID ${id} has been found. ${rawGuild.name}`); + + const [channels, botMember] = await Promise.all([ + getChannels(id, false), + getMember(id, botId, { force: true }), + ]).catch((error) => { + console.warn(error); + return []; + }); + + if (!botMember || !channels) { + processing.delete(id); + return console.info(`[DISPATCH] Guild ID ${id} Name: ${rawGuild.name} failed. Unable to get botMember or channels`); + } + + const guild = await structures.createDiscordenoGuild(rawGuild, shardId); + + // Add to cache + cache.guilds.set(id, guild); + cache.dispatchedGuildIds.delete(id); + channels.forEach((channel) => { + cache.dispatchedChannelIds.delete(channel.id); + cache.channels.set(channel.id, channel); + }); + + processing.delete(id); + + console.info(`[DISPATCH] Guild ID ${id} Name: ${guild.name} completely loaded.`); +} diff --git a/src/util/utils.ts b/src/util/utils.ts index 43d48466e..e1fa966e4 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -1,6 +1,5 @@ import { encode } from "./deps.ts"; import { eventHandlers } from "../bot.ts"; -import { isActionRow } from "../helpers/type_guards/is_action_row.ts"; import { isButton } from "../helpers/type_guards/is_button.ts"; import { Errors } from "../types/discordeno/errors.ts"; import type { ApplicationCommandOption } from "../types/interactions/commands/application_command_option.ts"; @@ -14,6 +13,7 @@ import type { DiscordImageFormat } from "../types/misc/image_format.ts"; import type { DiscordImageSize } from "../types/misc/image_size.ts"; import { SLASH_COMMANDS_NAME_REGEX } from "./constants.ts"; import { validateLength } from "./validate_length.ts"; +import { isSelectMenu } from "../helpers/type_guards/is_select_menu.ts"; export async function urlToBase64(url: string) { const buffer = await fetch(url).then((res) => res.arrayBuffer()); @@ -215,43 +215,6 @@ export function validateComponents(components: MessageComponents) { let actionRowCounter = 0; for (const component of components) { - // 5 Link buttons can not have a customId - if (isButton(component)) { - if (component.type === ButtonStyles.Link && component.customId) { - throw new Error(Errors.LINK_BUTTON_CANNOT_HAVE_CUSTOM_ID); - } - // Other buttons must have a customId - if (!component.customId && component.type !== ButtonStyles.Link) { - throw new Error(Errors.BUTTON_REQUIRES_CUSTOM_ID); - } - - if (!validateLength(component.label, { max: 80 })) { - throw new Error(Errors.COMPONENT_LABEL_TOO_BIG); - } - - if (component.customId && !validateLength(component.customId, { max: 100 })) { - throw new Error(Errors.COMPONENT_CUSTOM_ID_TOO_BIG); - } - - if (typeof component.emoji === "string") { - // A snowflake id was provided - if (/^[0-9]+$/.test(component.emoji)) { - component.emoji = { - id: component.emoji, - }; - } else { - // A unicode emoji was provided - component.emoji = { - name: component.emoji, - }; - } - } - } - - if (!isActionRow(component)) { - continue; - } - actionRowCounter++; // Max of 5 ActionRows per message if (actionRowCounter > 5) throw new Error(Errors.TOO_MANY_ACTION_ROWS); @@ -259,6 +222,124 @@ export function validateComponents(components: MessageComponents) { // Max of 5 Buttons (or any component type) within an ActionRow if (component.components?.length > 5) { throw new Error(Errors.TOO_MANY_COMPONENTS); + } else if ( + component.components?.length > 1 && + component.components.some((subcomponent) => isSelectMenu(subcomponent)) + ) { + throw new Error(Errors.COMPONENT_SELECT_MUST_BE_ALONE); + } + + for (const subcomponent of component.components) { + if (subcomponent.customId && !validateLength(subcomponent.customId, { max: 100 })) { + throw new Error(Errors.COMPONENT_CUSTOM_ID_TOO_BIG); + } + + // 5 Link buttons can not have a customId + if (isButton(subcomponent)) { + if (subcomponent.type === ButtonStyles.Link && subcomponent.customId) { + throw new Error(Errors.LINK_BUTTON_CANNOT_HAVE_CUSTOM_ID); + } + // Other buttons must have a customId + if (!subcomponent.customId && subcomponent.type !== ButtonStyles.Link) { + throw new Error(Errors.BUTTON_REQUIRES_CUSTOM_ID); + } + + if (!validateLength(subcomponent.label, { max: 80 })) { + throw new Error(Errors.COMPONENT_LABEL_TOO_BIG); + } + + subcomponent.emoji = makeEmojiFromString(subcomponent.emoji); + } + + if (isSelectMenu(subcomponent)) { + if (subcomponent.placeholder && !validateLength(subcomponent.placeholder, { max: 100 })) { + throw new Error(Errors.COMPONENT_PLACEHOLDER_TOO_BIG); + } + + if (subcomponent.minValues) { + if (subcomponent.minValues < 1) { + throw new Error(Errors.COMPONENT_SELECT_MINVALUE_TOO_LOW); + } + + if (subcomponent.minValues > 25) { + throw new Error(Errors.COMPONENT_SELECT_MINVALUE_TOO_MANY); + } + + if (!subcomponent.maxValues) subcomponent.maxValues = subcomponent.minValues; + if (subcomponent.minValues > subcomponent.maxValues) { + throw new Error(Errors.COMPONENT_SELECT_MIN_HIGHER_THAN_MAX); + } + } + + if (subcomponent.maxValues) { + if (subcomponent.maxValues < 1) { + throw new Error(Errors.COMPONENT_SELECT_MAXVALUE_TOO_LOW); + } + + if (subcomponent.maxValues > 25) { + throw new Error(Errors.COMPONENT_SELECT_MAXVALUE_TOO_MANY); + } + } + + if (subcomponent.options.length < 1) { + throw new Error(Errors.COMPONENT_SELECT_OPTIONS_TOO_LOW); + } + + if (subcomponent.options.length > 25) { + throw new Error(Errors.COMPONENT_SELECT_OPTIONS_TOO_MANY); + } + + let defaults = 0; + + for (const option of subcomponent.options) { + if (option.default) { + defaults++; + if (defaults > (subcomponent.maxValues || 25)) { + throw new Error(Errors.SELECT_OPTION_TOO_MANY_DEFAULTS); + } + } + + if (!validateLength(option.label, { max: 25 })) { + throw new Error(Errors.SELECT_OPTION_LABEL_TOO_BIG); + } + + if (!validateLength(option.value, { max: 100 })) { + throw new Error(Errors.SELECT_OPTION_VALUE_TOO_BIG); + } + + if (option.description && !validateLength(option.description, { max: 50 })) { + throw new Error(Errors.SELECT_OPTION_VALUE_TOO_BIG); + } + + option.emoji = makeEmojiFromString(option.emoji); + } + } } } } + +function makeEmojiFromString( + emoji?: + | string + | { + id?: string | undefined; + name?: string | undefined; + animated?: boolean | undefined; + } +) { + if (typeof emoji !== "string") return emoji; + + // A snowflake id was provided + if (/^[0-9]+$/.test(emoji)) { + emoji = { + id: emoji, + }; + } else { + // A unicode emoji was provided + emoji = { + name: emoji, + }; + } + + return emoji; +} diff --git a/tests/local.ts b/tests/local.ts index 6676768cb..85e15eef6 100644 --- a/tests/local.ts +++ b/tests/local.ts @@ -1,3 +1,22 @@ +// THE ORDER OF THE IMPORTS IN THIS FILE MATTER! +// DO NOT MOVE THEM UNLESS YOU KNOW WHAT YOUR DOING! + import "./util/utils.ts"; import "./util/validate_length.ts"; import "./util/loop_object.ts"; + +// Final cleanup + +import { cache } from "../src/cache.ts"; +import { delay } from "../src/util/utils.ts"; +if (import.meta.main) { + // clear all the sweeper intervals + for (const c of Object.values(cache)) { + if (!(c instanceof Map)) continue; + + c.stopSweeper(); + console.log("Cleaned"); + } + + await delay(3000); +} diff --git a/tests/mod.ts b/tests/mod.ts index d804f71f1..13c4dc1a2 100644 --- a/tests/mod.ts +++ b/tests/mod.ts @@ -74,3 +74,17 @@ import "./discoveries/valid_discovery_term.ts"; // Final cleanup import "./guilds/delete_guild.ts"; import "./ws/ws_close.ts"; + +import { cache } from "../src/cache.ts"; +import { delay } from "../src/util/utils.ts"; +if (import.meta.main) { + // clear all the sweeper intervals + for (const c of Object.values(cache)) { + if (!(c instanceof Map)) continue; + + c.stopSweeper(); + console.log("Cleaned"); + } + + await delay(3000); +}