Newer
Older
percord / src / api / util / handlers / Message.ts
/*
	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 <https://www.gnu.org/licenses/>.
*/

import { fillMessageUrlEmbeds, randomString } from "@spacebar/api";
import {
    Application,
    arrayDistributeSequentially,
    arrayPartition,
    Attachment,
    Channel,
    CloudAttachment,
    Config,
    DiscordApiErrors,
    emitEvent,
    EVERYONE_MENTION,
    FieldErrors,
    getDatabase,
    getPermission,
    getRights,
    Guild,
    handleFile,
    HERE_MENTION,
    mathLogBase,
    Member,
    Message,
    MessageCreateEvent,
    MessageFlags,
    Permissions,
    ReadState,
    Role,
    ROLE_MENTION,
    Session,
    Snowflake,
    Sticker,
    Stopwatch,
    TraceNode,
    TraceRoot,
    TraceSubTree,
    User,
    USER_MENTION,
    Webhook,
} from "@spacebar/util";
import { HTTPError } from "lambert-server";
import { Equal, In, Or } from "typeorm";
import {
    ActionRowComponent,
    BaseMessageComponents,
    ButtonStyle,
    ChannelType,
    Embed,
    EmbedType,
    MessageComponentType,
    MessageCreateAttachment,
    MessageCreateCloudAttachment,
    MessageCreateSchema,
    MessageReferenceType,
    MessageType,
    Reaction,
    ReadStateType,
    UnfurledMediaItem,
    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<string, { code?: string; message: string }>, 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<string, { code?: string; message: string }> = {};
    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<Message> {
    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);
    }

    message.content = opts.content?.trim();

    await handleMessageMentionsAsync(message);

    const attachmentIndices = new Map(message.attachments?.map((attachment, index) => [`attachment://${attachment.filename}`, index]));
    const attachmentsToRemove = new Set<number>();
    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 (message.isWebhook || (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<T>(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<Attachment> => {
        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;
}

async function handleMessageMentionsAsync(message: Message) {
    const sw = Stopwatch.startNew(),
        totalSw = Stopwatch.startNew();
    const trace: TraceNode = { micros: 0, calls: [] };
    const traceRoot: TraceRoot = ["handleMessageMentionsAsync", trace];

    const channel = await Channel.findOneOrFail({
        where: { id: message.channel_id },
        relations: { recipients: true },
    });
    trace.calls.push(`getChannel(${channel.id})`, { micros: sw.getElapsedAndReset().totalMicroseconds });

    const permissionTargetId = message.isWebhook ? message.webhook?.application_id : (message.author_id ?? message.author?.id);
    const permission =
        permissionTargetId != null
            ? await getPermission(permissionTargetId, channel.guild_id, channel)
            : message.guild_id != null
              ? new Permissions((await Role.findOneOrFail({ where: { id: message.guild_id ?? message.guild?.id } })).permissions)
              : Permissions.DEFAULT_DM_PERMISSIONS;
    trace.calls.push(`getPermissions`, { micros: sw.getElapsedAndReset().totalMicroseconds });

    let content = message.content;

    // TODO: sets
    // 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[];
    let mention_everyone = false;
    let mention_here = false;
    const mention_user_id_set = new Set<string>();
    const mention_role_id_set = new Set<string>();

    if (content) {
        const contentSw = Stopwatch.startNew();
        const contentTrace: TraceNode = { micros: 0, calls: [] };
        // TODO: explicit-only mentions
        // TODO: make mentions lazy
        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);
		}*/
        contentTrace.calls.push("filterCodeblocks", { micros: sw.getElapsedAndReset().totalMicroseconds });

        for (const [, mention] of content.matchAll(USER_MENTION)) mention_user_id_set.add(mention);
        for (const [, mention] of content.matchAll(ROLE_MENTION)) mention_role_id_set.add(mention);
        if (message.webhook?.id || message.webhook_id || permission?.has("MENTION_EVERYONE") || channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM) {
            mention_everyone = !!content.match(EVERYONE_MENTION);
            mention_here = !!content.match(HERE_MENTION);
        }
        contentTrace.calls.push("parseMentions", { micros: sw.getElapsedAndReset().totalMicroseconds });

        let mentionedRoles = await Role.find({ where: { id: In(mention_role_id_set.values().toArray()), guild_id: channel.guild_id } });
        contentTrace.calls.push("queryMentionRoles", { micros: sw.getElapsedAndReset().totalMicroseconds });

        //TODO: should this throw at all?
        if (mention_role_id_set.size != mentionedRoles.length) {
            const missingRoles = mention_role_id_set
                .values()
                .filter((x) => !mentionedRoles.find((r) => r.id == x))
                .toArray();
            throw new HTTPError("Mentioned invalid roles: " + missingRoles.join(", "), 500);
        }

        if (!(message.webhook?.id || message.webhook_id || permission?.has("MANAGE_ROLES"))) {
            mentionedRoles = mentionedRoles.filter((x) => x.mentionable);
            mention_role_id_set.clear();
            mentionedRoles.forEach((r) => mention_role_id_set.add(r.id));
        }

        contentTrace.calls.push("validateMentionRoles", { micros: sw.getElapsedAndReset().totalMicroseconds });
        contentTrace.micros = contentSw.elapsed().totalMicroseconds;
        trace.calls.push("parseContent", contentTrace);
    }

    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(),
            );
        }

        if (message.message_reference.type === MessageReferenceType.FORWARD) {
            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()];
            }
        }
        trace.calls.push("handleMessageReference", { micros: sw.getElapsedAndReset().totalMicroseconds });
    }

    // 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 Role.find({ where: { id: In(mention_role_id_set.values().toArray()), guild_id: channel.guild_id } });
    message.mentions = [...message.mentions, ...(await User.find({ where: { id: In(mention_user_id_set.values().toArray()) } }))];
    message.mention_everyone = mention_everyone;
    trace.calls.push("fillMessageMentionProperties", { micros: sw.getElapsedAndReset().totalMicroseconds });

    const fillInMissingIDs = async (ids: string[], trace?: TraceSubTree) => {
        const fillMessageSw = Stopwatch.startNew(),
            subSw = Stopwatch.startNew();
        const subTrace: TraceSubTree = { micros: 0, calls: [] };
        try {
            const states = await ReadState.find({
                where: {
                    user_id: In(ids),
                    channel_id: channel.id,
                    read_state_type: ReadStateType.CHANNEL,
                },
                select: { user_id: true },
            });
            subTrace.calls.push("findReadStates", { micros: subSw.getElapsedAndReset().totalMicroseconds });

            const users = new Set(ids);
            states.forEach((state) => users.delete(state.user_id));
            subTrace.calls.push("collectMissingIds", { micros: subSw.getElapsedAndReset().totalMicroseconds });

            if (!users.size) {
                subTrace.calls.push("--noop--", { micros: subSw.getElapsedAndReset().totalMicroseconds });
                return;
            }

            const newReadStateSeqs = arrayDistributeSequentially(users.values().toArray(), Math.max(1, mathLogBase(users.size, 2))).map((seq) =>
                seq.map((user_id) => ({ id: Snowflake.generate(), user_id, channel_id: channel.id, read_state_type: ReadStateType.CHANNEL })),
            );
            subTrace.calls.push(`constructNewReadStatesChunked(${newReadStateSeqs.length})`, { micros: subSw.getElapsedAndReset().totalMicroseconds });

            await Promise.all(
                newReadStateSeqs.map((seq) =>
                    // just a safety thing... handle postgres hard limit at 65535 parameters, at 4 params per object... 16384
                    seq.length > 15000
                        ? fillInMissingIDs(
                              seq.map((rs) => rs.user_id),
                              subTrace,
                          )
                        : ReadState.insert(seq).catch((e) => {
                              console.log("Failed to bulk insert", seq.length, "new ReadStates, trying again (race condition/too many params?)...\nDetails:", e);
                              return fillInMissingIDs(
                                  seq.map((rs) => rs.user_id),
                                  subTrace,
                              );
                          }),
                ),
            );
            subTrace.calls.push("insertNewReadStatesChunked", { micros: subSw.getElapsedAndReset().totalMicroseconds });
        } finally {
            trace?.calls.push(`fillInMissingIDs(${ids.length})`, { micros: fillMessageSw.getElapsedAndReset().totalMicroseconds, calls: subTrace.calls });
        }
    };

    if ((message.flags & (1 << 6)) !== 0) {
        // ephemeral messages
        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);
            // TODO: can we somehow rewrite this into an In(...) query?
            if (!pinged) pinged = !!(await Member.find({ where: { id, roles: Or(...message.mention_roles.map(({ id }) => Equal(id))) } }));
            if (pinged) {
                //stuff
            }
        }
        trace.calls.push("ephemeralPinged", { micros: sw.getElapsedAndReset().totalMicroseconds });
    } else if (mention_everyone) {
        if (channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM) {
            if (channel.recipients) {
                await fillInMissingIDs(
                    channel.recipients.map((r) => r.user_id),
                    trace,
                );
            }
        } else {
            await fillInMissingIDs(
                (await Member.find({ where: { guild_id: channel.guild_id }, select: { id: true } })).map((m) => m.id),
                trace,
            );
        }
        const repository = ReadState.getRepository();
        await repository.increment({ channel_id: channel.id, read_state_type: ReadStateType.CHANNEL }, "mention_count", 1);
        trace.calls.push("mentionEveryone", { micros: sw.getElapsedAndReset().totalMicroseconds });
    } else {
        const users = new Set<string>([
            ...(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),
        ]);
        trace.calls.push("getUsers", { micros: sw.getElapsedAndReset().totalMicroseconds });

        if (mention_here) {
            // TODO: incorporate sessions
            const ids = (await Member.find({ where: { guild_id: channel.guild_id } })).map((m) => m.id);
            (await Session.find({ where: { user_id: In(ids) } })).forEach((s) => users.add(s.user_id));
            trace.calls.push("mentionHere", { micros: sw.getElapsedAndReset().totalMicroseconds });
        }

        if (users.size) {
            const repository = ReadState.getRepository();

            await fillInMissingIDs([...users], trace);
            await repository.increment({ user_id: In(users.values().toArray()), channel_id: channel.id, read_state_type: ReadStateType.CHANNEL }, "mention_count", 1);
            trace.calls.push("updateMentionedUserReadStates", { micros: sw.getElapsedAndReset().totalMicroseconds });
        }
    }

    trace.micros = totalSw.elapsed().totalMicroseconds;
    if (process.env.LOG_MENTION_TRACE === "true") new console.Console({ stdout: process.stdout, inspectOptions: { depth: 20 } }).log("Mention handling trace:", trace);
}