diff --git a/src/database/entities/Message.ts b/src/database/entities/Message.ts
new file mode 100644
index 00000000..31e6a0e4
--- /dev/null
+++ b/src/database/entities/Message.ts
@@ -0,0 +1,486 @@
+/*
+ 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 { 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 "../../util/Signing";
+import {
+ ApplicationCommandType,
+ BaseMessageComponents,
+ Embed,
+ MessageComponentType,
+ MessageSnapshot,
+ MessageType,
+ PartialMessage,
+ Poll,
+ PublicMessage,
+ Reaction,
+ UnfurledMediaItem,
+ PartialUser,
+ InteractionType,
+} from "@spacebar/schemas";
+import { MessageFlags } from "@spacebar/util";
+import { JsonRemoveEmpty } from "../../util/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[];
+
+ get isWebhook() {
+ return this.webhook_id != null && this.webhook != null;
+ }
+
+ 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);
+};
|