diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts
index 661c51a8..68ec403f 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 = /<?https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)>?/g;
+function checkActionRow(row: ActionRowComponent, knownComponentIds: string[], errors: Record<string, { code?: string; message: string }>, rowIndex: number) {
+ if (!row.components) {
+ return;
+ }
-export async function handleMessage(opts: MessageOptions): Promise<Message> {
- const errors: Record<string, { code?: string; message: string }> = {};
- const knownComponentIds: string[] = [];
+ 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 row of opts.components || []) {
- if (!row.components) {
- continue;
- }
+ 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 (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.`,
- };
+ 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!);
+ }
}
+ }
+}
- 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",
- };
- }
+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");
+}
- 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!);
+export async function handleMessage(opts: MessageOptions): Promise<Message> {
+ const errors: Record<string, { code?: string; message: string }> = {};
+ const knownComponentIds: string[] = [];
+ 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 5fb888b5..4d2f4370 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;
|