Newer
Older
percord / src / util / entities / 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 { User } from "./User";
import { Member } from "./Member";
import { Role } from "./Role";
import { Channel } from "./Channel";
import { InteractionType } from "../interfaces";
import { Application } from "./Application";
import { Column, CreateDateColumn, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, RelationId, FindOneOptions, Raw, Not, BaseEntity, In } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { Webhook } from "./Webhook";
import { Sticker } from "./Sticker";
import { Attachment } from "./Attachment";
import { NewUrlUserSignatureData } from "../Signing";
import {
    ApplicationCommandType,
    BaseMessageComponents,
    Embed,
    MessageComponentType,
    MessageSnapshot,
    MessageType,
    PartialMessage,
    Poll,
    PublicMessage,
    Reaction,
    UnfurledMediaItem,
    PartialUser,
} from "@spacebar/schemas";
import { MessageFlags } from "@spacebar/util";
import { JsonRemoveEmpty } from "../util/Decorators";

@Entity({
    name: "messages",
})
@Index(["channel_id", "id"], { unique: true })
export class Message extends BaseClass {
    @Column({ nullable: true })
    @RelationId((message: Message) => message.channel)
    @Index()
    channel_id?: string;

    @JoinColumn({ name: "channel_id" })
    @ManyToOne(() => Channel, {
        onDelete: "CASCADE",
    })
    channel: Channel;

    @Column({ nullable: true })
    @RelationId((message: Message) => message.thread)
    @JsonRemoveEmpty
    thread_id?: string;

    @JoinColumn({ name: "thread_id" })
    @ManyToOne(() => Channel, {
        onDelete: "CASCADE",
    })
    @JsonRemoveEmpty
    thread?: Channel;

    @Column({ nullable: true })
    @RelationId((message: Message) => message.guild)
    @JsonRemoveEmpty
    guild_id?: string;

    @JoinColumn({ name: "guild_id" })
    @ManyToOne(() => Guild, {
        onDelete: "CASCADE",
    })
    guild?: Guild;

    @Column({ nullable: true })
    @RelationId((message: Message) => message.author)
    @Index()
    author_id?: string;

    @JoinColumn({ name: "author_id", referencedColumnName: "id" })
    @ManyToOne(() => User, {
        onDelete: "CASCADE",
    })
    author?: User;

    @Column({ nullable: true })
    @RelationId((message: Message) => message.member)
    member_id?: string;

    @JoinColumn({ name: "member_id", referencedColumnName: "id" })
    @ManyToOne(() => User, {
        onDelete: "CASCADE",
    })
    member?: Member;

    @Column({ nullable: true })
    @RelationId((message: Message) => message.webhook)
    @JsonRemoveEmpty
    webhook_id?: string;

    @JoinColumn({ name: "webhook_id" })
    @ManyToOne(() => Webhook)
    webhook?: Webhook;

    @Column({ nullable: true })
    @RelationId((message: Message) => message.application)
    application_id?: string;

    @JoinColumn({ name: "application_id" })
    @ManyToOne(() => Application)
    application?: Application;

    @Column({ nullable: true })
    content?: string;

    @Column()
    @CreateDateColumn()
    timestamp: Date;

    @Column({ nullable: true })
    edited_timestamp?: Date;

    @Column({ nullable: true })
    tts?: boolean;

    @Column({ nullable: true })
    mention_everyone?: boolean;

    @JoinTable({ name: "message_user_mentions" })
    @ManyToMany(() => User)
    mentions: User[];

    @JoinTable({ name: "message_role_mentions" })
    @ManyToMany(() => Role)
    mention_roles: Role[];

    @JoinTable({ name: "message_channel_mentions" })
    @ManyToMany(() => Channel)
    @JsonRemoveEmpty
    mention_channels: Channel[];

    @JoinTable({ name: "message_stickers" })
    @ManyToMany(() => Sticker, { cascade: true, onDelete: "CASCADE" })
    @JsonRemoveEmpty
    sticker_items?: Sticker[];

    @OneToMany(() => Attachment, (attachment: Attachment) => attachment.message, {
        cascade: true,
        orphanedRowAction: "delete",
    })
    attachments?: Attachment[];

    @Column({ type: "jsonb" })
    embeds: Embed[];

    @Column({ type: "jsonb" })
    @JsonRemoveEmpty
    reactions: Reaction[];

    @Column({ type: "text", nullable: true })
    @JsonRemoveEmpty
    nonce?: string;

    @Column({ nullable: true, type: Date })
    pinned_at?: Date | null;

    get pinned(): boolean {
        return this.pinned_at != null;
    }

    @Column({ type: "int" })
    type: MessageType;

    @Column({ type: "jsonb", nullable: true })
    @JsonRemoveEmpty
    activity?: {
        type: number;
        party_id: string;
    };

    @Column({ default: 0 })
    flags: number;

    @Column({ type: "jsonb", nullable: true })
    @JsonRemoveEmpty
    message_reference?: {
        message_id?: string;
        channel_id?: string;
        guild_id?: string;
        type?: number; // 0 = DEFAULT, 1 = FORWARD
    };

    @JoinColumn({ name: "message_reference_id" })
    @ManyToOne(() => Message, { onDelete: "SET NULL" })
    referenced_message?: Message | null;

    @Column({ type: "jsonb", nullable: true })
    @JsonRemoveEmpty
    interaction?: {
        id: string;
        type: InteractionType;
        name: string;
    };

    @Column({ type: "jsonb", nullable: true })
    @JsonRemoveEmpty
    interaction_metadata?: {
        id: string;
        type: InteractionType;
        user_id: string;
        authorizing_integration_owners: object;
        name: string;
        command_type: ApplicationCommandType;
    };

    @Column({ type: "jsonb", nullable: true })
    components?: BaseMessageComponents[];

    @Column({ type: "jsonb", nullable: true })
    @JsonRemoveEmpty
    poll?: Poll;

    @Column({ nullable: true })
    username?: string;

    @Column({ nullable: true })
    avatar?: string;

    @Column({ default: "[]", type: "jsonb" })
    message_snapshots: MessageSnapshot[];

    static async fillReplies(messages: Message[]) {
        const ms = messages
            .filter((msg) => msg.message_reference && !msg.referenced_message?.id && msg.message_reference.message_id)
            .filter((msg) => [MessageType.REPLY, MessageType.THREAD_STARTER_MESSAGE, MessageType.CONTEXT_MENU_COMMAND].includes(msg.type));
        if (!ms.length) return;
        const curMs = new Map(messages.map((m) => [m.id, m] as const));
        const neededIds = new Set(ms.map((m) => m.message_reference!.message_id as string)).difference(curMs);
        if (neededIds.size) {
            const newMessages = await Message.find({
                where: {
                    id: In([...neededIds]),
                },
                relations: { author: true, mentions: true, mention_roles: true, mention_channels: true },
            });
            newMessages.forEach((msg) => curMs.set(msg.id, msg));
        }
        for (const message of ms) {
            message.referenced_message = curMs.get(message.message_reference!.message_id as string) || null;
        }
    }

    toJSON(shallow = false): PublicMessage {
        // this.clean_data();
        return {
            ...this,
            channel_id: this.channel_id ?? this.channel.id,
            channel: undefined,

            timestamp: this.timestamp.toISOString(),
            edited_timestamp: this.edited_timestamp ? this.edited_timestamp.toISOString() : null,

            author_id: undefined,
            member_id: undefined,
            webhook_id: this.webhook_id ?? undefined,
            application_id: undefined,
            mentions: this.mentions?.map((user) => {
                if (user && !user.toPublicUser) console.trace("toPublic user missing!!!");
                return (user?.toPublicUser?.() ?? user ?? undefined) as unknown as PartialUser;
            }),

            mention_roles: this.mention_roles?.map((role) => role.id) ?? [],
            mention_channels: this.mention_channels?.map((ch) => ch.toJSON()) ?? [],
            attachments: this.attachments?.map((att) => att.toJSON()) ?? [],

            nonce: this.nonce ?? undefined,
            tts: this.tts ?? false,
            guild: this.guild ?? undefined,
            webhook: this.webhook ?? undefined,
            interaction: this.interaction ?? undefined,
            interaction_metadata: this.interaction_metadata ?? undefined,
            reactions: this.reactions ?? undefined,
            sticker_items: this.sticker_items ?? undefined,
            message_reference: this.message_reference ?? undefined,
            mention_everyone: this.mention_everyone ?? false,
            author: {
                ...(this.author?.toPublicUser() ?? undefined),
                // Webhooks
                username: this.username ?? this.author?.username ?? null,
                avatar: this.avatar ?? this.author?.avatar ?? null,
            },
            activity: this.activity ?? undefined,
            application: this.application ?? undefined,
            components: this.components ?? [],
            poll: this.poll ?? undefined,
            content: this.content ?? "",
            pinned: this.pinned,
            thread: this.thread ? this.thread.toJSON() : this.thread,
            referenced_message: this.referenced_message && !shallow ? this.referenced_message.toJSON(true) : undefined,
        };
    }

    toPartialMessage(): PartialMessage {
        return {
            id: this.id,
            // lobby_id: this.lobby_id,
            channel_id: this.channel_id!,
            type: this.type,
            content: this.content!,
            author: { ...this.author!, avatar: this.author?.avatar ?? null },
            flags: this.flags,
            application_id: this.application_id,
            //channel: this.channel, // TODO: ephemeral DM channels
            // recipient_id: this.recipient_id, // TODO: ephemeral DM channels
        };
    }

    toSnapshot(): MessageSnapshot {
        return {
            message: {
                attachments: this.attachments?.map((x) => x.toJSON()),
                components: this.components,
                content: this.content!,
                edited_timestamp: this.edited_timestamp,
                embeds: this.embeds,
                flags: this.flags,
                mention_roles: this.mention_roles?.map((x) => x.id),
                mentions: this.mentions.map((x) => x.toPublicUser() as unknown as PartialUser), // TODO: write a proper method for this
                timestamp: this.timestamp,
                type: this.type,
            },
        };
    }

    withSignedAttachments(data: NewUrlUserSignatureData) {
        function signMedia(media: UnfurledMediaItem) {
            Object.assign(media, Attachment.prototype.signUrls.call(media, data));
        }
        return {
            ...this,
            attachments: this.attachments?.map((attachment: Attachment) => Attachment.prototype.signUrls.call(attachment, data)),
            components: this.components
                ? this.components.map((comp) => {
                      comp = structuredClone(comp);
                      if (comp.type === MessageComponentType.Section) {
                          const accessory = comp.accessory;
                          if (accessory.type === MessageComponentType.Thumbnail) {
                              signMedia(accessory.media);
                          }
                      } else if (comp.type === MessageComponentType.MediaGallery) {
                          comp.items.forEach(({ media }) => signMedia(media));
                      } else if (comp.type === MessageComponentType.File) {
                          signMedia(comp.file);
                      } else if (comp.type === MessageComponentType.Container) {
                          for (const elm of comp.components) {
                              switch (elm.type) {
                                  case MessageComponentType.Separator:
                                  case MessageComponentType.TextDisplay:
                                  case MessageComponentType.ActionRow:
                                      break;
                                  case MessageComponentType.Section: {
                                      const accessory = elm.accessory;
                                      if (accessory.type === MessageComponentType.Thumbnail) {
                                          signMedia(accessory.media);
                                      }
                                      break;
                                  }
                                  case MessageComponentType.MediaGallery:
                                      elm.items.forEach(({ media }) => signMedia(media));
                                      break;
                                  case MessageComponentType.File: {
                                      signMedia(elm.file);
                                      break;
                                  }

                                  default:
                                      elm satisfies never;
                              }
                          }
                      }
                      return comp;
                  })
                : this.components,
        };
    }

    static async createWithDefaults(opts: Partial<Message>): Promise<Message> {
        const message = Message.create();

        if (!opts.author) {
            if (!opts.author_id) throw new Error("Either author or author_id must be provided to create a Message");
            opts.author = await User.findOneOrFail({ where: { id: opts.author_id! } });
        }

        if (!opts.channel) {
            if (!opts.channel_id) throw new Error("Either channel or channel_id must be provided to create a Message");
            opts.channel = await Channel.findOneOrFail({ where: { id: opts.channel_id! } });
            opts.guild_id ??= opts.channel.guild_id;
        }

        if (!opts.member_id) opts.member_id = message.author_id;
        if (!opts.member) opts.member = await Member.findOneOrFail({ where: { id: opts.member_id! } });

        if (!opts.guild) {
            if (opts.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.guild_id! } });
            else if (opts.channel?.guild?.id) opts.guild = opts.channel.guild;
            else if (opts.channel?.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.channel.guild_id! } });
            else if (opts.member?.guild?.id) opts.guild = opts.member.guild;
            else if (opts.member?.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.member.guild_id! } });
            else throw new Error("Either guild, guild_id, channel.guild, channel.guild_id, member.guild or member.guild_id must be provided to create a Message");
        }

        // try 2 now that we have a guild
        if (!opts.member) opts.member = await Member.findOneOrFail({ where: { id: opts.author!.id, guild_id: opts.guild!.id } });

        // set reply type if a message if referenced
        if (opts.message_reference && !opts.type) message.type = MessageType.REPLY;

        // backpropagate ids
        opts.channel_id = opts.channel.id;
        opts.guild_id = opts.guild.id;
        opts.author_id = opts.author.id;
        opts.member_id = opts.member.id;
        opts.webhook_id = opts.webhook?.id;
        opts.application_id = opts.application?.id;

        delete opts.member;

        Object.assign(message, {
            tts: false,
            embeds: [],
            reactions: [],
            flags: 0,
            type: 0,
            timestamp: new Date(),
            ...opts,
        });
        return message;
    }
    static addDefault(options: FindOneOptions<Message>) {
        if (options.where) {
            const arr = options.where instanceof Array ? options.where : [options.where];
            for (const thing of arr) {
                if (!("flags" in thing)) {
                    thing.flags = Not(Raw((alias) => `${alias} & ${MessageFlags.FLAGS.EPHEMERAL} = ${MessageFlags.FLAGS.EPHEMERAL}`));
                }
            }
        }
    }
}

//@ts-expect-error It works but TS types hate it
Message.findOneOrFail = function (this: Message, options: FindOneOptions<Message>): Promise<Message> {
    Message.addDefault(options as FindOneOptions<Message>);
    //@ts-expect-error how to use generics on call, who knows!
    return BaseEntity.findOneOrFail.call(Message, options);
};
//@ts-expect-error It works but TS types hate it
Message.findOne = function (this: Message, options: FindOneOptions<Message>): Promise<Message> {
    Message.addDefault(options as FindOneOptions<Message>);
    //@ts-expect-error how to use generics on call, who knows!
    return BaseEntity.findOne.call(Message, options);
};
//@ts-expect-error It works but TS types hate it
Message.find = function (this: Message, options: FindOneOptions<Message>): Promise<Message[]> {
    Message.addDefault(options as FindOneOptions<Message>);
    //@ts-expect-error how to use generics on call, who knows!
    return BaseEntity.find.call(Message, options);
};