/* Spacebar: A FOSS re-implementation and extension of the Discord.com backend. Copyright (C) 2023 Spacebar and Spacebar Contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ import { EmbedHandlers, randomString, fillMessageUrlEmbeds } from "@spacebar/api"; import { Application, Attachment, Channel, Config, EmbedCache, emitEvent, EVERYONE_MENTION, getPermission, getRights, Guild, HERE_MENTION, Message, MessageCreateEvent, MessageUpdateEvent, Role, ROLE_MENTION, Sticker, User, //CHANNEL_MENTION, USER_MENTION, Webhook, handleFile, Permissions, normalizeUrl, DiscordApiErrors, CloudAttachment, ReadState, Member, Session, MessageFlags, FieldErrors, Snowflake, } from "@spacebar/util"; import { HTTPError } from "lambert-server"; import { In, Or, Equal, IsNull } from "typeorm"; import { ActionRowComponent, ButtonStyle, ChannelType, Embed, EmbedType, MessageComponentType, MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageType, Reaction, ReadStateType, UnfurledMediaItem, BaseMessageComponents, v1CompTypes, PartialUser, } from "@spacebar/schemas"; const allow_empty = false; // TODO: check webhook, application, system author, stickers // TODO: embed gifs/videos/images 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, messageId: string, batchId: string, user: User, channel: Channel, id: string): Promise<(() => void) | void> { if (Object.keys(media).length > 1) throw new HTTPError("Extra keys for media items are not allowed"); 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"); let attEnt: CloudAttachment; let delWhenDone = false; if (url.protocol === "attachment") { attEnt = await CloudAttachment.findOneOrFail({ where: { uploadFilename: url.hostname, }, }); } else { const res = await fetch(url); if (!res.ok) throw new HTTPError("URL did not return OK"); const blob = await res.blob(); const name = url.pathname.split("/").findLast((_) => _) || id; const uploadFilename = `${channel.id}/${batchId}/${id ?? "0"}/${name}`; attEnt = CloudAttachment.create({ user: user, channel: channel, uploadFilename: uploadFilename, userAttachmentId: id ?? "0", userFilename: name, userFileSize: blob.size, userIsClip: false, }); await attEnt.save(); const cdnUrl = Config.get().cdn.endpointPublic; const fetchUrl = `${cdnUrl}/attachments/${attEnt.uploadFilename}`; await ( await fetch(fetchUrl, { method: "PUT", body: blob, }) ).text(); // re-fetch due to changed DB entry attEnt = await CloudAttachment.findOneOrFail({ where: { id: attEnt.id, }, }); delWhenDone = true; } const cloneResponse = await fetch(`${Config.get().cdn.endpointPrivate}/attachments/${attEnt.uploadFilename}/clone_to_message/${messageId}`, { method: "POST", headers: { signature: Config.get().security.requestSignature || "", }, }); if (!cloneResponse.ok) { console.error(`[Message] Failed to clone attachment ${attEnt.userFilename} to message ${messageId}`); throw new HTTPError("Failed to process attachment: " + (await cloneResponse.text()), 500); } const cloneRespBody = (await cloneResponse.json()) as { success: boolean; new_path: string }; media.proxy_url = `${Config.get().cdn.endpointPublic}/${cloneRespBody.new_path}`; if (url.protocol === "attachment:") media.url = media.proxy_url; const realAtt = Attachment.create({ filename: attEnt.userFilename, url: media.url, proxy_url: media.proxy_url, size: attEnt.size, height: attEnt.height, width: attEnt.width, content_type: attEnt.contentType || attEnt.userOriginalContentType, }); await realAtt.save(); //TODO maybe this needs to be a new DB object? I don't see a reason to do this rn though, though this id *should* technically be different from the id of the attachment media.id = realAtt.id; media.height = attEnt.height; media.width = attEnt.width; media.content_type = attEnt.contentType; //TODO flags? media.attachment_id = attEnt.id; //TODO preview stuff if (delWhenDone) { return () => fetch(`${Config.get().cdn.endpointPrivate}/attachments/${attEnt.uploadFilename}`, { headers: { signature: Config.get().security.requestSignature, }, method: "DELETE", }).then(() => { attEnt.remove(); }); } } export function handleComps(components: BaseMessageComponents[], flags: number) { const conf = Config.get(); const mediaGalleryLimit = conf.components.mediaGalleryLimit ?? 10; const actionRowLimit = conf.components.actionRowLimit ?? 5; const errors: Record = {}; const knownComponentIds: string[] = []; const compv2 = (flags || 0) & Number(MessageFlags.FLAGS.IS_COMPONENTS_V2); if (!compv2) { const bad = components.reduce((bad, comp) => bad || !v1CompTypes.has(comp.type), false); if (bad) throw new HTTPError("Must be comp v2"); } const medias: UnfurledMediaItem[] = []; for (const comp of components || []) { if (comp.type === MessageComponentType.ActionRow) { checkActionRow(comp, knownComponentIds, errors, components!.indexOf(comp)); } else if (comp.type === MessageComponentType.Section) { const accessory = comp.accessory; if (comp.components.length < 1 || comp.components.length > actionRowLimit) { errors[`data.components[${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) { //Here to make sure everything is checked } else if (comp.type === MessageComponentType.MediaGallery) { if (comp.items.length < 1 || comp.items.length > mediaGalleryLimit) { errors[`data.components[${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) { medias.push(comp.file); } else if (comp.type === MessageComponentType.Separator) { //Here to make sure everything is checked } else if (comp.type === MessageComponentType.Container) { 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 > actionRowLimit) { errors[`data.components[${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 > mediaGalleryLimit) { errors[`data.components[${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, components!.indexOf(elm)); break; default: elm satisfies never; } } } else { comp satisfies never; } } if (Object.keys(errors).length > 0) { throw FieldErrors(errors); } return async (messageId: string, user: User, channel: Channel) => { const batchId = `CLOUD_compUploads_${randomString(128)}`; (await Promise.all(medias.map((m, index) => processMedia(m, messageId, batchId, user, channel, index + "")))).forEach((_) => _?.()); }; } export async function handleMessage(opts: MessageOptions): Promise { const conf = Config.get(); const handle = opts.components ? handleComps(opts.components, opts.flags || 0) : undefined; const channel = await Channel.findOneOrFail({ where: { id: opts.channel_id }, relations: { recipients: true }, }); if (!channel || !opts.channel_id) throw new HTTPError("Channel not found", 404); let permission: null | Permissions = null; const limit = channel.rate_limit_per_user; if (limit) { const lastMsgTime = (await Message.findOne({ where: { channel_id: channel.id, author_id: opts.author_id }, select: { timestamp: true }, order: { timestamp: "DESC" } })) ?.timestamp; if (lastMsgTime && Date.now() - limit * 1000 < +lastMsgTime) { permission = await getPermission(opts.author_id, channel.guild_id, channel); //FIXME MANAGE_MESSAGES and MANAGE_CHANNELS will need to be removed once they're gone as checks if (!permission.has("MANAGE_MESSAGES") && !permission.has("MANAGE_CHANNELS") && !permission.has("BYPASS_SLOWMODE")) { throw DiscordApiErrors.SLOWMODE_RATE_LIMIT; } } } const stickers = opts.sticker_ids ? await Sticker.find({ where: { id: In(opts.sticker_ids) } }) : undefined; // cloud attachments with indexes const cloudAttachments = opts.attachments?.reduce( (acc, att, index) => { if ("uploaded_filename" in att) { acc.push({ attachment: att, index }); } return acc; }, [] as { attachment: MessageCreateCloudAttachment; index: number }[], ); const message = Message.create({ ...opts, message_reference: opts.message_reference ?? undefined, poll: opts.poll, sticker_items: stickers, guild_id: channel.guild_id, channel_id: opts.channel_id, attachments: opts.attachments || [], embeds: opts.embeds || [], reactions: opts.reactions || [], type: opts.type ?? 0, mentions: [], components: opts.components ?? undefined, // Fix Discord-Go? }); message.channel = channel; if (opts.author_id) { message.author = await User.findOneOrFail({ where: { id: opts.author_id }, }); const rights = await getRights(opts.author_id); message.author.clean_data(); rights.hasThrow("SEND_MESSAGES"); } const ephermal = (message.flags & (1 << 6)) !== 0; if (!ephermal && channel.type === ChannelType.GUILD_PUBLIC_THREAD) { const rep = Channel.getRepository(); await rep.increment({ id: channel.id }, "message_count", 1); await rep.increment({ id: channel.id }, "total_message_sent", 1); } if (!ephermal) { channel.last_message_id = message.id; await channel.save(); } if (cloudAttachments && cloudAttachments.length > 0) { console.log("[Message] Processing attachments for message", message.id, ":", message.attachments); handle?.(message.id, message.author as User, message.channel); const uploadedAttachments = await Promise.all( cloudAttachments.map(async (att) => { const cAtt = att.attachment; const attEnt = await CloudAttachment.findOneOrFail({ where: { uploadFilename: cAtt.uploaded_filename, }, }); const cloneResponse = await fetch(`${conf.cdn.endpointPrivate}/attachments/${attEnt.uploadFilename}/clone_to_message/${message.id}`, { method: "POST", headers: { signature: conf.security.requestSignature || "", }, }); if (!cloneResponse.ok) { console.error(`[Message] Failed to clone attachment ${attEnt.userFilename} to message ${message.id}`); throw new HTTPError("Failed to process attachment: " + (await cloneResponse.text()), 500); } const cloneRespBody = (await cloneResponse.json()) as { success: boolean; new_path: string }; const realAtt = Attachment.create({ filename: attEnt.userFilename, url: `${conf.cdn.endpointPublic}/${cloneRespBody.new_path}`, proxy_url: `${conf.cdn.endpointPublic}/${cloneRespBody.new_path}`, size: attEnt.size, height: attEnt.height, width: attEnt.width, content_type: attEnt.contentType || attEnt.userOriginalContentType, }); await realAtt.save(); return { attachment: realAtt, index: att.index }; }), ); console.log("[Message] Processed attachments for message", message.id, ":", message.attachments); for (const att of uploadedAttachments) { message.attachments![att.index] = att.attachment; } } // else console.log("[Message] No cloud attachments to process for message", message.id, ":", message.attachments); if (message.content && message.content.length > conf.limits.message.maxCharacters) { throw new HTTPError("Content length over max character limit"); } if (opts.application_id) { message.application = await Application.findOneOrFail({ where: { id: opts.application_id }, }); } if (opts.webhook_id) { message.webhook = await Webhook.findOneOrFail({ where: { id: opts.webhook_id }, }); message.author = (await User.findOne({ where: { id: opts.webhook_id }, })) || undefined; if (!message.author) { message.author = User.create({ id: opts.webhook_id, username: message.webhook.name, discriminator: "0000", avatar: message.webhook.avatar, public_flags: 0, premium: false, premium_type: 0, bot: true, created_at: new Date(), verified: true, rights: "0", data: { valid_tokens_since: new Date(), }, }); await message.author.save(); } if (opts.username) { message.username = opts.username; message.author.username = message.username; } if (opts.avatar_url) { const avatarData = await fetch(opts.avatar_url); const base64 = await avatarData.arrayBuffer().then((x) => Buffer.from(x).toString("base64")); const dataUri = "data:" + avatarData.headers.get("content-type") + ";base64," + base64; message.avatar = await handleFile(`/avatars/${opts.webhook_id}`, dataUri as string); message.author.avatar = message.avatar; } } else { permission ||= await getPermission(opts.author_id, channel.guild_id, channel); if (permission === null) throw new HTTPError("permission was null after getPermission", 500); permission.hasThrow("SEND_MESSAGES"); if (permission.cache.member) { message.member = permission.cache.member; } if (opts.tts) permission.hasThrow("SEND_TTS_MESSAGES"); if (opts.message_reference) { permission.hasThrow("READ_MESSAGE_HISTORY"); // code below has to be redone when we add custom message routing if (message.guild_id !== null) { await Guild.findOneOrFail({ where: { id: channel.guild_id }, }); if (!opts.message_reference.guild_id) opts.message_reference.guild_id = channel.guild_id; if (!opts.message_reference.channel_id) opts.message_reference.channel_id = opts.channel_id; if (opts.message_reference.type != 1) { if (opts.message_reference.guild_id !== channel.guild_id) throw new HTTPError("You can only reference messages from this guild"); if (opts.message_reference.channel_id !== opts.channel_id && opts.type !== MessageType.THREAD_STARTER_MESSAGE && opts.type !== MessageType.THREAD_CREATED) throw new HTTPError("You can only reference messages from this channel"); } message.message_reference = opts.message_reference; if (message.message_reference.message_id) { message.referenced_message = await Message.findOneOrFail({ where: { id: opts.message_reference.message_id, }, relations: { author: true, webhook: true, application: true, mentions: true, mention_roles: true, mention_channels: true, sticker_items: true, attachments: true, }, }); if ( message.referenced_message.channel_id && message.referenced_message.channel_id !== opts.message_reference.channel_id && opts.type !== MessageType.THREAD_STARTER_MESSAGE ) throw new HTTPError("Referenced message not found in the specified channel", 404); if ( message.referenced_message.guild_id && message.referenced_message.guild_id !== opts.message_reference.guild_id && opts.type !== MessageType.THREAD_STARTER_MESSAGE ) throw new HTTPError("Referenced message not found in the specified channel", 404); } } /** Q: should be checked if the referenced message exists? ANSWER: NO otherwise backfilling won't work **/ if (MessageType.THREAD_STARTER_MESSAGE !== message.type && MessageType.THREAD_CREATED !== message.type) message.type = MessageType.REPLY; } } // TODO: stickers/activity if ( !allow_empty && !opts.content && !opts.embeds?.length && !opts.attachments?.length && !opts.sticker_ids?.length && !opts.poll && !opts.components?.length && opts.message_reference?.type != 1 && opts.type !== MessageType.THREAD_STARTER_MESSAGE ) { console.log("[Message] Rejecting empty message:", opts, message); throw new HTTPError("Empty messages are not allowed", 50006); } let content = opts.content; // root@Rory - 20/02/2023 - This breaks channel mentions in test client. We're not sure this was used in older clients. //const mention_channel_ids = [] as string[]; const mention_role_ids = [] as string[]; const mention_user_ids = [] as string[]; let mention_everyone = false; if (content) { // TODO: explicit-only mentions message.content = content.trim(); content = content.replace(/ *`[^)]*` */g, ""); // remove codeblocks // root@Rory - 20/02/2023 - This breaks channel mentions in test client. We're not sure this was used in older clients. /*for (const [, mention] of content.matchAll(CHANNEL_MENTION)) { if (!mention_channel_ids.includes(mention)) mention_channel_ids.push(mention); }*/ for (const [, mention] of content.matchAll(USER_MENTION)) { if (!mention_user_ids.includes(mention)) mention_user_ids.push(mention); } await Promise.all( Array.from(content.matchAll(ROLE_MENTION)).map(async ([, mention]) => { const role = await Role.findOneOrFail({ where: { id: mention, guild_id: channel.guild_id }, }); if (role.mentionable || opts.webhook_id || permission?.has("MANAGE_ROLES")) { mention_role_ids.push(mention); } }), ); if (opts.webhook_id || permission?.has("MENTION_EVERYONE")) { mention_everyone = !!content.match(EVERYONE_MENTION) || !!content.match(HERE_MENTION); } } if (message.message_reference?.message_id) { const referencedMessage = await Message.findOne({ where: { id: message.message_reference.message_id, channel_id: message.channel_id, }, }); if (referencedMessage && referencedMessage.author_id !== message.author_id) { message.mentions.push( // @ts-expect-error it does not like the .toPublicUser() lol (await User.findOne({ where: { id: referencedMessage.author_id } }))!.toPublicUser(), ); } // FORWARD if (message.message_reference.type === 1) { message.type = MessageType.DEFAULT; if (message.referenced_message) { const mention_roles: string[] = []; const mentions: string[] = []; // TODO: mention_roles and mentions arrays - not needed it seems, but discord still returns that message.message_snapshots = [ { message: { attachments: message.referenced_message.attachments, components: message.referenced_message.components, content: message.referenced_message.content!, edited_timestamp: message.referenced_message.edited_timestamp, embeds: message.referenced_message.embeds, flags: message.referenced_message.flags, mention_roles, mentions, timestamp: message.referenced_message.timestamp, type: message.referenced_message.type, }, }, ]; } } } // root@Rory - 20/02/2023 - This breaks channel mentions in test client. We're not sure this was used in older clients. /*message.mention_channels = mention_channel_ids.map((x) => Channel.create({ id: x }), );*/ message.mention_roles = ( await Promise.all( mention_role_ids.map((x) => { return Role.findOne({ where: { id: x } }); }), ) ).filter((role) => role !== null); message.mentions = [ ...message.mentions, ...( await Promise.all( mention_user_ids.map((x) => { return User.findOne({ where: { id: x } }); }), ) ).filter((user) => user !== null), ]; message.mention_everyone = mention_everyone; async function fillInMissingIDs(ids: string[]) { const states = await ReadState.findBy({ user_id: Or(...ids.map((id) => Equal(id))), channel_id: channel.id, }); const users = new Set(ids); states.forEach((state) => users.delete(state.user_id)); if (!users.size) { return; } return Promise.all( [...users].map((user_id) => { return ReadState.create({ user_id, channel_id: channel.id }).save(); }), ); } if (ephermal) { const id = message.interaction_metadata?.user_id; if (id) { let pinged = mention_everyone || channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM; if (!pinged) pinged = !!message.mentions.find((user) => user.id === id); if (!pinged) pinged = !!(await Member.find({ where: { id, roles: Or(...message.mention_roles.map(({ id }) => Equal(id))) } })); if (pinged) { //stuff } } } else if ((!!message.content?.match(EVERYONE_MENTION) && permission?.has("MENTION_EVERYONE")) || channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM) { if (channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM) { if (channel.recipients) { await fillInMissingIDs(channel.recipients.map(({ user_id }) => user_id)); } } else { await fillInMissingIDs((await Member.find({ where: { guild_id: channel.guild_id } })).map(({ id }) => id)); } const repository = ReadState.getRepository(); const condition = { channel_id: channel.id, read_state_type: ReadStateType.CHANNEL }; await repository.update({ ...condition, mention_count: IsNull() }, { mention_count: 0 }); await repository.increment(condition, "mention_count", 1); } else { const users = new Set([ ...(message.mention_roles.length ? await Member.find({ where: [ ...message.mention_roles.map((role) => { return { roles: { id: role.id } }; }), ], }) : [] ).map((member) => member.id), ...message.mentions.map((user) => user.id), ]); if (!!message.content?.match(HERE_MENTION) && permission?.has("MENTION_EVERYONE")) { const ids = (await Member.find({ where: { guild_id: channel.guild_id } })).map(({ id }) => id); (await Session.find({ where: { user_id: Or(...ids.map((id) => Equal(id))) } })).forEach(({ user_id }) => users.add(user_id)); } if (users.size) { const repository = ReadState.getRepository(); const condition = { user_id: Or(...[...users].map((id) => Equal(id))), channel_id: channel.id, read_state_type: ReadStateType.CHANNEL }; await fillInMissingIDs([...users]); await repository.increment(condition, "mention_count", 1); } } const attachmentIndices = new Map( message.attachments?.map((attachment, index) => { return [`attachment://${attachment.filename}`, index]; }), ); const attachmentsToRemove = new Set(); function fetchAttachment(url: string | undefined): Attachment | undefined { if (url == undefined) { return undefined; } const index = attachmentIndices.get(url); if (index === undefined) { return undefined; } const attachment = message.attachments?.[index]; if (attachment === undefined) { return undefined; } attachmentsToRemove.add(index); return attachment; } for (const embed of message.embeds) { const footer = embed.footer; const footerAttachment = fetchAttachment(footer?.icon_url); if (footerAttachment !== undefined) { footer!.icon_url = footerAttachment.url; footer!.proxy_icon_url = footerAttachment.proxy_url; } const image = embed.image; const imageAttachment = fetchAttachment(image?.url); if (imageAttachment !== undefined) { image!.url = imageAttachment.url; image!.proxy_url = imageAttachment.proxy_url; } const author = embed.author; const authorAttachment = fetchAttachment(author?.icon_url); if (authorAttachment !== undefined) { author!.icon_url = authorAttachment.url; author!.proxy_icon_url = authorAttachment.proxy_url; } } message.attachments = message.attachments?.filter((_, index) => { return !attachmentsToRemove.has(index); }); // TODO: check and put it all in the body return message; } // TODO: cache link result in db export async function postHandleMessage(message: Message) { message.clean_data(); message.embeds ??= []; message.embeds.forEach((embed) => { // we need to handle false-y values (empty string) here, so cant use ??= embed.type ||= EmbedType.rich; }); if ((await getPermission(message.author_id, message.channel.guild_id, message.channel_id)).has(Permissions.FLAGS.EMBED_LINKS)) await fillMessageUrlEmbeds(message); } export async function sendMessage(opts: MessageOptions) { const message = await handleMessage({ ...opts, timestamp: new Date() }); const ephemeral = (message.flags & Number(MessageFlags.FLAGS.EPHEMERAL)) !== 0; await Promise.all([ message.insert(), message.channel.save(), emitEvent({ event: "MESSAGE_CREATE", ...(ephemeral ? { user_id: message.interaction_metadata?.user_id } : { channel_id: message.channel_id }), data: message.toJSON(), } satisfies MessageCreateEvent), ]); // no await as it should catch error non-blockingly postHandleMessage(message).catch((e) => console.error("[Message] post-message handler failed", e)); return message; } interface MessageOptions extends MessageCreateSchema { id?: string; type?: MessageType; pinned?: boolean; author_id?: string; webhook_id?: string; application_id?: string; embeds?: Embed[] | null; reactions?: Reaction[]; channel_id?: string; attachments?: (MessageCreateAttachment | MessageCreateCloudAttachment | Attachment)[]; // why are we masking this? edited_timestamp?: Date; timestamp?: Date; username?: string; avatar_url?: string; }