summary refs log tree commit diff
path: root/src/database/entities/Message.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/database/entities/Message.ts')
-rw-r--r--src/database/entities/Message.ts486
1 files changed, 486 insertions, 0 deletions
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); +};