/* 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 { randomString, fillMessageUrlEmbeds } from "@spacebar/api"; import { Application, Attachment, Channel, Config, emitEvent, EVERYONE_MENTION, getPermission, getRights, Guild, HERE_MENTION, Message, MessageCreateEvent, Role, ROLE_MENTION, Sticker, User, //CHANNEL_MENTION, USER_MENTION, Webhook, handleFile, Permissions, DiscordApiErrors, CloudAttachment, ReadState, Member, Session, MessageFlags, FieldErrors, getDatabase, } 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, } 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, size: attEnt.size, height: attEnt.height, width: attEnt.width, content_type: attEnt.contentType || attEnt.userOriginalContentType, channel_id: channel.id, message_id: messageId, }); 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; 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: [], embeds: opts.embeds || [], reactions: opts.reactions || [], type: opts.type ?? 0, mentions: [], components: opts.components ?? undefined, // Fix Discord-Go? }); message.channel = channel; await processMessageOptionAttachments(opts, message); 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(); } // TODO: Removed cloud attachment handling being inline - handle components! 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 // TODO: make mentions lazy 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, }, relations: { mentions: true, mention_roles: true, }, }); 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) { // TODO: mention_roles and mentions arrays - not needed it seems, but discord still returns that message.message_snapshots = [message.referenced_message.toSnapshot()]; } } } // 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) => Role.findOne({ where: { id: x } })))).filter((role) => role !== null); message.mentions = [...message.mentions, ...(await Promise.all(mention_user_ids.map((x) => 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) => 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) => ({ 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) => [`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.toJSON().url; footer!.proxy_icon_url = footerAttachment.toJSON().proxy_url; } const image = embed.image; const imageAttachment = fetchAttachment(image?.url); if (imageAttachment !== undefined) { image!.url = imageAttachment.toJSON().url; image!.proxy_url = imageAttachment.toJSON().proxy_url; } const author = embed.author; const authorAttachment = fetchAttachment(author?.icon_url); if (authorAttachment !== undefined) { author!.icon_url = authorAttachment.toJSON().url; author!.proxy_icon_url = authorAttachment.toJSON().proxy_url; } } message.attachments = message.attachments?.filter((_, index) => !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 getDatabase()?.transaction(async (entityManager) => { await entityManager.save(message); await entityManager.save(message.channel); if (message.attachments && message.attachments.length > 0) await entityManager.save(message.attachments); }); await Promise.all([ 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; } type MessageOptionAttachment = MessageCreateAttachment | MessageCreateCloudAttachment | Attachment; 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; } // Makes for concise code, inspired by Nix' lib.trace function logPassthru(obj: T, ...data: unknown[]) { console.log(...data); return obj; } export async function processMessageOptionAttachments(source: MessageOptions, destination: Message) { if (!source.attachments || source.attachments.length == 0) return; const logp = `[Message/${destination.id}/Attachments]`; console.log("[Message] Processing attachments for message", source.id, "->", source.attachments); const tasks = source.attachments?.map(async (src): Promise => { if (src instanceof Attachment) return logPassthru(src, logp, `Got Attachment instance`); if (isCloudAttachment(src)) return logPassthru(await convertCloudAttachmentToAttachment(src, destination.channel_id!, destination.id), logp, "Got MessageCreateCloudAttachment contents"); throw new Error(logp + " Unhandled attachment: " + JSON.stringify(src)); }); destination.attachments = []; for (const task of tasks) { destination.attachments.push(await task); } } export function isCloudAttachment(attachment: MessageOptionAttachment) { return "uploaded_filename" in attachment; } export async function convertCloudAttachmentToAttachment(cAtt: MessageCreateCloudAttachment, destinationChannelId: string, destinationMessageId: string) { const attEnt = await CloudAttachment.findOneOrFail({ where: { uploadFilename: cAtt.uploaded_filename, }, }); const cloneResponse = await fetch(`${Config.get().cdn.endpointPrivate}/attachments/${attEnt.uploadFilename}/clone_to_message/${destinationMessageId}`, { method: "POST", headers: { signature: Config.get().security.requestSignature || "", }, }); if (!cloneResponse.ok) { console.error(`[Message] Failed to clone attachment ${attEnt.userFilename} to message ${destinationMessageId}`); 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, size: attEnt.size, height: attEnt.height, width: attEnt.width, content_type: attEnt.contentType || attEnt.userOriginalContentType, channel_id: destinationChannelId, message_id: destinationMessageId, }); console.log("[Message] Converted cloud attachment to", realAtt); return realAtt; }