diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts index 661c51a8a..68ec403f3 100644 --- a/src/api/util/handlers/Message.ts +++ b/src/api/util/handlers/Message.ts @@ -53,6 +53,7 @@ import { import { HTTPError } from "lambert-server"; import { In, Or, Equal, IsNull } from "typeorm"; import { + ActionRowComponent, ButtonStyle, ChannelType, Embed, @@ -64,53 +65,140 @@ import { MessageType, Reaction, ReadStateType, + UnfurledMediaItem, } from "@spacebar/schemas"; const allow_empty = false; // TODO: check webhook, application, system author, stickers // TODO: embed gifs/videos/images const LINK_REGEX = /?/g; +function checkActionRow(row: ActionRowComponent, knownComponentIds: string[], errors: Record, rowIndex: number) { + if (!row.components) { + return; + } + + if (row.components.length < 1 || row.components.length > 5) { + errors[`data.components[${rowIndex}].components`] = { + code: "BASE_TYPE_BAD_LENGTH", + message: `Must be between 1 and 5 in length.`, + }; + } + + for (const component of row.components) { + if (component.type == MessageComponentType.Button && component.style != ButtonStyle.Link) { + if (component.custom_id?.trim() === "") { + errors[`data.components[${rowIndex}].components[${row.components.indexOf(component)}].custom_id`] = { + code: "BUTTON_COMPONENT_CUSTOM_ID_REQUIRED", + message: "A custom id required", + }; + } + + if (knownComponentIds.includes(component.custom_id!)) { + errors[`data.components[${rowIndex}].components[${row.components.indexOf(component)}].custom_id`] = { + code: "COMPONENT_CUSTOM_ID_DUPLICATED", + message: "Component custom id cannot be duplicated", + }; + } else { + knownComponentIds.push(component.custom_id!); + } + } + } +} + +async function processMedia(media: UnfurledMediaItem) { + if (Object.keys(media).length > 1) throw new HTTPError("no, you can't send those"); + if (!URL.canParse(media.url)) throw new HTTPError("media URL must be a URI"); + const url = new URL(media.url); + if (!["http", "https", "attachment"].includes(url.protocol)) throw new HTTPError("invalid media protocol"); +} export async function handleMessage(opts: MessageOptions): Promise { const errors: Record = {}; const knownComponentIds: string[] = []; - - for (const row of opts.components || []) { - if (!row.components) { - continue; - } - - if (row.components.length < 1 || row.components.length > 5) { - errors[`data.components[${opts.components!.indexOf(row)}].components`] = { - code: "BASE_TYPE_BAD_LENGTH", - message: `Must be between 1 and 5 in length.`, - }; - } - - for (const component of row.components) { - if (component.type == MessageComponentType.Button && component.style != ButtonStyle.Link) { - if (component.custom_id?.trim() === "") { - errors[`data.components[${opts.components!.indexOf(row)}].components[${row.components.indexOf(component)}].custom_id`] = { - code: "BUTTON_COMPONENT_CUSTOM_ID_REQUIRED", - message: "A custom id required", - }; - } - - if (knownComponentIds.includes(component.custom_id!)) { - errors[`data.components[${opts.components!.indexOf(row)}].components[${row.components.indexOf(component)}].custom_id`] = { - code: "COMPONENT_CUSTOM_ID_DUPLICATED", - message: "Component custom id cannot be duplicated", - }; - } else { - knownComponentIds.push(component.custom_id!); + const compv2 = (opts.flags || 0) & Number(MessageFlags.FLAGS.IS_COMPONENTS_V2); + const medias: UnfurledMediaItem[] = []; + for (const comp of opts.components || []) { + if (comp.type === MessageComponentType.ActionRow) { + checkActionRow(comp, knownComponentIds, errors, opts.components!.indexOf(comp)); + } else if (comp.type === MessageComponentType.Section) { + if (!compv2) throw new HTTPError("Must be comp v2"); + const accessory = comp.accessory; + if (comp.components.length < 1 || comp.components.length > 3) { + errors[`data.components[${opts.components!.indexOf(comp)}].components`] = { + code: "TOO_LONG", + message: "Component list is too long", + }; + } + if (accessory.type === MessageComponentType.Thumbnail) { + medias.push(accessory.media); + } + } else if (comp.type === MessageComponentType.TextDisplay) { + if (!compv2) throw new HTTPError("Must be comp v2"); + } else if (comp.type === MessageComponentType.MediaGallery) { + if (!compv2) throw new HTTPError("Must be comp v2"); + if (comp.items.length < 1 || comp.items.length > 10) { + errors[`data.components[${opts.components!.indexOf(comp)}].items`] = { + code: "TOO_LONG", + message: "Media list is too long", + }; + } + medias.push(...comp.items.map(({ media }) => media)); + } else if (comp.type === MessageComponentType.File) { + if (!compv2) throw new HTTPError("Must be comp v2"); + medias.push(comp.file); + } else if (comp.type === MessageComponentType.Separator) { + if (!compv2) throw new HTTPError("Must be comp v2"); + } else if (comp.type === MessageComponentType.Container) { + if (!compv2) throw new HTTPError("Must be comp v2"); + for (const elm of comp.components) { + switch (elm.type) { + case MessageComponentType.Separator: + case MessageComponentType.TextDisplay: + break; + case MessageComponentType.Section: { + const accessory = elm.accessory; + if (elm.components.length < 1 || elm.components.length > 3) { + errors[`data.components[${opts.components!.indexOf(comp)}].components[${comp.components!.indexOf(elm)}].components`] = { + code: "TOO_LONG", + message: "Component list is too long", + }; + } + if (accessory.type === MessageComponentType.Thumbnail) { + medias.push(accessory.media); + } + break; + } + case MessageComponentType.MediaGallery: + if (elm.items.length < 1 || elm.items.length > 10) { + errors[`data.components[${opts.components!.indexOf(comp)}].components[${comp.components!.indexOf(elm)}].items`] = { + code: "TOO_LONG", + message: "Media list is too long", + }; + } + medias.push(...elm.items.map(({ media }) => media)); + break; + case MessageComponentType.File: { + medias.push(elm.file); + break; + } + case MessageComponentType.ActionRow: + checkActionRow(elm, knownComponentIds, errors, opts.components!.indexOf(elm)); + break; + default: + elm satisfies never; } } + } else { + comp satisfies never; } } if (Object.keys(errors).length > 0) { throw FieldErrors(errors); } + + await Promise.all(medias.map(processMedia)); + const channel = await Channel.findOneOrFail({ where: { id: opts.channel_id }, relations: { recipients: true }, @@ -722,8 +810,8 @@ export async function sendMessage(opts: MessageOptions) { const ephemeral = (message.flags & Number(MessageFlags.FLAGS.EPHEMERAL)) !== 0; await Promise.all([ - Message.insert(message), - Channel.update(message.channel.id, message.channel), + message.insert(), + message.channel.save(), emitEvent({ event: "MESSAGE_CREATE", ...(ephemeral ? { user_id: message.interaction_metadata?.user_id } : { channel_id: message.channel_id }), diff --git a/src/schemas/api/messages/Components.ts b/src/schemas/api/messages/Components.ts index 5fb888b5d..4d2f43700 100644 --- a/src/schemas/api/messages/Components.ts +++ b/src/schemas/api/messages/Components.ts @@ -20,13 +20,78 @@ import { PartialEmoji } from "@spacebar/schemas"; export interface MessageComponent { type: MessageComponentType; + id?: number; +} + +export interface SectionComponent extends MessageComponent { + type: MessageComponentType.Section; + components: TextDispalyComponent[]; + accessory: ThumbnailComponent | ButtonComponent; +} + +export interface ThumbnailComponent extends MessageComponent { + type: MessageComponentType.Thumbnail; + description?: string; + media: UnfurledMediaItem; + spoiler?: boolean; +} +export interface UnfurledMediaItem { + id?: string; + url: string; + proxy_url?: string; + height?: number; + width?: number; + flags?: number; + content_type?: string; + content_scan_metadata?: unknown; //TODO deal with this lol + placeholder_version?: number; + placeholder?: string; + loading_state?: number; + attachment_id?: string; +} +export interface TextDispalyComponent extends MessageComponent { + type: MessageComponentType.TextDisplay; + content: string; +} +export interface MediaGalleryComponent extends MessageComponent { + type: MessageComponentType.MediaGallery; + items: { + media: UnfurledMediaItem; + description?: string; + spoiler?: boolean; + }[]; +} + +export interface FileComponent extends MessageComponent { + type: MessageComponentType.File; + file: UnfurledMediaItem; + spoiler: boolean; + name: string; + size: number; +} +export const enum SeperatorSpacing { + Small = 1, + Large = 2, +} +export interface SeperatorComponent extends MessageComponent { + type: MessageComponentType.Separator; + divider?: boolean; + spacing?: SeperatorSpacing; } export interface ActionRowComponent extends MessageComponent { type: MessageComponentType.ActionRow; components: (ButtonComponent | StringSelectMenuComponent | SelectMenuComponent | TextInputComponent)[]; } -export type BaseMessageComponents = ActionRowComponent; + +export interface ContainerComponent extends MessageComponent { + type: MessageComponentType.Container; + components: (ActionRowComponent | TextDispalyComponent | SectionComponent | MediaGalleryComponent | SeperatorComponent | FileComponent)[]; + accent_color?: number; + spoiler?: boolean; +} + +export type BaseMessageComponents = ActionRowComponent | SectionComponent | TextDispalyComponent | MediaGalleryComponent | FileComponent | SeperatorComponent | ContainerComponent; export interface ButtonComponent extends MessageComponent { type: MessageComponentType.Button;