diff --git a/src/database/entities/Application.ts b/src/database/entities/Application.ts
new file mode 100644
index 00000000..c4f40343
--- /dev/null
+++ b/src/database/entities/Application.ts
@@ -0,0 +1,138 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, OneToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Team } from "./Team";
+import { User } from "./User";
+import { Guild } from "./Guild";
+
+@Entity({
+ name: "applications",
+})
+export class Application extends BaseClass {
+ @Column()
+ name: string;
+
+ @Column({ nullable: true })
+ icon?: string;
+
+ @Column({ nullable: true })
+ description: string;
+
+ @Column({ nullable: true })
+ summary: string = "";
+
+ @Column({ type: "jsonb", nullable: true })
+ type?: object; // TODO: this type is bad
+
+ @Column()
+ hook: boolean = true;
+
+ @Column()
+ bot_public?: boolean = true;
+
+ @Column()
+ bot_require_code_grant?: boolean = false;
+
+ @Column()
+ verify_key: string;
+
+ @JoinColumn({ name: "owner_id" })
+ @ManyToOne(() => User, { onDelete: "CASCADE" })
+ owner: User;
+
+ // TODO: enum this? https://discord.com/developers/docs/resources/application#application-object-application-flags
+ @Column()
+ flags: number = 0;
+
+ @Column({ type: "varchar", nullable: true })
+ redirect_uris: string[] = [];
+
+ @Column({ nullable: true })
+ rpc_application_state: number = 0;
+
+ @Column({ nullable: true })
+ store_application_state: number = 1;
+
+ @Column({ nullable: true })
+ verification_state: number = 1;
+
+ @Column({ nullable: true })
+ interactions_endpoint_url?: string;
+
+ @Column({ nullable: true })
+ integration_public: boolean = true;
+
+ @Column({ nullable: true })
+ integration_require_code_grant: boolean = false;
+
+ @Column({ nullable: true })
+ discoverability_state: number = 1;
+
+ @Column({ nullable: true })
+ discovery_eligibility_flags: number = 2240;
+
+ @JoinColumn({ name: "bot_user_id" })
+ @OneToOne(() => User, { onDelete: "CASCADE" })
+ bot?: User;
+
+ @Column({ type: "varchar", array: true, nullable: true })
+ tags?: string[];
+
+ @Column({ nullable: true })
+ cover_image?: string; // the application's default rich presence invite cover image hash
+
+ @Column({ type: "jsonb", nullable: true })
+ install_params?: { scopes: string[]; permissions: string };
+
+ @Column({ nullable: true })
+ terms_of_service_url?: string;
+
+ @Column({ nullable: true })
+ privacy_policy_url?: string;
+
+ @Column({ nullable: true })
+ @RelationId((application: Application) => application.guild)
+ guild_id?: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild)
+ guild?: Guild; // guild to which the app is linked, e.g. a developer support server
+
+ @Column({ nullable: true })
+ custom_install_url?: string;
+
+ //just for us
+
+ //@Column({ type: "varchar", array: true, nullable: true })
+ //rpc_origins?: string[];
+
+ //@Column({ nullable: true })
+ //primary_sku_id?: string; // if this application is a game sold, this field will be the id of the "Game SKU" that is created,
+
+ //@Column({ nullable: true })
+ //slug?: string; // if this application is a game sold, this field will be the URL slug that links to the store page
+
+ @JoinColumn({ name: "team_id" })
+ @ManyToOne(() => Team, {
+ onDelete: "CASCADE",
+ nullable: true,
+ })
+ team?: Team;
+}
diff --git a/src/database/entities/ApplicationCommand.ts b/src/database/entities/ApplicationCommand.ts
new file mode 100644
index 00000000..892bb833
--- /dev/null
+++ b/src/database/entities/ApplicationCommand.ts
@@ -0,0 +1,88 @@
+/*
+ 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 { Column, Entity } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import {
+ ApplicationCommandHandlerType,
+ ApplicationCommandOption,
+ ApplicationCommandIndexPermissions,
+ ApplicationCommandType,
+ Snowflake,
+ ApplicationIntegrationType,
+ InteractionContextType,
+} from "@spacebar/schemas";
+
+@Entity({
+ name: "application_commands",
+})
+export class ApplicationCommand extends BaseClass {
+ @Column({ default: ApplicationCommandType.CHAT_INPUT })
+ type?: ApplicationCommandType;
+
+ @Column()
+ application_id: Snowflake;
+
+ @Column({ nullable: true })
+ guild_id?: Snowflake;
+
+ @Column()
+ name: string;
+
+ @Column({ nullable: true, type: "jsonb" })
+ name_localizations?: Record<string, string>;
+
+ @Column()
+ description: string;
+
+ @Column({ nullable: true, type: "jsonb" })
+ description_localizations?: Record<string, string>;
+
+ @Column({ type: "jsonb", default: [] })
+ options: ApplicationCommandOption[];
+
+ @Column({ nullable: true, type: String })
+ default_member_permissions: string | null;
+
+ /*
+ * @deprecated
+ */
+ @Column({ default: true })
+ dm_permission?: boolean;
+
+ @Column({ nullable: true, type: "jsonb" })
+ permissions?: ApplicationCommandIndexPermissions;
+
+ @Column({ default: false })
+ nsfw?: boolean;
+
+ @Column({ nullable: true, type: "jsonb" })
+ integration_types?: ApplicationIntegrationType[];
+
+ @Column({ default: 0 })
+ global_popularity_rank?: number;
+
+ @Column({ nullable: true, type: "jsonb" })
+ contexts?: InteractionContextType[];
+
+ @Column({ default: 0 })
+ version: Snowflake;
+
+ @Column({ default: 0 })
+ handler?: ApplicationCommandHandlerType;
+}
diff --git a/src/database/entities/Attachment.ts b/src/database/entities/Attachment.ts
new file mode 100644
index 00000000..b9756bf7
--- /dev/null
+++ b/src/database/entities/Attachment.ts
@@ -0,0 +1,92 @@
+/*
+ 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 { BeforeRemove, Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { Config, deleteFile } from "../../util/util";
+import { BaseClass } from "./BaseClass";
+import { getUrlSignature, NewUrlUserSignatureData, NewUrlSignatureData } from "../../util/Signing";
+import { PublicAttachment } from "../../schemas/api/messages/Attachments";
+
+@Entity({
+ name: "attachments",
+})
+export class Attachment extends BaseClass {
+ @Column()
+ filename: string; // name of file attached
+
+ @Column()
+ size: number; // size of file in bytes
+
+ @Column({ nullable: true })
+ height?: number; // height of file (if image)
+
+ @Column({ nullable: true })
+ width?: number; // width of file (if image)
+
+ @Column({ nullable: true })
+ content_type?: string;
+
+ @Column({ nullable: true })
+ @RelationId((attachment: Attachment) => attachment.message)
+ message_id: string;
+
+ @Column({ nullable: true })
+ @RelationId((attachment: Attachment) => attachment.channel)
+ channel_id: string;
+
+ @JoinColumn({ name: "message_id" })
+ @ManyToOne(() => require("./Message").Message, (message: import("./Message").Message) => message.attachments, {
+ onDelete: "CASCADE",
+ })
+ message: import("./Message").Message;
+
+ @JoinColumn({ name: "channel_id" })
+ @ManyToOne(() => require("./Channel").Channel, {
+ onDelete: "CASCADE",
+ })
+ channel: import("./Channel").Channel;
+
+ @BeforeRemove()
+ onDelete() {
+ return deleteFile(new URL(this.toJSON().url).pathname);
+ }
+
+ toJSON() {
+ const channelId = this.channel_id ?? this.channel?.id ?? this.message?.channel_id;
+ const messageId = this.message_id ?? this.message?.id;
+ return {
+ ...this,
+ url: `${Config.get().cdn.endpointPublic}/attachments/${channelId}/${messageId}/${this.filename}`,
+ proxy_url: `${Config.get().cdn.endpointPublic}/attachments/${channelId}/${messageId}/${this.filename}`,
+ };
+ }
+ signUrls(data: NewUrlUserSignatureData): PublicAttachment {
+ const att = Attachment.prototype.toJSON.apply(this);
+ return {
+ ...att,
+ url: getUrlSignature(new NewUrlSignatureData({ ...data, url: att.url }))
+ .applyToUrl(att.url)
+ .toString(),
+ proxy_url: att.proxy_url
+ ? getUrlSignature(new NewUrlSignatureData({ ...data, url: att.proxy_url }))
+ .applyToUrl(att.proxy_url)
+ .toString()
+ : att.proxy_url,
+ };
+ }
+}
diff --git a/src/database/entities/AuditLog.ts b/src/database/entities/AuditLog.ts
new file mode 100644
index 00000000..f47f6dc7
--- /dev/null
+++ b/src/database/entities/AuditLog.ts
@@ -0,0 +1,61 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+import { AuditLogChange, AuditLogEvents } from "@spacebar/schemas";
+
+@Entity({
+ name: "audit_logs",
+})
+export class AuditLog extends BaseClass {
+ @JoinColumn({ name: "target_id" })
+ @ManyToOne(() => User)
+ target?: User;
+
+ @Column({ nullable: true })
+ @RelationId((auditlog: AuditLog) => auditlog.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, (user: User) => user.id)
+ user: User;
+
+ @Column({ type: "int" })
+ action_type: AuditLogEvents;
+
+ @Column({ type: "jsonb", nullable: true })
+ options?: {
+ delete_member_days?: string;
+ members_removed?: string;
+ channel_id?: string;
+ messaged_id?: string;
+ count?: string;
+ id?: string;
+ type?: string;
+ role_name?: string;
+ };
+
+ @Column()
+ @Column({ type: "jsonb" })
+ changes: AuditLogChange[];
+
+ @Column({ nullable: true })
+ reason?: string;
+}
diff --git a/src/database/entities/AutomodRule.ts b/src/database/entities/AutomodRule.ts
new file mode 100644
index 00000000..97312984
--- /dev/null
+++ b/src/database/entities/AutomodRule.ts
@@ -0,0 +1,67 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2024 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 { BaseClass } from "./BaseClass";
+import { Entity, JoinColumn, ManyToOne, Column } from "typeorm";
+import { User } from "./User";
+import { AutomodAction, AutomodRuleEventType, AutomodRuleTriggerMetadata, AutomodRuleTriggerType } from "@spacebar/schemas";
+
+@Entity({
+ name: "automod_rules",
+})
+export class AutomodRule extends BaseClass {
+ @JoinColumn({ name: "creator_id" })
+ @ManyToOne(() => User, { onDelete: "CASCADE" })
+ creator: User;
+
+ @Column()
+ enabled: boolean;
+
+ @Column()
+ event_type: AutomodRuleEventType;
+
+ @Column({ type: "int8", array: true })
+ exempt_channels: string[];
+
+ @Column({ type: "int8", array: true })
+ exempt_roles: string[];
+
+ @Column()
+ guild_id: string;
+
+ @Column()
+ name: string;
+
+ @Column()
+ position: number;
+
+ @Column()
+ trigger_type: AutomodRuleTriggerType;
+
+ @Column({
+ type: "jsonb",
+ nullable: true,
+ })
+ trigger_metadata?: // this is null for "Block suspected spam content"
+ AutomodRuleTriggerMetadata;
+
+ @Column({
+ type: "jsonb",
+ })
+ actions: AutomodAction[];
+}
diff --git a/src/database/entities/BackupCodes.ts b/src/database/entities/BackupCodes.ts
new file mode 100644
index 00000000..88fe932e
--- /dev/null
+++ b/src/database/entities/BackupCodes.ts
@@ -0,0 +1,56 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+import crypto from "node:crypto";
+import { Config } from "../../util/util";
+
+@Entity({
+ name: "backup_codes",
+})
+export class BackupCode extends BaseClass {
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, { onDelete: "CASCADE" })
+ user: User;
+
+ @Column()
+ code: string;
+
+ @Column()
+ consumed: boolean;
+
+ @Column()
+ expired: boolean;
+}
+
+export function generateMfaBackupCodes(user_id: string) {
+ const backup_codes: BackupCode[] = [];
+ for (let i = 0; i < Config.get().security.mfaBackupCodeCount; i++) {
+ const code = BackupCode.create({
+ user: { id: user_id },
+ code: crypto.randomBytes(4).toString("hex"), // 8 characters
+ consumed: false,
+ expired: false,
+ });
+ backup_codes.push(code);
+ }
+
+ return backup_codes;
+}
diff --git a/src/database/entities/Badge.ts b/src/database/entities/Badge.ts
new file mode 100644
index 00000000..924ef79e
--- /dev/null
+++ b/src/database/entities/Badge.ts
@@ -0,0 +1,37 @@
+/*
+ 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 { Column, Entity } from "typeorm";
+import { BaseClassWithoutId } from "./BaseClass";
+
+@Entity({
+ name: "badges",
+})
+export class Badge extends BaseClassWithoutId {
+ @Column({ primary: true })
+ id: string;
+
+ @Column()
+ description: string;
+
+ @Column()
+ icon: string;
+
+ @Column({ nullable: true })
+ link?: string;
+}
diff --git a/src/database/entities/Ban.ts b/src/database/entities/Ban.ts
new file mode 100644
index 00000000..17121ace
--- /dev/null
+++ b/src/database/entities/Ban.ts
@@ -0,0 +1,61 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { User } from "./User";
+
+@Entity({
+ name: "bans",
+})
+export class Ban extends BaseClass {
+ @Column({ nullable: true })
+ @RelationId((ban: Ban) => ban.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ @Column({ nullable: true })
+ @RelationId((ban: Ban) => ban.guild)
+ guild_id: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, {
+ onDelete: "CASCADE",
+ })
+ guild: Guild;
+
+ @Column({ nullable: true })
+ @RelationId((ban: Ban) => ban.executor)
+ executor_id: string;
+
+ @JoinColumn({ name: "executor_id" })
+ @ManyToOne(() => User)
+ executor: User;
+
+ @Column({ nullable: true })
+ ip?: string;
+
+ @Column({ nullable: true })
+ reason?: string;
+}
diff --git a/src/database/entities/BaseClass.ts b/src/database/entities/BaseClass.ts
new file mode 100644
index 00000000..770c96e8
--- /dev/null
+++ b/src/database/entities/BaseClass.ts
@@ -0,0 +1,125 @@
+/*
+ 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 { BaseEntity, BeforeInsert, BeforeUpdate, FindOptionsWhere, PrimaryColumn } from "typeorm";
+import { getDatabase } from "../Database";
+import { Snowflake } from "../../util/util/Snowflake";
+import { OrmUtils } from "../../util/imports/OrmUtils";
+import { annotationsKey } from "../../util/util/Decorators";
+
+export class BaseClassWithoutId extends BaseEntity {
+ private get construct() {
+ return this.constructor;
+ }
+
+ // stores custom annotations we may stick on the properties
+ [annotationsKey]: { [p: string]: string[] };
+
+ // retrieves the custom annotations as its not super straight forward
+ get_annotations() {
+ return Object.getPrototypeOf(this)[annotationsKey];
+ }
+
+ // Loops through all the keys and compares it to annotations. If the RemoveEmpty is there it sets the value to undefined if null
+ clean_data() {
+ const annotations = this.get_annotations();
+ if (annotations == undefined || annotations.length > 0)
+ //prevent errors if there are no annotations on an object
+ return;
+ for (const key in this) {
+ if (
+ key in this && // This object has this property, should never fail but better to be safe
+ key in annotations && // If this property has an annotation
+ annotations[key].indexOf("JsonRemoveEmpty") > -1 && // if one of the annotations is JsonRemoveEmpty
+ (this[key] == null || // If this property is null
+ (typeof this[key] == "object" && Object.keys(this[key]).length == 0))
+ ) {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ this[key] = undefined; // set to undefined to remove
+ }
+ if (
+ key in this && // This object has this property, should never fail but better to be safe
+ key in annotations && // If this property has an annotation
+ annotations[key].indexOf("JsonNumber") > -1 && // if one of the annotations is JsonRemoveEmpty
+ typeof this[key] == "string" // and its a String
+ ) {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ this[key] = Number(this[key]); // convert string back to number
+ }
+ }
+ return this;
+ }
+
+ private get metadata() {
+ return getDatabase()?.getMetadata(this.construct);
+ }
+
+ assign(props: object) {
+ OrmUtils.mergeDeep(this, props);
+ return this;
+ }
+
+ // TODO: fix eslint
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ toJSON(): any {
+ this.clean_data();
+ return Object.fromEntries(
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ this.metadata!.columns // @ts-ignore
+ .map((x) => [x.propertyName, this[x.propertyName]])
+ .concat(
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ this.metadata.relations.map((x) => [
+ x.propertyName,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ this[x.propertyName],
+ ]),
+ ),
+ );
+ }
+
+ static increment<T extends BaseClass>(conditions: FindOptionsWhere<T>, propertyPath: string, value: number | string) {
+ const repository = this.getRepository();
+ return repository.increment(conditions, propertyPath, value);
+ }
+
+ static decrement<T extends BaseClass>(conditions: FindOptionsWhere<T>, propertyPath: string, value: number | string) {
+ const repository = this.getRepository();
+ return repository.decrement(conditions, propertyPath, value);
+ }
+
+ public async insert(): Promise<this> {
+ await getDatabase()!.getRepository(this.construct).insert(this);
+ return this;
+ }
+}
+
+export class BaseClass extends BaseClassWithoutId {
+ @PrimaryColumn({ type: "int8" })
+ id: string = Snowflake.generate();
+
+ @BeforeUpdate()
+ @BeforeInsert()
+ _do_validate() {
+ if (!this.id) this.id = Snowflake.generate();
+ }
+}
diff --git a/src/database/entities/Categories.ts b/src/database/entities/Categories.ts
new file mode 100644
index 00000000..8c7ca78c
--- /dev/null
+++ b/src/database/entities/Categories.ts
@@ -0,0 +1,57 @@
+/*
+ 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 { Column, Entity, PrimaryColumn } from "typeorm";
+import { BaseClassWithoutId } from "./BaseClass";
+
+// TODO: categories:
+// [{
+// "id": 16,
+// "default": "Anime & Manga",
+// "localizations": {
+// "de": "Anime & Manga",
+// "fr": "Anim\u00e9s et mangas",
+// "ru": "\u0410\u043d\u0438\u043c\u0435 \u0438 \u043c\u0430\u043d\u0433\u0430"
+// }
+// },
+// "is_primary": false/true
+// }]
+// Also populate discord default categories
+
+@Entity({
+ name: "categories",
+})
+export class Categories extends BaseClassWithoutId {
+ // Not using snowflake
+
+ @PrimaryColumn()
+ id: number;
+
+ @Column({ nullable: true })
+ name: string;
+
+ @Column({ type: "jsonb" })
+ localizations: string;
+
+ // Whether to show the category prominently (e.g. in a sidebar) instead of only secondary (e.g. in search results)
+ @Column({ nullable: true })
+ is_primary: boolean;
+
+ @Column({ nullable: true })
+ icon?: string;
+}
diff --git a/src/database/entities/Channel.ts b/src/database/entities/Channel.ts
new file mode 100644
index 00000000..17a2a628
--- /dev/null
+++ b/src/database/entities/Channel.ts
@@ -0,0 +1,736 @@
+/*
+ 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 { HTTPError } from "lambert-server/HTTPError";
+import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
+import { DmChannelDTO } from "../../util/dtos";
+import { ChannelCreateEvent, ChannelRecipientRemoveEvent, ThreadCreateEvent, ThreadMembersUpdateEvent } from "../../util/interfaces";
+import { InvisibleCharacters, Snowflake, emitEvent, getPermission, trimSpecial, Permissions, Config, DiscordApiErrors } from "../../util/util";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { Invite } from "./Invite";
+import { Message } from "./Message";
+import { Tag } from "./Tag";
+import { ReadState } from "./ReadState";
+import { Recipient } from "./Recipient";
+import { User } from "./User";
+import { VoiceState } from "./VoiceState";
+import { Webhook } from "./Webhook";
+import { Member } from "./Member";
+import { ChannelPermissionOverwrite, ChannelType, PublicChannel, PublicUserProjection, ThreadMetadata } from "@spacebar/schemas";
+import { OrmUtils } from "../../util/imports";
+import { ThreadMember } from "./ThreadMember";
+
+@Entity({
+ name: "channels",
+})
+export class Channel extends BaseClass {
+ @Column()
+ created_at: Date;
+
+ @Column({ nullable: true })
+ name?: string;
+
+ @Column({ type: "text", nullable: true })
+ icon?: string | null;
+
+ @Column({ type: "int" })
+ type: ChannelType;
+
+ @OneToMany(() => Recipient, (recipient: Recipient) => recipient.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ recipients?: Recipient[];
+
+ @OneToMany(() => ThreadMember, (member: ThreadMember) => member.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ thread_members?: ThreadMember[];
+
+ @Column({ nullable: true })
+ last_message_id?: string;
+
+ @Column({ nullable: true })
+ @RelationId((channel: Channel) => channel.guild)
+ guild_id?: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, (guild) => guild.channels, {
+ onDelete: "CASCADE",
+ nullable: true,
+ })
+ guild?: Guild;
+
+ @Column({ nullable: true })
+ @RelationId((channel: Channel) => channel.parent)
+ parent_id: string | null;
+
+ @JoinColumn({ name: "parent_id" })
+ @ManyToOne(() => Channel)
+ parent?: Channel;
+
+ // for group DMs and owned custom channel types
+ @Column({ nullable: true })
+ @RelationId((channel: Channel) => channel.owner)
+ owner_id?: string;
+
+ @JoinColumn({ name: "owner_id" })
+ @ManyToOne(() => User)
+ owner: User;
+
+ @Column({ nullable: true, type: "timestamp with time zone" })
+ last_pin_timestamp?: Date | null; // ISO8601
+
+ @Column({ nullable: true })
+ default_auto_archive_duration?: number;
+
+ @Column({ type: "jsonb", nullable: true })
+ permission_overwrites?: ChannelPermissionOverwrite[];
+
+ @Column({ nullable: true })
+ video_quality_mode?: number;
+
+ @Column({ nullable: true })
+ bitrate?: number;
+
+ @Column({ nullable: true })
+ user_limit?: number;
+
+ @Column()
+ nsfw: boolean = false;
+
+ @Column({ nullable: true })
+ rate_limit_per_user?: number;
+
+ @Column({ nullable: true })
+ topic?: string;
+
+ @OneToMany(() => Invite, (invite: Invite) => invite.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ invites?: Invite[];
+
+ @Column({ nullable: true })
+ retention_policy_id?: string;
+
+ @OneToMany(() => Message, (message: Message) => message.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ messages?: Message[];
+
+ @OneToMany(() => VoiceState, (voice_state: VoiceState) => voice_state.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ voice_states?: VoiceState[];
+
+ @OneToMany(() => ReadState, (read_state: ReadState) => read_state.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ read_states?: ReadState[];
+
+ @OneToMany(() => Webhook, (webhook: Webhook) => webhook.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ webhooks?: Webhook[];
+
+ @Column()
+ flags: number = 0;
+
+ @Column({ nullable: true })
+ default_thread_rate_limit_per_user?: number = 0;
+
+ @Column({ type: "jsonb", nullable: true })
+ thread_metadata?: ThreadMetadata;
+
+ @Column({ nullable: true })
+ member_count?: number;
+
+ @Column({ nullable: true })
+ message_count?: number;
+
+ @Column({ nullable: true })
+ total_message_sent?: number;
+
+ @JoinColumn({ name: "available_tags_ids" })
+ @OneToMany(() => Tag, (tag: Tag) => tag.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ available_tags?: Tag[];
+
+ @Column("text", { array: true, nullable: true })
+ applied_tags?: string[];
+
+ @Column("text", { nullable: true })
+ status?: string | null;
+
+ /** Must be calculated Channel.calculatePosition */
+ position: number;
+
+ // TODO: DM channel
+ static async createChannel(
+ channel: Partial<Channel>,
+ user_id: string = "0",
+ opts?: {
+ keepId?: boolean;
+ skipExistsCheck?: boolean;
+ skipPermissionCheck?: boolean;
+ skipEventEmit?: boolean;
+ skipNameChecks?: boolean;
+ },
+ ): Promise<Channel> {
+ if (!opts?.skipPermissionCheck) {
+ // Always check if user has permission first
+ const permissions = await getPermission(user_id, channel.guild_id);
+ permissions.hasThrow("MANAGE_CHANNELS");
+ }
+
+ const guild = await Guild.findOneOrFail({
+ where: { id: channel.guild_id },
+ select: {
+ features: !opts?.skipNameChecks,
+ channel_ordering: true,
+ id: true,
+ },
+ });
+
+ if (!opts?.skipNameChecks) {
+ if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) {
+ for (const character of InvisibleCharacters) if (channel.name.includes(character)) throw new HTTPError("Channel name cannot include invalid characters", 403);
+
+ // Categories skip these checks on discord.com
+ if (
+ (channel.type !== ChannelType.GUILD_CATEGORY && channel.type !== ChannelType.GUILD_STAGE_VOICE && channel.type !== ChannelType.GUILD_VOICE) ||
+ guild.features.includes("IRC_LIKE_CATEGORY_NAMES")
+ ) {
+ if (channel.name.includes(" ")) throw new HTTPError("Channel name cannot include invalid characters", 403);
+
+ if (channel.name.match(/--+/g)) throw new HTTPError("Channel name cannot include multiple adjacent dashes.", 403);
+
+ if (channel.name.charAt(0) === "-" || channel.name.charAt(channel.name.length - 1) === "-")
+ throw new HTTPError("Channel name cannot start/end with dash.", 403);
+ } else channel.name = channel.name.trim(); //category names are trimmed client side on discord.com
+ }
+
+ if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) {
+ if (!channel.name) throw new HTTPError("Channel name cannot be empty.", 403);
+ }
+ }
+
+ switch (channel.type) {
+ // TODO: should threads even be routed through this function instead of createThreadChannel?
+ case ChannelType.GUILD_PUBLIC_THREAD:
+ case ChannelType.GUILD_PRIVATE_THREAD:
+ case ChannelType.GUILD_NEWS_THREAD:
+ case ChannelType.GUILD_TEXT:
+ case ChannelType.GUILD_FORUM:
+ case ChannelType.GUILD_MEDIA:
+ case ChannelType.GUILD_NEWS:
+ case ChannelType.GUILD_VOICE:
+ if (channel.parent_id && !opts?.skipExistsCheck) {
+ const exists = await Channel.findOneOrFail({
+ where: { id: channel.parent_id },
+ });
+ if (!exists) throw new HTTPError("Parent id channel doesn't exist", 400);
+ if (exists.guild_id !== channel.guild_id) throw new HTTPError("The category channel needs to be in the guild");
+ }
+ break;
+ case ChannelType.GUILD_CATEGORY:
+ case ChannelType.UNHANDLED:
+ break;
+ case ChannelType.DM:
+ case ChannelType.GROUP_DM:
+ throw new HTTPError("You can't create a dm channel in a guild");
+ case ChannelType.GUILD_STORE:
+ default:
+ throw new HTTPError("Not yet supported");
+ }
+
+ if (!channel.permission_overwrites) channel.permission_overwrites = [];
+ // TODO: eagerly auto generate position of all guild channels
+
+ const position = (channel.type === ChannelType.UNHANDLED ? 0 : channel.position) || 0;
+
+ channel = {
+ ...channel,
+ ...(!opts?.keepId && { id: Snowflake.generate() }),
+ created_at: new Date(),
+ position,
+ // from #876 (threads): shouldnt these be undefined?
+ // message_count: 0,
+ // member_count: 0,
+ // total_message_sent: 0,
+ };
+
+ // TODO: figure out why the generic is required here
+ const ret = Channel.create<Channel>(channel);
+
+ await Promise.all([
+ ret.save(),
+ !opts?.skipEventEmit
+ ? emitEvent({
+ event: "CHANNEL_CREATE",
+ data: ret.toJSON(),
+ guild_id: channel.guild_id,
+ } satisfies ChannelCreateEvent)
+ : Promise.resolve(),
+ Guild.insertChannelInOrder(guild.id, ret.id, position, guild),
+ ]);
+
+ return ret;
+ }
+ threadOnly() {
+ return this.type === ChannelType.GUILD_FORUM || this.type === ChannelType.GUILD_MEDIA;
+ }
+
+ static async createThreadChannel(
+ channel: Partial<Channel>,
+ metadata: Partial<ThreadMetadata>,
+ user_id: string = "0",
+ opts?: {
+ keepId?: boolean;
+ skipExistsCheck?: boolean;
+ skipParentExistsCheck?: boolean;
+ skipPermissionCheck?: boolean;
+ skipEventEmit?: boolean;
+ skipNameChecks?: boolean;
+ },
+ ): Promise<Channel> {
+ channel = {
+ // set the default type to private
+ type: ChannelType.GUILD_PRIVATE_THREAD,
+ ...channel,
+ ...(!opts?.keepId && { id: Snowflake.generate() }),
+ created_at: new Date(),
+ position: 0, // TODO:
+ message_count: 0,
+ member_count: 1,
+ total_message_sent: 0,
+ };
+
+ const exists = await Channel.findOne({
+ where: {
+ id: channel.id,
+ },
+ });
+
+ const guild = await Guild.findOneOrFail({ where: { id: channel.guild_id } });
+
+ if (!opts?.skipExistsCheck && !guild.features.includes("ALLOW_EXISTING_THREAD_FOR_MESSAGE") && exists) throw DiscordApiErrors.THREAD_ALREADY_CREATED_FOR_THIS_MESSAGE;
+
+ if (!channel.parent_id) throw new HTTPError("Parent id not set", 400);
+ const parent = await Channel.findOneOrFail({ where: { id: channel.parent_id } });
+
+ if (!opts?.skipPermissionCheck) {
+ // Always check if user has permission first
+ const permissions = await getPermission(user_id, parent.guild_id);
+ permissions.hasThrow(channel.type === ChannelType.GUILD_PRIVATE_THREAD ? "CREATE_PRIVATE_THREADS" : "CREATE_PUBLIC_THREADS");
+ }
+
+ channel = {
+ ...channel,
+ permission_overwrites: parent.permission_overwrites,
+ nsfw: parent.nsfw,
+ owner_id: user_id,
+ guild_id: parent.guild_id,
+ thread_metadata: {
+ create_timestamp: new Date().toISOString(),
+ archive_timestamp: new Date().toISOString(),
+ archived: false,
+ auto_archive_duration: 0,
+ invitable: channel.type === ChannelType.GUILD_NEWS_THREAD || channel.type === ChannelType.GUILD_PUBLIC_THREAD ? Config.get().guild.publicThreadsInvitable : false,
+ locked: false,
+ ...metadata,
+ },
+ };
+
+ if (!opts?.skipParentExistsCheck) {
+ if (!parent) throw new HTTPError("Parent channel doesn't exist", 400);
+ if (parent.guild_id !== channel.guild_id) throw new HTTPError("The category channel needs to be in the guild");
+ }
+
+ if (!opts?.skipNameChecks) {
+ const guild = await Guild.findOneOrFail({ where: { id: channel.guild_id } });
+ if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) {
+ for (const character of InvisibleCharacters) if (channel.name.includes(character)) throw new HTTPError("Channel name cannot include invalid characters", 403);
+
+ channel.name = channel.name.trim(); //category names are trimmed client side on discord.com
+ }
+
+ if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) {
+ if (!channel.name) throw new HTTPError("Channel name cannot be empty.", 403);
+ }
+ }
+
+ // TODO: eagerly auto generate position of all guild channels
+
+ const thread = await OrmUtils.mergeDeep(new Channel(), channel).save();
+
+ const member = {
+ id: thread.id,
+ user_id,
+ join_timestamp: new Date(),
+ muted: false,
+ mute_config: null,
+ flags: 0,
+ };
+ if (channel.member_count) channel.member_count++;
+
+ const threadMember = await OrmUtils.mergeDeep(new ThreadMember(), member).save();
+
+ if (!opts?.skipEventEmit) {
+ await Promise.all([
+ emitEvent({
+ event: "THREAD_CREATE",
+ data: {
+ ...thread,
+ newly_created: true,
+ },
+ guild_id: channel.guild_id,
+ } satisfies ThreadCreateEvent),
+ emitEvent({
+ event: "THREAD_MEMBERS_UPDATE",
+ data: {
+ guild_id: channel.guild_id!, // TODO: is this the right fix?
+ id: thread.id,
+ member_count: channel.member_count ?? 0, //TODO: is this the right fix?
+ added_members: [threadMember],
+ removed_member_ids: [],
+ },
+ guild_id: channel.guild_id,
+ } satisfies ThreadMembersUpdateEvent),
+ ]);
+ }
+
+ return thread;
+ }
+
+ static async createDMChannel(recipients: string[], creator_user_id: string, name?: string) {
+ recipients = [...new Set(recipients)].filter((x) => x !== creator_user_id);
+ // TODO: check config for max number of recipients
+ /** if you want to disallow note to self channels, uncomment the conditional below
+
+ const otherRecipientsUsers = await User.find({ where: recipients.map((x) => ({ id: x })) });
+ if (otherRecipientsUsers.length !== recipients.length) {
+ throw new HTTPError("Recipient/s not found");
+ }
+ **/
+
+ const type = recipients.length > 1 ? ChannelType.GROUP_DM : ChannelType.DM;
+
+ let channel = null;
+ let needsTx = true;
+
+ const channelRecipients = [...recipients, creator_user_id];
+
+ const userRecipients = await Recipient.find({
+ where: { user_id: creator_user_id },
+ relations: { channel: { recipients: true } },
+ });
+
+ for (const ur of userRecipients) {
+ if (!ur.channel.recipients) continue;
+ const re = ur.channel.recipients.map((r) => r.user_id);
+ if (re.length === channelRecipients.length) {
+ if (channelRecipients.every((_) => re.includes(_))) {
+ if (channel == null) {
+ channel = ur.channel;
+ if (!ur.closed) needsTx = false;
+ await ur.assign({ closed: false }).save();
+ }
+ }
+ }
+ }
+
+ if (channel == null) {
+ name = trimSpecial(name);
+
+ channel = await Channel.create({
+ name,
+ type,
+ owner_id: type === ChannelType.GROUP_DM ? creator_user_id : undefined,
+ created_at: new Date(),
+ last_message_id: undefined,
+ recipients: channelRecipients.map((x) =>
+ Recipient.create({
+ user_id: x,
+ closed: !(type === ChannelType.GROUP_DM || x === creator_user_id),
+ }),
+ ),
+ nsfw: false,
+ }).save();
+ }
+
+ const channel_dto = await DmChannelDTO.from(channel);
+
+ if (!needsTx) {
+ /*ignored*/
+ } else if (type === ChannelType.GROUP_DM && channel.recipients) {
+ for (const recipient of channel.recipients) {
+ await emitEvent({
+ event: "CHANNEL_CREATE",
+ data: channel_dto.excludedRecipients([recipient.user_id]),
+ user_id: recipient.user_id,
+ });
+ }
+ } else {
+ await emitEvent({
+ event: "CHANNEL_CREATE",
+ data: channel_dto,
+ user_id: creator_user_id,
+ });
+ }
+
+ if (recipients.length === 1) return channel_dto;
+ else return channel_dto.excludedRecipients([creator_user_id]);
+ }
+
+ static async removeRecipientFromChannel(channel: Channel, user_id: string) {
+ await Recipient.delete({ channel_id: channel.id, user_id: user_id });
+ channel.recipients = channel.recipients?.filter((r) => r.user_id !== user_id);
+
+ if (channel.recipients?.length === 0) {
+ await Channel.deleteChannel(channel);
+ await emitEvent({
+ event: "CHANNEL_DELETE",
+ data: await DmChannelDTO.from(channel, [user_id]),
+ user_id: user_id,
+ });
+ return;
+ }
+
+ await emitEvent({
+ event: "CHANNEL_DELETE",
+ data: await DmChannelDTO.from(channel, [user_id]),
+ user_id: user_id,
+ });
+
+ //If the owner leave the server user is the new owner
+ if (channel.owner_id === user_id) {
+ channel.owner_id = "1"; // The channel is now owned by the server user
+ await emitEvent({
+ event: "CHANNEL_UPDATE",
+ data: await DmChannelDTO.from(channel, [user_id]),
+ channel_id: channel.id,
+ });
+ }
+
+ await channel.save();
+
+ await emitEvent({
+ event: "CHANNEL_RECIPIENT_REMOVE",
+ data: {
+ channel_id: channel.id,
+ user: await User.findOneOrFail({
+ where: { id: user_id },
+ select: PublicUserProjection,
+ }),
+ },
+ channel_id: channel.id,
+ } satisfies ChannelRecipientRemoveEvent);
+ }
+
+ static async deleteChannel(channel: Channel) {
+ // TODO Delete attachments from the CDN for messages in the channel
+ await Channel.delete({ id: channel.id });
+
+ if (channel.guild_id) {
+ const guild = await Guild.findOneOrFail({
+ where: { id: channel.guild_id },
+ select: { channel_ordering: true },
+ });
+
+ const updatedOrdering = guild.channel_ordering.filter((id) => id != channel.id);
+ await Guild.update({ id: channel.guild_id }, { channel_ordering: updatedOrdering });
+ }
+ }
+
+ static async calculatePosition(channel_id: string, guild_id: string, guild?: Guild) {
+ if (!guild)
+ guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: { channel_ordering: true },
+ });
+
+ return guild.channel_ordering.findIndex((id) => channel_id == id);
+ }
+
+ static async getOrderedChannels(guild_id: string, guild?: Guild) {
+ if (!guild)
+ guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: { channel_ordering: true },
+ });
+
+ const channels = await Promise.all(guild.channel_ordering.map((id) => Channel.findOne({ where: { id } })));
+
+ return channels
+ .filter((channel) => channel !== null)
+ .reduce((r, v) => {
+ v = v as Channel;
+
+ v.position = (guild as Guild).channel_ordering.indexOf(v.id);
+ r[v.position] = v;
+ return r;
+ }, [] as Array<Channel>);
+ }
+
+ isDm() {
+ return this.type === ChannelType.DM || this.type === ChannelType.GROUP_DM;
+ }
+
+ isThread() {
+ return this.type === ChannelType.GUILD_NEWS_THREAD || this.type === ChannelType.GUILD_PUBLIC_THREAD || this.type === ChannelType.GUILD_PRIVATE_THREAD;
+ }
+ isForum() {
+ return this.type === ChannelType.GUILD_FORUM || this.type === ChannelType.GUILD_MEDIA;
+ }
+
+ isPrivateThread() {
+ return this.type === ChannelType.GUILD_PRIVATE_THREAD;
+ }
+
+ isPublicThread() {
+ return this.type === ChannelType.GUILD_NEWS_THREAD || this.type === ChannelType.GUILD_PUBLIC_THREAD;
+ }
+
+ // Does the channel support sending messages ( eg categories do not )
+ isWritable() {
+ const disallowedChannelTypes = [ChannelType.GUILD_CATEGORY, ChannelType.GUILD_STAGE_VOICE];
+ return disallowedChannelTypes.indexOf(this.type) == -1;
+ }
+
+ async getUserPermissions(opts: { user_id?: string; user?: User; member?: Member; guild?: Guild }): Promise<Permissions> {
+ if (this.isDm()) return this.owner_id == (opts.user_id ?? opts.user?.id) ? Permissions.ALL : Permissions.DEFAULT_DM_PERMISSIONS;
+ let guild = opts.guild;
+ if (!guild) {
+ if (this.guild) guild = this.guild;
+ else if (this.guild_id) guild = await Guild.findOneOrFail({ where: { id: this.guild_id } });
+ else {
+ console.error("Channel.getUserPermissions: called without guild for non-DM channel.");
+ return Permissions.NONE;
+ }
+ }
+
+ // check if we can resolve here to short-circuit possibly calling the database unnecessarily
+ // TODO: do we want to have an instance-wide opt out of this behavior? It would just be an extra if statement here
+ const ownerId = guild?.owner?.id ?? guild?.owner_id;
+ if (!!opts.user_id && ownerId === opts.user_id) return Permissions.ALL;
+ if (!!opts.user?.id && ownerId === opts.user?.id) return Permissions.ALL;
+ if (!!opts.member?.id && ownerId === opts.member?.id) return Permissions.ALL;
+
+ let member = opts.member;
+ if (!member) {
+ if (opts.user) member = await Member.findOneOrFail({ where: { guild_id: guild.id, id: opts.user.id }, relations: { roles: true } });
+ else if (opts.user_id) member = await Member.findOneOrFail({ where: { guild_id: guild.id, id: opts.user_id }, relations: { roles: true } });
+ else {
+ console.error("Channel.getUserPermissions: called without user or member for non-DM channel.");
+ return Permissions.NONE;
+ }
+ }
+
+ const roles = (
+ member.roles ||
+ (
+ await Member.findOneOrFail({
+ where: { guild_id: guild.id, index: member.index },
+ relations: { roles: true },
+ select: {
+ roles: {
+ id: true,
+ permissions: true,
+ position: true,
+ },
+ },
+ loadEagerRelations: false,
+ })
+ ).roles
+ ).sort((a, b) => a.position - b.position); // ascending by position
+
+ return Permissions.finalPermission({
+ user: {
+ ...member,
+ roles: roles.map((r) => r.id),
+ flags: member.user?.flags ?? (await User.findOneOrFail({ where: { id: member.id }, select: { flags: true } })).flags,
+ },
+ guild: { id: guild.id, owner_id: guild.owner_id!, roles }, // We don't care about including *all* guild roles, as not all of them are relevant...
+ channel: this,
+ });
+ }
+
+ // TODO: should we throw for missing args?
+ async canViewChannel(opts: { user_id?: string; user?: User; member?: Member; guild?: Guild }): Promise<boolean> {
+ if (this.isDm()) return await this.canViewDmChannel(opts.user_id, opts.user);
+
+ const userPerms = await this.getUserPermissions(opts);
+ return userPerms.has("VIEW_CHANNEL");
+ }
+
+ private async canViewDmChannel(user_id?: string, user?: User): Promise<boolean> {
+ const userId = user_id ?? user?.id;
+ if (!userId) {
+ console.error("Channel.canViewChannel: called without user for DM channel.");
+ return false;
+ }
+ if (!user) return false;
+ if (this.recipients) return this.recipients.some((r) => r.user_id === user.id && !r.closed);
+ else {
+ // we dont have recipients on hand
+ const recipient = await Recipient.findOne({ where: { channel_id: this.id, user_id: user.id } });
+ return recipient == null ? false : !recipient.closed;
+ }
+ }
+
+ toJSON(): PublicChannel {
+ return {
+ ...this,
+ last_pin_timestamp: this.last_pin_timestamp?.toISOString(),
+ guild_id: this.guild_id ?? undefined,
+ recipients: undefined, //this.recipients?.map(x=>x.user.toPublicUser()), // TODO: fix me
+ owner: undefined, // TODO: fix me - this is thread owner
+
+ // these fields are not returned depending on the type of channel
+ bitrate: this.bitrate || undefined,
+ user_limit: this.user_limit || undefined,
+ rate_limit_per_user: this.rate_limit_per_user || undefined,
+ owner_id: this.owner_id || undefined,
+ ...(this.isThread() && this.thread_members ? { member_ids_preview: this.thread_members.map((_) => _.member.id) } : {}),
+ default_auto_archive_duration: this.default_auto_archive_duration ?? undefined,
+ retention_policy_id: undefined,
+ thread_metadata: this.thread_metadata
+ ? {
+ ...this.thread_metadata,
+ archive_timestamp: new Date(this.thread_metadata.archive_timestamp).toISOString().replace("Z", "+00:00"),
+ create_timestamp: new Date(this.thread_metadata.create_timestamp).toISOString().replace("Z", "+00:00"),
+ }
+ : undefined,
+ member_count: this.member_count ?? undefined,
+ message_count: this.message_count ?? undefined,
+ total_message_sent: this.total_message_sent ?? undefined,
+ applied_tags: this.applied_tags ?? undefined,
+ permission_overwrites: this.isThread() ? undefined : this.permission_overwrites,
+ };
+ }
+}
diff --git a/src/database/entities/ClientRelease.ts b/src/database/entities/ClientRelease.ts
new file mode 100644
index 00000000..fc8fe86c
--- /dev/null
+++ b/src/database/entities/ClientRelease.ts
@@ -0,0 +1,43 @@
+/*
+ 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 { Column, Entity } from "typeorm";
+import { BaseClass } from "./BaseClass";
+
+@Entity({
+ name: "client_release",
+})
+export class ClientRelease extends BaseClass {
+ @Column()
+ name: string;
+
+ @Column()
+ pub_date: Date;
+
+ @Column()
+ url: string;
+
+ @Column()
+ platform: string;
+
+ @Column()
+ enabled: boolean;
+
+ @Column({ nullable: true })
+ notes?: string;
+}
diff --git a/src/database/entities/CloudAttachment.ts b/src/database/entities/CloudAttachment.ts
new file mode 100644
index 00000000..408df024
--- /dev/null
+++ b/src/database/entities/CloudAttachment.ts
@@ -0,0 +1,81 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+import { Channel } from "./Channel";
+
+@Entity({
+ name: "cloud_attachments",
+})
+export class CloudAttachment extends BaseClass {
+ // Internal tracking metadata
+ @Column({ name: "user_id", nullable: true })
+ @RelationId((att: CloudAttachment) => att.user)
+ userId: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, { nullable: true, onDelete: "SET NULL" })
+ user?: User;
+
+ @Column({ name: "channel_id", nullable: true })
+ @RelationId((att: CloudAttachment) => att.channel)
+ channelId?: string; // channel the file is uploaded to
+
+ @JoinColumn({ name: "channel_id" })
+ @ManyToOne(() => Channel, { nullable: true, onDelete: "SET NULL" })
+ channel?: Channel; // channel the file is uploaded to
+
+ @Column({ name: "upload_filename" })
+ uploadFilename: string;
+
+ // User-provided info
+ @Column({ name: "user_attachment_id", nullable: true })
+ userAttachmentId?: string;
+
+ @Column({ name: "user_filename" })
+ userFilename: string; // name of file attached
+
+ @Column({ name: "user_file_size", nullable: true })
+ userFileSize?: number; // size of file in bytes
+
+ @Column({ name: "user_original_content_type", nullable: true })
+ userOriginalContentType?: string;
+
+ @Column({ name: "user_is_clip", nullable: true })
+ userIsClip?: boolean; // whether the file is a clip
+
+ // Actual file info, initialised after upload
+ @Column({ nullable: true })
+ size?: number; // size of file in bytes
+
+ @Column({ nullable: true })
+ height?: number; // height of file (if image)
+
+ @Column({ nullable: true })
+ width?: number; // width of file (if image)
+
+ @Column({ name: "content_type", nullable: true })
+ contentType?: string;
+
+ // @BeforeRemove()
+ // onDelete() {
+ // return deleteFile(new URL(this.url).pathname);
+ // }
+}
diff --git a/src/database/entities/Config.ts b/src/database/entities/Config.ts
new file mode 100644
index 00000000..7d4e05af
--- /dev/null
+++ b/src/database/entities/Config.ts
@@ -0,0 +1,31 @@
+/*
+ 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 { Column, Entity, PrimaryColumn } from "typeorm";
+import { BaseClassWithoutId } from "./BaseClass";
+
+@Entity({
+ name: "config",
+})
+export class ConfigEntity extends BaseClassWithoutId {
+ @PrimaryColumn()
+ key: string;
+
+ @Column({ type: "simple-json", nullable: true })
+ value: number | boolean | null | string | undefined;
+}
diff --git a/src/database/entities/ConnectedAccount.ts b/src/database/entities/ConnectedAccount.ts
new file mode 100644
index 00000000..d0ed8097
--- /dev/null
+++ b/src/database/entities/ConnectedAccount.ts
@@ -0,0 +1,83 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+import { ConnectedAccountTokenData } from "@spacebar/schemas";
+
+@Entity({
+ name: "connected_accounts",
+})
+export class ConnectedAccount extends BaseClass {
+ @Column()
+ external_id: string;
+
+ @Column({ nullable: true })
+ @RelationId((account: ConnectedAccount) => account.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ @Column({ select: false })
+ friend_sync?: boolean = false;
+
+ @Column()
+ name: string;
+
+ @Column({ select: false })
+ revoked?: boolean = false;
+
+ @Column({ select: false })
+ show_activity?: number = 0;
+
+ @Column()
+ type: string;
+
+ @Column()
+ verified?: boolean = true;
+
+ @Column({ select: false })
+ visibility?: number = 0;
+
+ @Column({ type: "varchar", array: true })
+ integrations?: string[] = [];
+
+ @Column({ type: "jsonb", name: "metadata", nullable: true })
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ metadata_?: any;
+
+ @Column()
+ metadata_visibility?: number = 0;
+
+ @Column()
+ two_way_link?: boolean = false;
+
+ @Column({ select: false, nullable: true, type: "jsonb" })
+ token_data?: ConnectedAccountTokenData | null;
+
+ async revoke() {
+ this.revoked = true;
+ this.token_data = null;
+ await this.save();
+ }
+}
diff --git a/src/database/entities/ConnectionConfigEntity.ts b/src/database/entities/ConnectionConfigEntity.ts
new file mode 100644
index 00000000..9ad2af8b
--- /dev/null
+++ b/src/database/entities/ConnectionConfigEntity.ts
@@ -0,0 +1,31 @@
+/*
+ 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 { Column, Entity, PrimaryColumn } from "typeorm";
+import { BaseClassWithoutId } from "./BaseClass";
+
+@Entity({
+ name: "connection_config",
+})
+export class ConnectionConfigEntity extends BaseClassWithoutId {
+ @PrimaryColumn()
+ key: string;
+
+ @Column({ type: "simple-json", nullable: true })
+ value: number | boolean | null | string | Date | undefined;
+}
diff --git a/src/database/entities/EmbedCache.ts b/src/database/entities/EmbedCache.ts
new file mode 100644
index 00000000..b34be18c
--- /dev/null
+++ b/src/database/entities/EmbedCache.ts
@@ -0,0 +1,38 @@
+/*
+ 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 { BaseClass } from "./BaseClass";
+import { Entity, Column } from "typeorm";
+import { Embed } from "@spacebar/schemas";
+
+@Entity({
+ name: "embed_cache",
+})
+export class EmbedCache extends BaseClass {
+ @Column()
+ url: string;
+
+ @Column({ type: "jsonb", nullable: true })
+ embed?: Embed;
+
+ @Column({ type: "jsonb", nullable: true })
+ embeds?: Embed[];
+
+ @Column({ name: "created_at", type: "timestamp with time zone" })
+ createdAt: Date;
+}
diff --git a/src/database/entities/Emoji.ts b/src/database/entities/Emoji.ts
new file mode 100644
index 00000000..f75cb03f
--- /dev/null
+++ b/src/database/entities/Emoji.ts
@@ -0,0 +1,65 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { User } from "./index";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+
+@Entity({
+ name: "emojis",
+})
+export class Emoji extends BaseClass {
+ @Column()
+ animated: boolean;
+
+ @Column()
+ available: boolean; // whether this emoji can be used, may be false due to various reasons
+
+ @Column()
+ guild_id: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, (guild) => guild.emojis, {
+ onDelete: "CASCADE",
+ })
+ guild: Guild;
+
+ @Column({ nullable: true })
+ @RelationId((emoji: Emoji) => emoji.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User)
+ user: User;
+
+ @Column()
+ managed: boolean;
+
+ @Column()
+ name: string;
+
+ @Column()
+ require_colons: boolean;
+
+ @Column({ type: "int8", array: true })
+ roles: string[]; // roles this emoji is whitelisted to (new discord feature?)
+
+ @Column({ type: "int8", array: true, nullable: true })
+ groups: string[]; // user groups this emoji is whitelisted to (Spacebar extension)
+}
diff --git a/src/database/entities/Encryption.ts b/src/database/entities/Encryption.ts
new file mode 100644
index 00000000..9c76701a
--- /dev/null
+++ b/src/database/entities/Encryption.ts
@@ -0,0 +1,43 @@
+/*
+ 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 { Column, Entity } from "typeorm";
+import { BaseClass } from "./BaseClass";
+
+@Entity({
+ name: "security_settings",
+})
+export class SecuritySettings extends BaseClass {
+ @Column({ nullable: true })
+ guild_id: string;
+
+ @Column({ nullable: true })
+ channel_id: string;
+
+ @Column()
+ encryption_permission_mask: number;
+
+ @Column({ type: "varchar", array: true })
+ allowed_algorithms: string[];
+
+ @Column()
+ current_algorithm: string;
+
+ @Column({ nullable: true })
+ used_since_message: string;
+}
diff --git a/src/database/entities/Guild.ts b/src/database/entities/Guild.ts
new file mode 100644
index 00000000..ab56b76a
--- /dev/null
+++ b/src/database/entities/Guild.ts
@@ -0,0 +1,492 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
+import { arrayRemove } from "@spacebar/extensions";
+import { Config, GuildWelcomeScreen, Snowflake, handleFile } from "@spacebar/util";
+import { Ban } from "./Ban";
+import { BaseClass } from "./BaseClass";
+import { Channel } from "./Channel";
+import { Emoji } from "./Emoji";
+import { Invite } from "./Invite";
+import { Member } from "./Member";
+import { Role } from "./Role";
+import { Sticker } from "./Sticker";
+import { Template } from "./Template";
+import { User } from "./User";
+import { VoiceState } from "./VoiceState";
+import { Webhook } from "./Webhook";
+// TODO: application_command_count, application_command_counts: {1: 0, 2: 0, 3: 0}
+// TODO: guild_scheduled_events
+// TODO: stage_instances
+// TODO: threads
+// TODO:
+// "keywords": [
+// "Genshin Impact",
+// "Paimon",
+// "Honkai Impact",
+// "ARPG",
+// "Open-World",
+// "Waifu",
+// "Anime",
+// "Genshin",
+// "miHoYo",
+// "Gacha"
+// ],
+
+export const PublicGuildRelations = [
+ "channels",
+ "emojis",
+ "roles",
+ "stickers",
+ "voice_states",
+ // "members", // TODO: These are public, but all members should not be fetched.
+ // "members.user",
+];
+
+@Entity({
+ name: "guilds",
+})
+export class Guild extends BaseClass {
+ @Column({ type: String, nullable: true })
+ @RelationId((guild: Guild) => guild.afk_channel)
+ afk_channel_id?: string | null;
+
+ @JoinColumn({ name: "afk_channel_id" })
+ @ManyToOne(() => Channel)
+ afk_channel?: Channel;
+
+ @Column({ nullable: true })
+ afk_timeout?: number;
+
+ // * commented out -> use owner instead
+ // application id of the guild creator if it is bot-created
+ // @Column({ nullable: true })
+ // application?: string;
+
+ @JoinColumn({ name: "ban_ids" })
+ @OneToMany(() => Ban, (ban: Ban) => ban.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ bans: Ban[];
+
+ @Column({ nullable: true })
+ banner?: string;
+
+ @Column({ nullable: true })
+ default_message_notifications?: number;
+
+ @Column({ nullable: true })
+ description?: string;
+
+ @Column({ nullable: true })
+ discovery_splash?: string;
+
+ @Column({ nullable: true })
+ explicit_content_filter?: number;
+
+ @Column({ type: "varchar", array: true })
+ features: string[] = []; //TODO use enum
+ //TODO: https://discord.com/developers/docs/resources/guild#guild-object-guild-features
+
+ @Column({ nullable: true })
+ primary_category_id?: string; // TODO: this was number?
+
+ @Column({ nullable: true })
+ icon?: string;
+
+ @Column()
+ large?: boolean = false;
+
+ @Column({ nullable: true })
+ max_members?: number;
+
+ @Column({ nullable: true })
+ max_presences?: number;
+
+ @Column({ nullable: true })
+ max_video_channel_users?: number;
+
+ @Column({ nullable: true })
+ member_count?: number;
+
+ @Column({ nullable: true })
+ presence_count?: number; // users online
+
+ @OneToMany(() => Member, (member: Member) => member.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ members: Member[];
+
+ @JoinColumn({ name: "role_ids" })
+ @OneToMany(() => Role, (role: Role) => role.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ roles: Role[];
+
+ @JoinColumn({ name: "channel_ids" })
+ @OneToMany(() => Channel, (channel: Channel) => channel.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ channels: Channel[];
+
+ @Column({ nullable: true })
+ @RelationId((guild: Guild) => guild.template)
+ template_id?: string;
+
+ @JoinColumn({ name: "template_id", referencedColumnName: "id" })
+ @ManyToOne(() => Template)
+ template: Template;
+
+ @JoinColumn({ name: "emoji_ids" })
+ @OneToMany(() => Emoji, (emoji: Emoji) => emoji.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ emojis: Emoji[];
+
+ @JoinColumn({ name: "sticker_ids" })
+ @OneToMany(() => Sticker, (sticker: Sticker) => sticker.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ stickers: Sticker[];
+
+ @JoinColumn({ name: "invite_ids" })
+ @OneToMany(() => Invite, (invite: Invite) => invite.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ invites: Invite[];
+
+ @JoinColumn({ name: "voice_state_ids" })
+ @OneToMany(() => VoiceState, (voicestate: VoiceState) => voicestate.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ voice_states: VoiceState[];
+
+ @JoinColumn({ name: "webhook_ids" })
+ @OneToMany(() => Webhook, (webhook: Webhook) => webhook.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ webhooks: Webhook[];
+
+ @Column({ nullable: true })
+ mfa_level?: number;
+
+ @Column()
+ name: string;
+
+ @Column({ nullable: true })
+ @RelationId((guild: Guild) => guild.owner)
+ owner_id?: string; // optional to allow for ownerless guilds
+
+ @JoinColumn({ name: "owner_id", referencedColumnName: "id" })
+ @ManyToOne(() => User)
+ owner?: User; // optional to allow for ownerless guilds
+
+ @Column({ nullable: true })
+ preferred_locale?: string;
+
+ @Column({ nullable: true })
+ premium_subscription_count?: number;
+
+ @Column()
+ premium_tier?: number; // crowd premium level
+
+ @Column({ type: String, nullable: true })
+ @RelationId((guild: Guild) => guild.public_updates_channel)
+ public_updates_channel_id: string | null;
+
+ @JoinColumn({ name: "public_updates_channel_id" })
+ @ManyToOne(() => Channel)
+ public_updates_channel?: Channel;
+
+ @Column({ type: String, nullable: true })
+ @RelationId((guild: Guild) => guild.rules_channel)
+ rules_channel_id?: string | null;
+
+ @JoinColumn({ name: "rules_channel_id" })
+ @ManyToOne(() => Channel)
+ rules_channel?: string;
+
+ @Column({ nullable: true })
+ region?: string;
+
+ @Column({ nullable: true })
+ splash?: string;
+
+ @Column({ type: String, nullable: true })
+ @RelationId((guild: Guild) => guild.system_channel)
+ system_channel_id?: string | null;
+
+ @JoinColumn({ name: "system_channel_id" })
+ @ManyToOne(() => Channel)
+ system_channel?: Channel;
+
+ @Column({ nullable: true })
+ system_channel_flags?: number;
+
+ @Column()
+ unavailable: boolean = false;
+
+ @Column({ nullable: true })
+ verification_level?: number;
+
+ /**
+ * DEPRECATED: Look at the new Guild onboarding screens.
+ */
+ @Column({ type: "jsonb" })
+ welcome_screen: GuildWelcomeScreen;
+
+ @Column({ nullable: true, type: "int8" })
+ @RelationId((guild: Guild) => guild.widget_channel)
+ widget_channel_id?: string;
+
+ @JoinColumn({ name: "widget_channel_id" })
+ @ManyToOne(() => Channel)
+ widget_channel?: Channel;
+
+ @Column()
+ widget_enabled: boolean = true;
+
+ @Column({ nullable: true })
+ nsfw_level?: number;
+
+ @Column()
+ nsfw: boolean = false;
+
+ // TODO: nested guilds
+ @Column({ nullable: true })
+ parent?: string;
+
+ // only for developer portal
+ permissions?: number;
+
+ //new guild settings, 11/08/2022:
+ @Column({ nullable: true })
+ premium_progress_bar_enabled: boolean = false;
+
+ @Column({ select: false, type: "int8", array: true })
+ channel_ordering: string[];
+
+ @Column()
+ discovery_weight: number = 0;
+
+ @Column()
+ discovery_excluded: boolean = false;
+
+ async ToGuildSource() {
+ if (!this.features.includes("DISCOVERABLE")) {
+ return null;
+ }
+ return {
+ id: this.id,
+ name: this.name,
+ icon: this.icon,
+ description: this.description,
+ banner: this.banner,
+ splash: this.splash,
+ discovery_splash: this.discovery_splash,
+ features: this.features,
+ vanity_url_code: null,
+ preferred_locale: this.preferred_locale || "en",
+ premium_subscription_count: this.premium_subscription_count,
+ approximate_member_count: await Member.countBy({
+ guild_id: this.id,
+ }),
+ approximate_presence_count: await Member.countBy({
+ guild_id: this.id,
+ user: {
+ sessions: {
+ status: "online",
+ },
+ },
+ }),
+ emojis: this.emojis ?? undefined,
+ emoji_count: this.emojis ? this.emojis.length : undefined,
+ stickers: this.stickers ?? undefined,
+ sticker_count: this.stickers ? this.stickers.length : undefined,
+ auto_removed: false,
+ primary_category_id: this.primary_category_id,
+ keywords: [],
+ is_published: false,
+ reasons_to_join: [],
+ };
+ }
+
+ static async createGuild(body: {
+ name?: string;
+ icon?: string | null;
+ owner_id?: string;
+ roles?: Partial<Role>[];
+ channels?: Partial<Channel>[];
+ source_guild_id: string | null;
+ }) {
+ const guild_id = Snowflake.generate();
+
+ const guild = await Guild.create({
+ id: guild_id,
+ name: body.name || "Spacebar",
+ icon: await handleFile(`/icons/${guild_id}`, body.icon as string),
+ owner_id: body.owner_id, // TODO: need to figure out a way for ownerless guilds and multiply-owned guilds
+ presence_count: 0,
+ member_count: 0, // will automatically be increased by addMember()
+ mfa_level: 0,
+ preferred_locale: "en-US",
+ premium_subscription_count: 0,
+ premium_tier: 0,
+ system_channel_flags: 4, // defaults effect: suppress the setup tips to save performance
+ nsfw_level: 0,
+ verification_level: 0,
+ welcome_screen: {
+ enabled: false,
+ description: "",
+ welcome_channels: [],
+ },
+ channel_ordering: [],
+ afk_timeout: Config.get().defaults.guild.afkTimeout,
+ default_message_notifications: Config.get().defaults.guild.defaultMessageNotifications,
+ explicit_content_filter: Config.get().defaults.guild.explicitContentFilter,
+ features: Config.get().guild.defaultFeatures,
+ max_members: Config.get().limits.guild.maxMembers,
+ max_presences: Config.get().defaults.guild.maxPresences,
+ max_video_channel_users: Config.get().defaults.guild.maxVideoChannelUsers,
+ region: Config.get().regions.default,
+ }).save();
+
+ // we have to create the role _after_ the guild because else we would get a foreign key error
+ // TODO: make the @everyone a pseudorole that is dynamically generated at runtime so we can save storage
+ await Role.create({
+ id: guild_id,
+ guild_id: guild_id,
+ color: 0,
+ colors: { primary_color: 0 },
+ hoist: false,
+ managed: false,
+ mentionable: false,
+ name: "@everyone",
+ permissions: "2251804225",
+ position: 0,
+ icon: undefined,
+ unicode_emoji: undefined,
+ flags: 0, // TODO?
+ }).save();
+
+ // create custom roles if provided
+ if (body.roles && body.roles.length) {
+ await Promise.all(
+ body.roles?.map(
+ (role) =>
+ new Promise((resolve) => {
+ Role.create({
+ ...role,
+ guild_id,
+ id:
+ // role.id === body.template_guild_id indicates that this is the @everyone role
+ role.id === body.source_guild_id || role.id == "0" ? guild_id : Snowflake.generate(),
+ })
+ .save()
+ .then(resolve);
+ }),
+ ),
+ );
+ }
+
+ if (!body.channels || !body.channels.length) {
+ body.channels = [{ id: "01", type: 0, name: "general", nsfw: false }];
+ }
+
+ const ids = new Map();
+
+ body.channels.forEach((x) => {
+ if (x.id) {
+ ids.set(x.id, Snowflake.generate());
+ }
+ });
+
+ for (const channel of body.channels.sort((a) => (a.parent_id ? 1 : -1))) {
+ const id = ids.get(channel.id) || Snowflake.generate();
+
+ const parent_id = ids.get(channel.parent_id);
+
+ const saved = await Channel.createChannel({ ...channel, guild_id, id, parent_id }, body.owner_id, {
+ keepId: true,
+ skipExistsCheck: true,
+ skipPermissionCheck: true,
+ skipEventEmit: true,
+ });
+
+ await Guild.insertChannelInOrder(guild.id, saved.id, parent_id ?? channel.position ?? 0, guild);
+ }
+
+ return guild;
+ }
+
+ /** Insert a channel into the guild ordering by parent channel id or position */
+ static async insertChannelInOrder(guild_id: string, channel_id: string, position: number, guild?: Guild): Promise<number>;
+ static async insertChannelInOrder(guild_id: string, channel_id: string, parent_id: string, guild?: Guild): Promise<number>;
+ static async insertChannelInOrder(guild_id: string, channel_id: string, insertPoint: string | number, guild?: Guild): Promise<number>;
+ static async insertChannelInOrder(guild_id: string, channel_id: string, insertPoint: string | number, guild?: Guild): Promise<number> {
+ if (!guild)
+ guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: { channel_ordering: true },
+ });
+
+ guild.channel_ordering ??= [];
+
+ let position;
+ if (typeof insertPoint == "string") position = guild.channel_ordering.indexOf(insertPoint) + 1;
+ else position = insertPoint;
+
+ arrayRemove(guild.channel_ordering, channel_id);
+
+ guild.channel_ordering.splice(position, 0, channel_id);
+ await Guild.update({ id: guild_id }, { channel_ordering: guild.channel_ordering });
+ return position;
+ }
+
+ toJSON(): Guild {
+ return {
+ ...this,
+ unavailable: this.unavailable == false ? undefined : true,
+ channel_ordering: undefined,
+ discovery_weight: undefined,
+ discovery_excluded: undefined,
+ parent: undefined,
+ primary_category_id: undefined,
+ nsfw: undefined,
+ template_id: undefined,
+ presence_count: undefined,
+ };
+ }
+}
diff --git a/src/database/entities/InstanceBan.ts b/src/database/entities/InstanceBan.ts
new file mode 100644
index 00000000..03424e5a
--- /dev/null
+++ b/src/database/entities/InstanceBan.ts
@@ -0,0 +1,111 @@
+/*
+ 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 { Column, CreateDateColumn, Entity, FindOptionsWhere, Index, JoinColumn, OneToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+
+@Entity({
+ name: "instance_bans",
+})
+export class InstanceBan extends BaseClass {
+ @Column({ type: "bigint" })
+ @CreateDateColumn()
+ created_at: Date = new Date();
+
+ @Column()
+ reason: string;
+
+ @Index()
+ @Column({ nullable: true })
+ user_id?: string;
+
+ @Index()
+ @Column({ nullable: true })
+ fingerprint?: string;
+
+ @Index()
+ @Column({ nullable: true })
+ ip_address?: string;
+
+ // chain of trust type tracking
+
+ @Column({ default: false })
+ is_allowlisted: boolean = false;
+
+ @Column({ default: false })
+ is_from_other_instance_ban: boolean = false;
+
+ @Column({ nullable: true })
+ @RelationId((instance_ban: InstanceBan) => instance_ban.origin_instance_ban)
+ origin_instance_ban_id?: string;
+
+ @JoinColumn({ name: "origin_instance_ban_id" })
+ @OneToOne(() => InstanceBan, { nullable: true, onDelete: "SET NULL" })
+ origin_instance_ban?: InstanceBan;
+
+ static async findInstanceBans(opts: { userId?: string; ipAddress?: string; fingerprint?: string; propagateBan?: boolean }) {
+ const optionalChecks: FindOptionsWhere<InstanceBan>[] = [{ user_id: opts.userId }];
+ if (opts?.ipAddress) optionalChecks.push({ ip_address: opts.ipAddress });
+ if (opts?.fingerprint) optionalChecks.push({ fingerprint: opts.fingerprint });
+ const instanceBans = await InstanceBan.find({ where: optionalChecks });
+
+ const banReasons = [];
+ for (const ban of instanceBans) {
+ if (ban.is_allowlisted) continue;
+ if (opts?.fingerprint && ban.fingerprint === opts.fingerprint) banReasons.push("fingerprint");
+ if (opts?.ipAddress && ban.ip_address === opts.ipAddress) banReasons.push("ipAddress");
+ if (opts?.userId && ban.user_id === opts?.userId) banReasons.push("userId");
+ }
+
+ const banViralityPromises: Promise<InstanceBan>[] = [];
+ if (opts.propagateBan && banReasons.length > 0) {
+ if (opts?.ipAddress && !instanceBans.find((b) => b.ip_address === opts.ipAddress))
+ banViralityPromises.push(
+ InstanceBan.create({
+ user_id: opts.userId,
+ ip_address: opts.ipAddress,
+ reason: "Propagated from other instance ban",
+ is_from_other_instance_ban: true,
+ origin_instance_ban: instanceBans[0],
+ }).save(),
+ );
+ if (opts?.fingerprint && !instanceBans.find((b) => b.fingerprint === opts.fingerprint))
+ banViralityPromises.push(
+ InstanceBan.create({
+ user_id: opts.userId,
+ fingerprint: opts.fingerprint,
+ reason: "Propagated from other instance ban",
+ is_from_other_instance_ban: true,
+ origin_instance_ban: instanceBans[0],
+ }).save(),
+ );
+ if (opts?.userId && !instanceBans.find((b) => b.user_id === opts.userId))
+ banViralityPromises.push(
+ InstanceBan.create({
+ user_id: opts.userId,
+ reason: "Propagated from other instance ban",
+ is_from_other_instance_ban: true,
+ origin_instance_ban: instanceBans[0],
+ }).save(),
+ );
+ }
+
+ await Promise.all(banViralityPromises);
+ return banReasons;
+ }
+}
diff --git a/src/database/entities/Invite.ts b/src/database/entities/Invite.ts
new file mode 100644
index 00000000..cc5374eb
--- /dev/null
+++ b/src/database/entities/Invite.ts
@@ -0,0 +1,126 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, RelationId } from "typeorm";
+import { BaseClassWithoutId } from "./BaseClass";
+import { Channel } from "./Channel";
+import { Guild } from "./Guild";
+import { Member } from "./Member";
+import { User } from "./User";
+
+export const PublicInviteRelation = ["inviter", "guild", "channel"];
+
+@Entity({
+ name: "invites",
+})
+export class Invite extends BaseClassWithoutId {
+ @PrimaryColumn()
+ code: string;
+
+ @Column()
+ temporary: boolean;
+
+ @Column()
+ uses: number;
+
+ @Column()
+ max_uses: number;
+
+ @Column()
+ max_age: number;
+
+ @Column()
+ created_at: Date;
+
+ @Column({ nullable: true })
+ expires_at?: Date;
+
+ @Column({ nullable: true })
+ @RelationId((invite: Invite) => invite.guild)
+ guild_id: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, (guild) => guild.invites, {
+ onDelete: "CASCADE",
+ })
+ guild: Guild;
+
+ @Column({ nullable: true })
+ @RelationId((invite: Invite) => invite.channel)
+ channel_id: string;
+
+ @JoinColumn({ name: "channel_id" })
+ @ManyToOne(() => Channel, {
+ onDelete: "CASCADE",
+ })
+ channel: Channel;
+
+ @Column({ nullable: true })
+ @RelationId((invite: Invite) => invite.inviter)
+ inviter_id?: string;
+
+ @JoinColumn({ name: "inviter_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ inviter: User;
+
+ @Column({ nullable: true })
+ @RelationId((invite: Invite) => invite.target_user)
+ target_user_id: string;
+
+ @JoinColumn({ name: "target_user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ target_user?: string; // could be used for "User specific invites" https://github.com/spacebarchat/server/issues/326
+
+ @Column({ nullable: true })
+ target_user_type?: number;
+
+ @Column({ nullable: true })
+ vanity_url?: boolean;
+
+ @Column()
+ flags: number;
+
+ isExpired() {
+ if (this.max_age !== 0 && this.expires_at && this.expires_at < new Date()) return true;
+ if (this.max_uses !== 0 && this.uses >= this.max_uses) return true;
+ return false;
+ }
+ toPublicJSON() {
+ return {
+ ...this,
+ inviter: this.inviter.toPublicUser(),
+ };
+ }
+
+ static async joinGuild(user_id: string, code: string) {
+ const invite = await Invite.findOneOrFail({ where: { code } });
+ if (invite.isExpired()) {
+ await Invite.delete({ code });
+ throw new Error("Invite is expired");
+ }
+ if (invite.uses++ >= invite.max_uses && invite.max_uses !== 0) await Invite.delete({ code });
+ else await invite.save();
+
+ await Member.addToGuild(user_id, invite.guild_id);
+ return invite;
+ }
+}
diff --git a/src/database/entities/Member.ts b/src/database/entities/Member.ts
new file mode 100644
index 00000000..b9fbac3d
--- /dev/null
+++ b/src/database/entities/Member.ts
@@ -0,0 +1,488 @@
+/*
+ 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 { HTTPError } from "lambert-server/HTTPError";
+import { BeforeInsert, BeforeUpdate, Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, Not, PrimaryGeneratedColumn, RelationId } from "typeorm";
+import { Ban, Channel, PublicGuildRelations } from "./index";
+import { ReadyGuildDTO } from "../../util/dtos";
+import { GuildCreateEvent, GuildDeleteEvent, GuildMemberAddEvent, GuildMemberRemoveEvent, GuildMemberUpdateEvent, MessageCreateEvent } from "../../util/interfaces";
+import { Config, emitEvent, DiscordApiErrors, Stopwatch } from "../../util/util";
+import { BaseClassWithoutId } from "./BaseClass";
+import { Guild } from "./Guild";
+import { Message } from "./Message";
+import { Role } from "./Role";
+import { User } from "./User";
+import { AvatarDecorationData, Collectibles, DisplayNameStyle, PublicMember, PublicMemberProjection, UserGuildSettings } from "@spacebar/schemas";
+
+export const MemberPrivateProjection: (keyof Member)[] = [
+ "id",
+ "guild",
+ "guild_id",
+ "deaf",
+ "joined_at",
+ "last_message_id",
+ "mute",
+ "nick",
+ "pending",
+ "premium_since",
+ "roles",
+ "settings",
+ "user",
+ "avatar",
+ "banner",
+ "bio",
+ "theme_colors",
+ "pronouns",
+ "communication_disabled_until",
+ "flags",
+];
+
+@Entity({
+ name: "members",
+})
+@Index(["id", "guild_id"], { unique: true })
+export class Member extends BaseClassWithoutId {
+ @PrimaryGeneratedColumn()
+ index: string;
+
+ @Column()
+ @RelationId((member: Member) => member.user)
+ id: string;
+
+ @JoinColumn({ name: "id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ @Column()
+ @RelationId((member: Member) => member.guild)
+ guild_id: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, {
+ onDelete: "CASCADE",
+ })
+ guild: Guild;
+
+ @Column({ nullable: true })
+ nick?: string;
+
+ @JoinTable({
+ name: "member_roles",
+ joinColumn: { name: "index", referencedColumnName: "index" },
+ inverseJoinColumn: {
+ name: "role_id",
+ referencedColumnName: "id",
+ },
+ })
+ @ManyToMany(() => Role, { cascade: true })
+ roles: Role[];
+
+ @Column()
+ joined_at: Date;
+
+ @Column({ type: "bigint", nullable: true })
+ premium_since?: number;
+
+ @Column()
+ deaf: boolean;
+
+ @Column()
+ mute: boolean;
+
+ @Column()
+ pending: boolean;
+
+ @Column({ type: "jsonb", select: false })
+ settings: UserGuildSettings;
+
+ @Column({ nullable: true })
+ last_message_id?: string;
+
+ /**
+ @JoinColumn({ name: "id" })
+ @ManyToOne(() => User, {
+ onDelete: "DO NOTHING",
+ // do not auto-kick force-joined members just because their joiners left the server
+ }) **/
+ @Column({ nullable: true })
+ joined_by: string;
+
+ @Column({ nullable: true })
+ avatar?: string;
+
+ @Column({ nullable: true })
+ banner: string;
+
+ @Column()
+ bio: string;
+
+ @Column({ nullable: true, type: "int4", array: true })
+ theme_colors?: number[]; // TODO: Separate `User` and `UserProfile` models
+
+ @Column({ nullable: true })
+ pronouns?: string;
+
+ @Column({ nullable: true, type: Date })
+ communication_disabled_until: Date | null;
+
+ // TODO: add this when we have proper read receipts
+ // @Column({ type: "jsonb" })
+ // read_state: ReadState;
+
+ @Column({ type: "jsonb", nullable: true })
+ avatar_decoration_data?: AvatarDecorationData;
+
+ @Column({ type: "jsonb", nullable: true })
+ display_name_styles?: DisplayNameStyle;
+
+ @Column({ type: "jsonb", nullable: true })
+ collectibles?: Collectibles;
+
+ @Column({ type: "int", default: 0 })
+ flags: number = 0;
+
+ @BeforeUpdate()
+ @BeforeInsert()
+ validate() {
+ if (this.nick) {
+ this.nick = this.nick.split("\n").join("");
+ this.nick = this.nick.split("\t").join("");
+ }
+ if (this.nick === "") this.nick = undefined;
+ if (this.pronouns === "") this.pronouns = undefined;
+ }
+
+ static async IsInGuildOrFail(user_id: string, guild_id: string) {
+ if (
+ await Member.count({
+ where: { id: user_id, guild_id },
+ })
+ )
+ return;
+ throw new HTTPError("You are not member of this guild", 403);
+ }
+
+ static async removeFromGuild(user_id: string, guild_id: string) {
+ const guild = await Guild.findOneOrFail({
+ select: { owner_id: true },
+ where: { id: guild_id },
+ });
+ if (guild.owner_id === user_id) throw new Error("The owner cannot be removed from the guild");
+ const member = await Member.findOneOrFail({
+ where: { id: user_id, guild_id },
+ relations: { user: true },
+ });
+
+ // use promise all to execute all promises at the same time -> save time
+ return Promise.all([
+ Member.delete({
+ id: user_id,
+ guild_id,
+ }),
+ Guild.decrement({ id: guild_id }, "member_count", 1),
+
+ emitEvent({
+ event: "GUILD_DELETE",
+ data: {
+ id: guild_id,
+ },
+ user_id: user_id,
+ } satisfies GuildDeleteEvent),
+ emitEvent({
+ event: "GUILD_MEMBER_REMOVE",
+ data: { guild_id, user: member.user.toPublicUser() },
+ guild_id,
+ } satisfies GuildMemberRemoveEvent),
+ ]);
+ }
+
+ static async addRole(user_id: string, guild_id: string, role_id: string) {
+ const [member] = await Promise.all([
+ Member.findOneOrFail({
+ where: { id: user_id, guild_id },
+ relations: { user: true, roles: true }, // we don't want to load the role objects just the ids
+ select: {
+ index: true,
+ roles: {
+ id: true,
+ },
+ },
+ }),
+ Role.findOneOrFail({
+ where: { id: role_id, guild_id },
+ select: { id: true },
+ }),
+ ]);
+ member.roles.push(Role.create({ id: role_id }));
+
+ await Promise.all([
+ member.save(),
+ emitEvent({
+ event: "GUILD_MEMBER_UPDATE",
+ data: {
+ guild_id,
+ user: member.user,
+ roles: member.roles.map((x) => x.id),
+ },
+ guild_id,
+ } satisfies GuildMemberUpdateEvent),
+ ]);
+ }
+
+ static async removeRole(user_id: string, guild_id: string, role_id: string) {
+ const [member] = await Promise.all([
+ Member.findOneOrFail({
+ where: { id: user_id, guild_id },
+ relations: { user: true, roles: true }, // we don't want to load the role objects just the ids
+ select: {
+ index: true,
+ roles: {
+ id: true,
+ },
+ },
+ }),
+ Role.findOneOrFail({ where: { id: role_id, guild_id } }),
+ ]);
+ member.roles = member.roles.filter((x) => x.id !== role_id);
+
+ await Promise.all([
+ member.save(),
+ emitEvent({
+ event: "GUILD_MEMBER_UPDATE",
+ data: {
+ guild_id,
+ user: member.user,
+ roles: member.roles.map((x) => x.id),
+ },
+ guild_id,
+ } satisfies GuildMemberUpdateEvent),
+ ]);
+ }
+
+ static async changeNickname(user_id: string, guild_id: string, nickname: string) {
+ const member = await Member.findOneOrFail({
+ where: {
+ id: user_id,
+ guild_id,
+ },
+ relations: { user: true, roles: true },
+ });
+
+ // @ts-expect-error Member nickname is nullable
+ member.nick = nickname || null;
+
+ await Promise.all([
+ member.save(),
+
+ emitEvent({
+ event: "GUILD_MEMBER_UPDATE",
+ data: {
+ guild_id,
+ user: member.user,
+ nick: nickname || undefined,
+ roles: member.roles.map((x) => x.id),
+ },
+ guild_id,
+ } satisfies GuildMemberUpdateEvent),
+ ]);
+ }
+
+ static async addToGuild(user_id: string, guild_id: string, isRegistration: boolean = false) {
+ const totalSw = Stopwatch.startNew();
+ const incSw = Stopwatch.startNew();
+ const logTrace = (...data: unknown[]) => {
+ if (process.env.LOG_VERBOSE_TRACES !== "true") return;
+ console.log("[Member.addToGuild]", ...data, `[${totalSw.elapsed().toString()} (+${incSw.getElapsedAndReset().totalMilliseconds}ms)]`);
+ };
+
+ if (!isRegistration) {
+ const isBanned = Ban.exists({ where: { guild_id, user_id } });
+ const isMember = Member.exists({ where: { id: user_id, guild_id } });
+
+ if (await isBanned) throw DiscordApiErrors.USER_BANNED;
+ logTrace("Check bans");
+
+ if (await isMember) throw new HTTPError("You are already a member of this guild", 400);
+ logTrace("Check existing membership");
+
+ const { maxGuilds } = Config.get().limits.user;
+ const guild_count = await Member.count({ where: { id: user_id } });
+ if (guild_count >= maxGuilds) {
+ throw new HTTPError(`You are at the ${maxGuilds} guild limit.`, 403);
+ }
+ logTrace("Enforce max guilds");
+ }
+
+ const guild = await Guild.findOneOrFail({
+ where: {
+ id: guild_id,
+ },
+ relations: PublicGuildRelations,
+ relationLoadStrategy: "query",
+ });
+ const channelPositionsGuild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: { channel_ordering: true },
+ });
+ logTrace("Find guild");
+
+ const newMember = Member.create({
+ id: user_id,
+ guild_id,
+ nick: undefined,
+ joined_at: new Date(),
+ deaf: false,
+ mute: false,
+ pending: false,
+ bio: "",
+ roles: [Role.create({ id: guild_id })], // @everyone role
+ // read_state: {},
+ settings: {
+ guild_id: null,
+ mute_config: null,
+ mute_scheduled_events: false,
+ flags: 0,
+ hide_muted_channels: false,
+ notify_highlights: 0,
+ channel_overrides: {},
+ message_notifications: guild.default_message_notifications,
+ mobile_push: true,
+ muted: false,
+ suppress_everyone: false,
+ suppress_roles: false,
+ version: 0,
+ },
+ // Member.save is needed because else the roles relations wouldn't be updated
+ });
+
+ let memberCount = 0;
+ let memberPreview: PublicMember[] = [];
+ if (!isRegistration) {
+ for await (const channel of guild.channels) {
+ channel.position = await Channel.calculatePosition(channel.id, guild_id, channelPositionsGuild);
+ }
+
+ logTrace("Reorder channels");
+
+ memberCount = isRegistration ? 0 : await Member.count({ where: { guild_id } });
+ logTrace("Get member count");
+
+ memberPreview = (
+ await Member.find({
+ where: {
+ guild_id,
+ user: {
+ id: Not(user_id),
+ sessions: {
+ status: Not("invisible" as const), // lol typescript?
+ },
+ },
+ },
+ relations: { user: true, roles: true },
+ take: 10,
+ })
+ ).map((member) => member.toPublicMember());
+ logTrace("Calculate member preview");
+ }
+
+ const user = await User.getPublicUser(user_id);
+ logTrace("Get user");
+
+ await Promise.all([
+ newMember.save(), // TODO: can we somehow insert the roles manually? We have no entity for this... Would skip a few select's
+ Guild.increment({ id: guild_id }, "member_count", 1),
+ emitEvent({
+ event: "GUILD_MEMBER_ADD",
+ data: {
+ ...newMember.toPublicMember(),
+ user: user,
+ guild_id,
+ },
+ guild_id,
+ origin: "util/entities/Member.ts:377/addToGuild(user_id, guild_id)",
+ } satisfies GuildMemberAddEvent),
+ isRegistration
+ ? null
+ : emitEvent({
+ event: "GUILD_CREATE",
+ data: {
+ ...new ReadyGuildDTO(guild).toJSON(),
+ members: [...memberPreview, { ...newMember.toPublicMember(), user }],
+ member_count: memberCount + 1,
+ guild_hashes: {},
+ guild_scheduled_events: [],
+ joined_at: newMember.joined_at,
+ presences: [],
+ stage_instances: [],
+ threads: [],
+ embedded_activities: [],
+ voice_states: guild.voice_states.map((x) => x.toPublicVoiceState()),
+ },
+ user_id,
+ } satisfies GuildCreateEvent),
+ ]);
+ logTrace("Save member info");
+
+ const welcomeChannelId = guild.system_channel_id;
+ if (welcomeChannelId && (await Channel.exists({ where: { id: welcomeChannelId } }))) {
+ // Send a welcome message
+ const message = Message.create({
+ type: 7,
+ guild_id: guild.id,
+ channel_id: welcomeChannelId,
+ author: user,
+ timestamp: new Date(),
+ reactions: [],
+ attachments: [],
+ embeds: [],
+ sticker_items: [],
+ edited_timestamp: undefined,
+ mentions: [],
+ mention_channels: [],
+ mention_roles: [],
+ mention_everyone: false,
+ });
+
+ await Promise.all([
+ message.insert(),
+ emitEvent({
+ event: "MESSAGE_CREATE",
+ channel_id: message.channel_id,
+ data: message.toJSON(),
+ } satisfies MessageCreateEvent),
+ Channel.update({ id: welcomeChannelId }, { last_message_id: message.id }),
+ ]);
+ logTrace("Send welcome message");
+ }
+ }
+
+ toPublicMember() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const member: any = {};
+ PublicMemberProjection.forEach((x) => {
+ member[x] = this[x];
+ });
+
+ if (this.roles) member.roles = this.roles.map((x: Role) => x.id);
+ if (this.user) member.user = this.user.toPublicUser();
+
+ return member as PublicMember;
+ }
+}
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);
+};
diff --git a/src/database/entities/Migration.ts b/src/database/entities/Migration.ts
new file mode 100644
index 00000000..b4a5ff78
--- /dev/null
+++ b/src/database/entities/Migration.ts
@@ -0,0 +1,33 @@
+/*
+ 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 { Column, Entity, PrimaryGeneratedColumn, BaseEntity } from "typeorm";
+
+@Entity({
+ name: "migrations",
+})
+export class Migration extends BaseEntity {
+ @PrimaryGeneratedColumn()
+ id: number;
+
+ @Column({ type: "bigint" })
+ timestamp: number;
+
+ @Column()
+ name: string;
+}
diff --git a/src/database/entities/Note.ts b/src/database/entities/Note.ts
new file mode 100644
index 00000000..e9f173ff
--- /dev/null
+++ b/src/database/entities/Note.ts
@@ -0,0 +1,38 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, Unique } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+
+@Entity({
+ name: "notes",
+})
+@Unique(["owner", "target"])
+export class Note extends BaseClass {
+ @JoinColumn({ name: "owner_id" })
+ @ManyToOne(() => User, { onDelete: "CASCADE" })
+ owner: User;
+
+ @JoinColumn({ name: "target_id" })
+ @ManyToOne(() => User, { onDelete: "CASCADE" })
+ target: User;
+
+ @Column()
+ content: string;
+}
diff --git a/src/database/entities/RateLimit.ts b/src/database/entities/RateLimit.ts
new file mode 100644
index 00000000..ab85285c
--- /dev/null
+++ b/src/database/entities/RateLimit.ts
@@ -0,0 +1,37 @@
+/*
+ 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 { Column, Entity } from "typeorm";
+import { BaseClass } from "./BaseClass";
+
+@Entity({
+ name: "rate_limits",
+})
+export class RateLimit extends BaseClass {
+ @Column() // no relation as it also
+ executor_id: string;
+
+ @Column()
+ hits: number;
+
+ @Column()
+ blocked: boolean;
+
+ @Column()
+ expires_at: Date;
+}
diff --git a/src/database/entities/ReadState.ts b/src/database/entities/ReadState.ts
new file mode 100644
index 00000000..4d15c6cc
--- /dev/null
+++ b/src/database/entities/ReadState.ts
@@ -0,0 +1,93 @@
+/*
+ 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 { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Channel } from "./Channel";
+import { User } from "./User";
+import { ReadStateFlags, ReadStateType } from "@spacebar/schemas";
+
+// for read receipts
+// notification cursor and public read receipt need to be forwards-only (the former to prevent re-pinging when marked as unread, and the latter to be acceptable as a legal acknowledgement in criminal proceedings), and private read marker needs to be advance-rewind capable
+// public read receipt ≥ notification cursor ≥ private fully read marker
+
+@Entity({
+ name: "read_states",
+})
+@Index(["channel_id", "user_id"], { unique: true })
+export class ReadState extends BaseClass {
+ @Column()
+ @RelationId((read_state: ReadState) => read_state.channel)
+ channel_id: string;
+
+ @JoinColumn({ name: "channel_id" })
+ @ManyToOne(() => Channel, {
+ onDelete: "CASCADE",
+ })
+ channel: Channel;
+
+ @Column()
+ @RelationId((read_state: ReadState) => read_state.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ @Column({ nullable: true })
+ last_message_id?: string;
+
+ @Column({ nullable: true })
+ last_acked_id?: string;
+
+ @Column({ nullable: true })
+ notifications_cursor: string;
+
+ @Column({ default: 0 })
+ mention_count: number;
+
+ @Column({ default: 0 })
+ badge_count: number;
+
+ @Column({ nullable: true })
+ last_pin_timestamp?: Date;
+
+ @Column({ default: ReadStateType.CHANNEL })
+ read_state_type: ReadStateType;
+
+ @Column({ default: 0 })
+ flags: ReadStateFlags;
+
+ // toJSON() {
+ // const res = { ...this } as Partial<ReadState>;
+ // if (this.read_state_type === ReadStateType.CHANNEL) {
+ // delete res.badge_count;
+ // delete res.last_acked_id;
+ // } else {
+ // delete res.mention_count; // mutually exclusive with badge_count
+ // delete res.last_message_id; // mutually exclusive with last_acked_id
+ // // these only apply to channels:
+ // delete res.last_pin_timestamp;
+ // delete res.flags;
+ // // delete res.last_viewed; // TODO
+ // }
+ // return res;
+ // }
+}
diff --git a/src/database/entities/Recipient.ts b/src/database/entities/Recipient.ts
new file mode 100644
index 00000000..2dcff244
--- /dev/null
+++ b/src/database/entities/Recipient.ts
@@ -0,0 +1,50 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+
+@Entity({
+ name: "recipients",
+})
+export class Recipient extends BaseClass {
+ @Column()
+ @RelationId((recipient: Recipient) => recipient.channel)
+ channel_id: string;
+
+ @JoinColumn({ name: "channel_id" })
+ @ManyToOne(() => require("./Channel").Channel, {
+ onDelete: "CASCADE",
+ })
+ channel: import("./Channel").Channel;
+
+ @Column()
+ @RelationId((recipient: Recipient) => recipient.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => require("./User").User, {
+ onDelete: "CASCADE",
+ })
+ user: import("./User").User;
+
+ @Column({ default: false })
+ closed: boolean;
+
+ // TODO: settings/mute/nick/added at/encryption keys/read_state
+}
diff --git a/src/database/entities/Relationship.ts b/src/database/entities/Relationship.ts
new file mode 100644
index 00000000..c2256ed3
--- /dev/null
+++ b/src/database/entities/Relationship.ts
@@ -0,0 +1,63 @@
+/*
+ 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 { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+import { RelationshipType } from "@spacebar/schemas";
+
+@Entity({
+ name: "relationships",
+})
+@Index(["from_id", "to_id"], { unique: true })
+export class Relationship extends BaseClass {
+ @Column({})
+ @RelationId((relationship: Relationship) => relationship.from)
+ from_id: string;
+
+ @JoinColumn({ name: "from_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ from: User;
+
+ @Column({})
+ @RelationId((relationship: Relationship) => relationship.to)
+ to_id: string;
+
+ @JoinColumn({ name: "to_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ to: User;
+
+ @Column({ nullable: true })
+ nickname?: string;
+
+ @Column({ type: "int" })
+ type: RelationshipType;
+
+ toPublicRelationship() {
+ return {
+ id: this.to?.id || this.to_id,
+ type: this.type,
+ nickname: this.nickname,
+ user: this.to?.toPublicUser(),
+ };
+ }
+}
diff --git a/src/database/entities/ReportMenu.ts b/src/database/entities/ReportMenu.ts
new file mode 100644
index 00000000..a37ae159
--- /dev/null
+++ b/src/database/entities/ReportMenu.ts
@@ -0,0 +1,41 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2024 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 { BaseClass } from "./BaseClass";
+import { Entity, Column } from "typeorm";
+import { ReportMenuType } from "../../schemas/api/reports/ReportMenu";
+
+@Entity({
+ name: "report_menus",
+})
+export class ReportMenu extends BaseClass {
+ @Column()
+ type: ReportMenuType;
+
+ @Column()
+ variant: string;
+
+ @Column()
+ isCurrent: boolean;
+
+ @Column({ nullable: true })
+ inherits?: string;
+
+ @Column({ type: "jsonb" })
+ content: unknown;
+}
diff --git a/src/database/entities/Role.ts b/src/database/entities/Role.ts
new file mode 100644
index 00000000..e6b2e459
--- /dev/null
+++ b/src/database/entities/Role.ts
@@ -0,0 +1,85 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { RoleColors } from "@spacebar/schemas";
+
+@Entity({
+ name: "roles",
+})
+export class Role extends BaseClass {
+ @Column()
+ @RelationId((role: Role) => role.guild)
+ guild_id: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, (guild) => guild.roles, {
+ onDelete: "CASCADE",
+ })
+ guild: Guild;
+
+ @Column()
+ color: number;
+
+ @Column()
+ hoist: boolean;
+
+ @Column({ default: false })
+ managed: boolean;
+
+ @Column()
+ mentionable: boolean;
+
+ @Column()
+ name: string;
+
+ @Column()
+ permissions: string;
+
+ @Column()
+ position: number;
+
+ @Column({ nullable: true })
+ icon?: string;
+
+ @Column({ nullable: true })
+ unicode_emoji?: string;
+
+ @Column({ type: "jsonb", nullable: true })
+ tags?: {
+ bot_id?: string;
+ integration_id?: string;
+ premium_subscriber?: boolean;
+ };
+
+ @Column({ default: 0 })
+ flags: number;
+
+ @Column({ nullable: false, type: "jsonb" })
+ colors: RoleColors;
+
+ toJSON(): Role {
+ return {
+ ...this,
+ tags: this.tags ?? undefined,
+ };
+ }
+}
diff --git a/src/database/entities/SecurityKey.ts b/src/database/entities/SecurityKey.ts
new file mode 100644
index 00000000..cbf80d3d
--- /dev/null
+++ b/src/database/entities/SecurityKey.ts
@@ -0,0 +1,48 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+
+@Entity({
+ name: "security_keys",
+})
+export class SecurityKey extends BaseClass {
+ @Column({ nullable: true })
+ @RelationId((key: SecurityKey) => key.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ @Column()
+ key_id: string;
+
+ @Column()
+ public_key: string;
+
+ @Column()
+ counter: number;
+
+ @Column()
+ name: string;
+}
diff --git a/src/database/entities/Session.ts b/src/database/entities/Session.ts
new file mode 100644
index 00000000..e0e622db
--- /dev/null
+++ b/src/database/entities/Session.ts
@@ -0,0 +1,181 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2025 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 crypto from "node:crypto";
+import { User } from "./User";
+import { BaseClassWithoutId } from "./BaseClass";
+import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn, RelationId } from "typeorm";
+import { Activity, ClientStatus, GatewaySession, GatewaySessionClientInfo, PrivateStatus } from "../../util/interfaces";
+import { randomUpperString } from "@spacebar/api";
+import { DateBuilder, IpDataClient, TimeSpan } from "../../util/util";
+
+@Entity({
+ name: "sessions",
+})
+export class Session extends BaseClassWithoutId {
+ @PrimaryColumn({ nullable: false })
+ session_id: string = randomUpperString();
+
+ @Column()
+ @RelationId((session: Session) => session.user)
+ @Index({})
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ @Column({ type: "jsonb", default: "[]" })
+ activities: Activity[];
+
+ @Column({ type: "jsonb" })
+ client_info: {
+ platform?: string;
+ os?: string;
+ version?: number;
+ location?: string;
+ };
+
+ @Column({ type: "jsonb" })
+ client_status: ClientStatus;
+
+ @Column({ nullable: false, type: String })
+ status: PrivateStatus;
+
+ @Column({ default: false })
+ is_admin_session: boolean;
+
+ @CreateDateColumn({ type: Date })
+ created_at: Date;
+
+ @Column({ nullable: true, type: Date })
+ last_seen?: Date;
+
+ @Column({ nullable: true, type: String })
+ last_seen_ip?: string;
+
+ @Column({ nullable: true, type: String })
+ last_seen_location?: string;
+
+ @Column({ nullable: true, type: "jsonb" })
+ last_seen_location_info?: ExtendedLocationInfo;
+
+ @Column({ nullable: true, type: String })
+ session_nickname?: string;
+
+ getPublicStatus() {
+ return this.status === "invisible" ? "offline" : this.status;
+ }
+
+ getDiscordDeviceInfo() {
+ return {
+ id_hash: crypto.createHash("sha256").update(this.session_id).digest("hex"),
+ approx_last_used_time: (this.last_seen ?? new Date(0)).toISOString(),
+ client_info: {
+ os: this.client_info?.os,
+ platform:
+ this.client_info?.platform + (this.client_info?.version ? ` ${this.client_info?.version}` : "") + (this.session_nickname ? ` (${this.session_nickname})` : ""),
+ location: this.last_seen_location,
+ },
+ };
+ }
+
+ getExtendedDeviceInfo() {
+ return {
+ id: this.session_id,
+ id_hash: crypto.createHash("sha256").update(this.session_id).digest("hex"),
+ status: this.status,
+ activities: this.activities,
+ client_status: this.client_status,
+ approx_last_used_time: (this.last_seen ?? new Date(0)).toISOString(),
+ client_info: {
+ ...(this.client_info ?? {}),
+ location: this.last_seen_location,
+ },
+ last_seen: this.last_seen,
+ last_seen_ip: this.last_seen_ip,
+ last_seen_location: this.last_seen_location,
+ last_seen_location_info: this.last_seen_location_info,
+ };
+ }
+
+ toPrivateGatewayDeviceInfo(): GatewaySession {
+ // TODO: ... or has `show_current_game` privacy setting enabled - except spotify (always visible)
+ const hasPrivateActivities = this.status == "offline" || this.status == "invisible";
+ const inactiveTreshold = new DateBuilder(new Date(0)).addMinutes(5).buildTimestamp();
+
+ return {
+ session_id: this.session_id,
+ client_info: {
+ client: this.client_info?.platform ?? "",
+ os: this.client_info?.os ?? "",
+ version: this.client_info?.version ?? 0,
+ } as GatewaySessionClientInfo,
+ status: this.status,
+ activities: hasPrivateActivities ? [] : this.activities,
+ hidden_activities: hasPrivateActivities ? this.activities : [],
+ active: TimeSpan.fromDates(this.last_seen?.getTime() ?? 0, new Date().getTime()).totalMillis < inactiveTreshold,
+ };
+ }
+
+ async updateIpInfo() {
+ const ipInfo = await IpDataClient.getIpInfo(this.last_seen_ip!);
+ if (ipInfo?.ip) {
+ this.last_seen_location = `${ipInfo.emoji_flag} ${ipInfo.postal} ${ipInfo.city}, ${ipInfo.region}, ${ipInfo.country_name}`;
+ this.last_seen_location_info = {
+ is_eu: ipInfo.is_eu,
+ city: ipInfo.city,
+ region: ipInfo.region,
+ region_code: ipInfo.region_code,
+ country_name: ipInfo.country_name,
+ country_code: ipInfo.country_code,
+ continent_name: ipInfo.continent_name,
+ continent_code: ipInfo.continent_code,
+ latitude: ipInfo.latitude,
+ longitude: ipInfo.longitude,
+ postal: ipInfo.postal,
+ calling_code: ipInfo.calling_code,
+ flag: ipInfo.flag,
+ emoji_flag: ipInfo.emoji_flag,
+ emoji_unicode: ipInfo.emoji_unicode,
+ };
+ }
+ }
+}
+
+export interface ExtendedLocationInfo {
+ is_eu: boolean;
+ city: string;
+ region: string;
+ region_code: string;
+ country_name: string;
+ country_code: string;
+ continent_name: string;
+ continent_code: string;
+ latitude: number;
+ longitude: number;
+ postal: string;
+ calling_code: string;
+ flag: string;
+ emoji_flag: string;
+ emoji_unicode: string;
+}
+
+export const PrivateSessionProjection: (keyof Session)[] = ["user_id", "session_id", "activities", "client_info", "status"];
diff --git a/src/database/entities/Sticker.ts b/src/database/entities/Sticker.ts
new file mode 100644
index 00000000..c88e0c29
--- /dev/null
+++ b/src/database/entities/Sticker.ts
@@ -0,0 +1,75 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { User } from "./User";
+import { StickerFormatType, StickerType } from "@spacebar/schemas";
+
+@Entity({
+ name: "stickers",
+})
+export class Sticker extends BaseClass {
+ @Column()
+ name: string;
+
+ @Column({ nullable: true })
+ description?: string;
+
+ @Column({ nullable: true })
+ available?: boolean;
+
+ @Column({ nullable: true })
+ tags?: string;
+
+ @Column({ nullable: true })
+ @RelationId((sticker: Sticker) => sticker.pack)
+ pack_id?: string;
+
+ @JoinColumn({ name: "pack_id" })
+ @ManyToOne(() => require("./StickerPack").StickerPack, {
+ onDelete: "CASCADE",
+ nullable: true,
+ })
+ pack: import("./StickerPack").StickerPack;
+
+ @Column({ nullable: true })
+ guild_id?: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, (guild) => guild.stickers, {
+ onDelete: "CASCADE",
+ })
+ guild?: Guild;
+
+ @Column({ nullable: true })
+ user_id?: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user?: User;
+
+ @Column({ type: "int" })
+ type: StickerType;
+
+ @Column({ type: "int" })
+ format_type: StickerFormatType;
+}
diff --git a/src/database/entities/StickerPack.ts b/src/database/entities/StickerPack.ts
new file mode 100644
index 00000000..87112441
--- /dev/null
+++ b/src/database/entities/StickerPack.ts
@@ -0,0 +1,51 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
+import { Sticker } from "./index";
+import { BaseClass } from "./BaseClass";
+
+@Entity({
+ name: "sticker_packs",
+})
+export class StickerPack extends BaseClass {
+ @Column()
+ name: string;
+
+ @Column({ nullable: true })
+ description?: string;
+
+ @Column({ nullable: true })
+ banner_asset_id?: string;
+
+ @OneToMany(() => Sticker, (sticker: Sticker) => sticker.pack, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ stickers: Sticker[];
+
+ // sku_id: string
+
+ @Column({ nullable: true })
+ @RelationId((pack: StickerPack) => pack.cover_sticker)
+ cover_sticker_id?: string;
+
+ @ManyToOne(() => Sticker, { nullable: true })
+ @JoinColumn()
+ cover_sticker?: Sticker;
+}
diff --git a/src/database/entities/Stream.ts b/src/database/entities/Stream.ts
new file mode 100644
index 00000000..bf2f3a62
--- /dev/null
+++ b/src/database/entities/Stream.ts
@@ -0,0 +1,32 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+import { Channel } from "./Channel";
+
+@Entity({
+ name: "streams",
+})
+export class Stream extends BaseClass {
+ @Column()
+ @RelationId((stream: Stream) => stream.owner)
+ owner_id: string;
+
+ @JoinColumn({ name: "owner_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ owner: User;
+
+ @Column()
+ @RelationId((stream: Stream) => stream.channel)
+ channel_id: string;
+
+ @JoinColumn({ name: "channel_id" })
+ @ManyToOne(() => Channel, {
+ onDelete: "CASCADE",
+ })
+ channel: Channel;
+
+ @Column()
+ endpoint: string;
+}
diff --git a/src/database/entities/StreamSession.ts b/src/database/entities/StreamSession.ts
new file mode 100644
index 00000000..1dca2424
--- /dev/null
+++ b/src/database/entities/StreamSession.ts
@@ -0,0 +1,39 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+import { Stream } from "./Stream";
+
+@Entity({
+ name: "stream_sessions",
+})
+export class StreamSession extends BaseClass {
+ @Column()
+ @RelationId((session: StreamSession) => session.stream)
+ stream_id: string;
+
+ @JoinColumn({ name: "stream_id" })
+ @ManyToOne(() => Stream, {
+ onDelete: "CASCADE",
+ })
+ stream: Stream;
+
+ @Column()
+ @RelationId((session: StreamSession) => session.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ @Column({ nullable: true })
+ token: string;
+
+ // this is for gateway session
+ @Column()
+ session_id: string;
+
+ @Column({ default: false })
+ used: boolean;
+}
diff --git a/src/database/entities/Tag.ts b/src/database/entities/Tag.ts
new file mode 100644
index 00000000..63ad4b86
--- /dev/null
+++ b/src/database/entities/Tag.ts
@@ -0,0 +1,57 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Channel } from "./Channel";
+
+@Entity({
+ name: "tags",
+})
+export class Tag extends BaseClass {
+ @Column()
+ channel_id: string;
+
+ @JoinColumn({ name: "channel_id" })
+ @ManyToOne(() => Channel, (channel) => channel.available_tags, {
+ onDelete: "CASCADE",
+ })
+ channel: Channel;
+
+ @Column()
+ name: string;
+
+ @Column()
+ moderated: boolean = false;
+
+ @Column({ nullable: true })
+ emoji_id?: string;
+
+ @Column({ nullable: true })
+ emoji_name?: string;
+
+ toJSON() {
+ return {
+ name: this.name,
+ id: this.id,
+ moderated: this.moderated,
+ emoji_id: this.emoji_id,
+ emoji_name: this.emoji_name,
+ };
+ }
+}
diff --git a/src/database/entities/Team.ts b/src/database/entities/Team.ts
new file mode 100644
index 00000000..e440e965
--- /dev/null
+++ b/src/database/entities/Team.ts
@@ -0,0 +1,47 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { TeamMember } from "./TeamMember";
+import { User } from "./User";
+
+@Entity({
+ name: "teams",
+})
+export class Team extends BaseClass {
+ @Column({ nullable: true })
+ icon?: string;
+
+ @JoinColumn({ name: "member_ids" })
+ @OneToMany(() => TeamMember, (member: TeamMember) => member.team, {
+ orphanedRowAction: "delete",
+ })
+ members: TeamMember[];
+
+ @Column()
+ name: string;
+
+ @Column({ nullable: true })
+ @RelationId((team: Team) => team.owner_user)
+ owner_user_id: string;
+
+ @JoinColumn({ name: "owner_user_id" })
+ @ManyToOne(() => User)
+ owner_user: User;
+}
diff --git a/src/database/entities/TeamMember.ts b/src/database/entities/TeamMember.ts
new file mode 100644
index 00000000..72e23b76
--- /dev/null
+++ b/src/database/entities/TeamMember.ts
@@ -0,0 +1,56 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+import { TeamMemberRole, TeamMemberState } from "@spacebar/schemas";
+
+@Entity({
+ name: "team_members",
+})
+export class TeamMember extends BaseClass {
+ @Column({ type: "int" })
+ membership_state: TeamMemberState;
+
+ @Column({ type: "varchar", array: true })
+ permissions: string[];
+
+ @Column()
+ role: TeamMemberRole;
+
+ @Column({ nullable: true })
+ @RelationId((member: TeamMember) => member.team)
+ team_id: string;
+
+ @JoinColumn({ name: "team_id" })
+ @ManyToOne(() => require("./Team").Team, (team: import("./Team").Team) => team.members, {
+ onDelete: "CASCADE",
+ })
+ team: import("./Team").Team;
+
+ @Column({ nullable: true })
+ @RelationId((member: TeamMember) => member.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+}
diff --git a/src/database/entities/Template.ts b/src/database/entities/Template.ts
new file mode 100644
index 00000000..79804eed
--- /dev/null
+++ b/src/database/entities/Template.ts
@@ -0,0 +1,64 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { User } from "./User";
+
+@Entity({
+ name: "templates",
+})
+export class Template extends BaseClass {
+ @Column({ unique: true })
+ code: string;
+
+ @Column()
+ name: string;
+
+ @Column({ nullable: true })
+ description?: string;
+
+ @Column({ nullable: true })
+ usage_count?: number;
+
+ @Column({ nullable: true })
+ @RelationId((template: Template) => template.creator)
+ creator_id: string;
+
+ @JoinColumn({ name: "creator_id" })
+ @ManyToOne(() => User)
+ creator: User;
+
+ @Column()
+ created_at: Date;
+
+ @Column()
+ updated_at: Date;
+
+ @Column({ nullable: true })
+ @RelationId((template: Template) => template.source_guild)
+ source_guild_id: string;
+
+ @JoinColumn({ name: "source_guild_id" })
+ @ManyToOne(() => Guild, { onDelete: "CASCADE" })
+ source_guild: Guild;
+
+ @Column({ type: "jsonb" })
+ serialized_source_guild: Guild;
+}
diff --git a/src/database/entities/ThreadMember.ts b/src/database/entities/ThreadMember.ts
new file mode 100644
index 00000000..fdda4eea
--- /dev/null
+++ b/src/database/entities/ThreadMember.ts
@@ -0,0 +1,195 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2026 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 { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, RelationId } from "typeorm";
+import { ThreadMembersUpdateEvent } from "../../util/interfaces";
+import { emitEvent } from "../../util/util";
+import { BaseClassWithoutId } from "./BaseClass";
+import { Channel } from "./Channel";
+import { HTTPError } from "lambert-server/HTTPError";
+import { Member } from "./Member";
+
+// TODO: move
+interface ThreadMemberMuteConfig {
+ end_time?: Date;
+ selected_time_window?: number;
+}
+
+// TODO: move
+export enum ThreadMemberFlags {
+ NONE = 0,
+ HAS_INTERACTED = 1 << 0,
+ ALL_MESSAGES = 1 << 1,
+ ONLY_MENTIONS = 1 << 2,
+ NO_MESSAGES = 1 << 3,
+}
+
+@Entity("thread_members")
+@Index(["id", "member_idx"], { unique: true })
+export class ThreadMember extends BaseClassWithoutId {
+ @PrimaryGeneratedColumn()
+ index: string;
+
+ @Column()
+ @RelationId((member: ThreadMember) => member.channel)
+ id: string;
+
+ @JoinColumn({ name: "id" })
+ @ManyToOne(() => Channel, {
+ onDelete: "CASCADE",
+ })
+ channel: Channel;
+
+ @Column()
+ @RelationId((member: ThreadMember) => member.member)
+ member_idx: string;
+
+ @JoinColumn({ name: "member_idx" })
+ @ManyToOne(() => Member, {
+ onDelete: "CASCADE",
+ })
+ member: Member;
+
+ @Column()
+ join_timestamp: Date;
+
+ @Column()
+ muted: boolean;
+
+ @Column({ nullable: true, type: "jsonb" })
+ mute_config?: ThreadMemberMuteConfig;
+
+ @Column()
+ flags: ThreadMemberFlags;
+
+ static async IsInThreadOrFail(member_id: string, thread_id: string) {
+ if (await ThreadMember.count({ where: { id: thread_id, member_idx: member_id } })) return true;
+ throw new HTTPError("You are not member of this thread", 403);
+ }
+
+ static async removeFromThread(member_id: string, thread_id: string) {
+ const channel = await Channel.findOneOrFail({ where: { id: thread_id } });
+ if (
+ !(await ThreadMember.count({
+ where: {
+ id: thread_id,
+ member_idx: member_id,
+ },
+ }))
+ )
+ throw new HTTPError("You are not member of this thread", 403);
+ // // use promise all to execute all promises at the same time -> save time
+ // TODO: check for bugs
+ if (channel.member_count) channel.member_count--;
+ return Promise.all([
+ ThreadMember.delete({
+ id: thread_id,
+ member_idx: member_id,
+ }),
+ // //Guild.decrement({ id: guild_id }, "member_count", -1),
+
+ emitEvent({
+ event: "THREAD_MEMBERS_UPDATE",
+ data: {
+ guild_id: channel.guild_id!, // TODO: is this the right fix?
+ id: channel.id,
+ member_count: channel.member_count ?? 0,
+ removed_member_ids: [member_id],
+ },
+ channel_id: thread_id,
+ } satisfies ThreadMembersUpdateEvent),
+ ]);
+ }
+
+ // static async addRole(user_id: string, guild_id: string, role_id: string) {
+ // const [member, role] = await Promise.all([
+ // // @ts-ignore
+ // Member.findOneOrFail({
+ // where: { id: user_id, guild_id },
+ // relations: ["user", "roles"], // we don't want to load the role objects just the ids
+ // select: ["index"]
+ // }),
+ // Role.findOneOrFail({ where: { id: role_id, guild_id }, select: ["id"] })
+ // ]);
+ // member.roles.push(OrmUtils.mergeDeep(new Role(), { id: role_id }));
+
+ // await Promise.all([
+ // member.save(),
+ // emitEvent({
+ // event: "GUILD_MEMBER_UPDATE",
+ // data: {
+ // guild_id,
+ // user: member.user,
+ // roles: member.roles.map((x) => x.id)
+ // },
+ // guild_id
+ // } satisfies GuildMemberUpdateEvent)
+ // ]);
+ // }
+
+ // static async removeRole(user_id: string, guild_id: string, role_id: string) {
+ // const [member] = await Promise.all([
+ // // @ts-ignore
+ // Member.findOneOrFail({
+ // where: { id: user_id, guild_id },
+ // relations: ["user", "roles"], // we don't want to load the role objects just the ids
+ // select: ["index"]
+ // }),
+ // await Role.findOneOrFail({ where: { id: role_id, guild_id } })
+ // ]);
+ // member.roles = member.roles.filter((x) => x.id == role_id);
+
+ // await Promise.all([
+ // member.save(),
+ // emitEvent({
+ // event: "GUILD_MEMBER_UPDATE",
+ // data: {
+ // guild_id,
+ // user: member.user,
+ // roles: member.roles.map((x) => x.id)
+ // },
+ // guild_id
+ // } satisfies GuildMemberUpdateEvent)
+ // ]);
+ // }
+
+ // static async changeNickname(user_id: string, guild_id: string, nickname: string) {
+ // const member = await Member.findOneOrFail({
+ // where: {
+ // id: user_id,
+ // guild_id
+ // },
+ // relations: ["user"]
+ // });
+ // member.nick = nickname;
+
+ // await Promise.all([
+ // member.save(),
+
+ // emitEvent({
+ // event: "GUILD_MEMBER_UPDATE",
+ // data: {
+ // guild_id,
+ // user: member.user,
+ // nick: nickname
+ // },
+ // guild_id
+ // } satisfies GuildMemberUpdateEvent)
+ // ]);
+ // }
+}
diff --git a/src/database/entities/User.ts b/src/database/entities/User.ts
new file mode 100644
index 00000000..5f40adb3
--- /dev/null
+++ b/src/database/entities/User.ts
@@ -0,0 +1,409 @@
+/*
+ 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 { Request } from "express";
+import { Column, Entity, JoinColumn, OneToMany, OneToOne } from "typeorm";
+import { Config, Email, FieldErrors, Snowflake, Stopwatch, trimSpecial } from "@spacebar/util";
+import { Random } from "@spacebar/util/util/Random";
+import { BaseClass } from "./BaseClass";
+import { Channel } from "./Channel";
+import { ConnectedAccount } from "./ConnectedAccount";
+import { Member } from "./Member";
+import { Relationship } from "./Relationship";
+import { SecurityKey } from "./SecurityKey";
+import { Session } from "./Session";
+import { UserSettings } from "./UserSettings";
+import {
+ AvatarDecorationData,
+ ChannelType,
+ Collectibles,
+ DisplayNameStyle,
+ PrimaryGuild,
+ PrivateUserProjection,
+ PublicUser,
+ PublicUserProjection,
+ UserPrivate,
+} from "@spacebar/schemas";
+import { JsonNumber } from "../../util/util/Decorators";
+
+@Entity({
+ name: "users",
+})
+export class User extends BaseClass {
+ @Column()
+ username: string; // username max length 32, min 2 (should be configurable)
+
+ @Column()
+ discriminator: string; // opaque string: 4 digits on discord.com
+
+ @Column({ nullable: true })
+ avatar?: string; // hash of the user avatar
+
+ @Column({ nullable: true })
+ accent_color?: number; // banner color of user
+
+ @Column({ nullable: true })
+ banner?: string; // hash of the user banner
+
+ // TODO: Separate `User` and `UserProfile` models
+ // puyo: changed from [number, number] because it breaks openapi
+ @Column({ nullable: true, type: "int4", array: true })
+ theme_colors?: number[];
+
+ @Column({ nullable: true })
+ pronouns?: string;
+
+ @Column({ nullable: true, select: false })
+ phone?: string; // phone number of the user
+
+ @Column({ select: false })
+ desktop: boolean = false; // if the user has desktop app installed
+
+ @Column({ select: false })
+ mobile: boolean = false; // if the user has mobile app installed
+
+ @Column()
+ premium: boolean; // if user bought individual premium
+
+ @Column()
+ premium_type: number; // individual premium level
+
+ @Column()
+ bot: boolean = false; // if user is bot
+
+ @Column()
+ bio: string = ""; // short description of the user
+
+ @Column()
+ system: boolean = false; // shouldn't be used, the api sends this field type true, if the generated message comes from a system generated author
+
+ @Column({ select: false })
+ nsfw_allowed: boolean = true; // if the user can do age-restricted actions (NSFW channels/guilds/commands) // TODO: depending on age
+
+ @Column({ select: false })
+ mfa_enabled: boolean = false; // if multi factor authentication is enabled
+
+ @Column({ select: false, default: false })
+ webauthn_enabled: boolean = false; // if webauthn multi factor authentication is enabled
+
+ @Column({ select: false, nullable: true })
+ totp_secret?: string = "";
+
+ @Column({ nullable: true, select: false })
+ totp_last_ticket?: string = "";
+
+ @Column()
+ created_at: Date; // registration date
+
+ @Column({ nullable: true })
+ premium_since: Date; // premium date
+
+ @Column({ select: false })
+ verified: boolean; // email is verified
+
+ @Column()
+ disabled: boolean = false; // if the account is disabled
+
+ @Column()
+ deleted: boolean = false; // if the user was deleted
+
+ @Column({ nullable: true, select: false })
+ email?: string; // email of the user
+
+ @Column({ type: "bigint" })
+ @JsonNumber
+ flags: number = 0; // UserFlags // TODO: generate
+
+ @Column({ type: "bigint" })
+ @JsonNumber
+ public_flags: number = 0;
+
+ @Column({ type: "bigint" })
+ @JsonNumber
+ purchased_flags: number = 0;
+
+ @Column()
+ premium_usage_flags: number = 0;
+
+ @Column({ type: "bigint" })
+ @JsonNumber
+ rights: string;
+
+ @OneToMany(() => Session, (session: Session) => session.user)
+ sessions: Session[];
+
+ @JoinColumn({ name: "relationship_ids" })
+ @OneToMany(() => Relationship, (relationship: Relationship) => relationship.from, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ relationships: Relationship[];
+
+ @JoinColumn({ name: "connected_account_ids" })
+ @OneToMany(() => ConnectedAccount, (account: ConnectedAccount) => account.user, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ connected_accounts: ConnectedAccount[];
+
+ @Column({ type: "jsonb", select: false })
+ data: {
+ valid_tokens_since: Date; // all tokens with a previous issue date are invalid
+ hash?: string; // hash of the password, salt is saved in password (bcrypt)
+ };
+
+ @Column({ type: "varchar", array: true, select: false })
+ fingerprints: string[] = []; // array of fingerprints -> used to prevent multiple accounts
+
+ @OneToOne(() => UserSettings, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ nullable: true,
+ })
+ @JoinColumn()
+ settings?: UserSettings;
+
+ @OneToMany(() => SecurityKey, (key: SecurityKey) => key.user)
+ security_keys: SecurityKey[];
+
+ @Column({ type: "int8", array: true, nullable: true })
+ badge_ids?: string[];
+
+ @Column({ type: "jsonb", nullable: true })
+ avatar_decoration_data?: AvatarDecorationData;
+
+ @Column({ type: "jsonb", nullable: true })
+ display_name_styles?: DisplayNameStyle;
+
+ @Column({ type: "jsonb", nullable: true })
+ collectibles?: Collectibles;
+
+ @Column({ type: "jsonb", nullable: true })
+ primary_guild?: PrimaryGuild;
+
+ // TODO: I don't like this method?
+ validate() {
+ if (this.discriminator) {
+ const discrim = Number(this.discriminator);
+ if (isNaN(discrim) || !Number.isInteger(discrim) || discrim <= 0 || discrim >= 10000)
+ throw FieldErrors({
+ discriminator: {
+ message: "Discriminator must be a number.",
+ code: "DISCRIMINATOR_INVALID",
+ },
+ });
+
+ this.discriminator = discrim.toString().padStart(4, "0");
+ }
+ }
+
+ toPublicUser() {
+ this.clean_data();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const user: any = {};
+ PublicUserProjection.forEach((x) => {
+ user[x] = this[x];
+ });
+ return user as PublicUser;
+ }
+
+ toPrivateUser(extraFields: (keyof User)[] = []) {
+ this.clean_data();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const user: any = {};
+ [...PrivateUserProjection, ...extraFields].forEach((x) => {
+ user[x] = this[x];
+ });
+ return user as UserPrivate;
+ }
+
+ static async getPublicUser(user_id: string): Promise<PublicUser> {
+ const user = await User.findOneOrFail({
+ where: { id: user_id },
+ select: PublicUserProjection,
+ });
+ return user.toPublicUser();
+ }
+
+ public static async generateDiscriminator(username: string): Promise<string | undefined> {
+ if (Config.get().register.incrementingDiscriminators) {
+ // discriminator will be incrementally generated
+
+ // First we need to figure out the currently highest discrimnator for the given username and then increment it
+ const users = await User.find({
+ where: { username },
+ select: { discriminator: true },
+ });
+ const highestDiscriminator = Math.max(0, ...users.map((u) => Number(u.discriminator)));
+
+ const discriminator = highestDiscriminator + 1;
+ if (discriminator >= 10000) {
+ return undefined;
+ }
+
+ return discriminator.toString().padStart(4, "0");
+ } else {
+ // discriminator will be randomly generated
+
+ // randomly generates a discriminator between 1 and 9999 and checks max five times if it already exists
+ // TODO: is there any better way to generate a random discriminator only once, without checking if it already exists in the database?
+ const takenDiscriminators = (await User.find({ where: { username }, select: { discriminator: true } })).map((x) => x.discriminator);
+ if (takenDiscriminators.length >= 9999) return undefined;
+
+ for (let tries = 0; tries < 15; tries++) {
+ const discriminator = Random.nextInt(1, 9999).toString().padStart(4, "0");
+ if (!takenDiscriminators.includes(discriminator)) return discriminator;
+ }
+
+ return undefined;
+ }
+ }
+
+ public get tag(): string {
+ //const { uniqueUsernames } = Config.get().general;
+ const uniqueUsernames = false;
+
+ return uniqueUsernames ? this.username : `${this.username}#${this.discriminator}`;
+ }
+
+ static async register({
+ email,
+ username,
+ password,
+ id,
+ req,
+ bot,
+ }: {
+ username: string;
+ password?: string;
+ email?: string;
+ date_of_birth?: Date; // "2000-04-03"
+ id?: string;
+ req?: Request;
+ bot?: boolean;
+ }) {
+ const totalSw = Stopwatch.startNew();
+ const incSw = Stopwatch.startNew();
+ const logTrace = (...data: unknown[]) => {
+ if (process.env.LOG_VERBOSE_TRACES !== "true") return;
+ console.log("[User.register]", ...data, `[${totalSw.elapsed().toString()} (+${incSw.getElapsedAndReset().totalMilliseconds}ms)]`);
+ };
+
+ // trim special utf8 control characters -> Backspace, Newline, ...
+ username = trimSpecial(username);
+
+ const discriminator = await User.generateDiscriminator(username);
+ if (!discriminator) {
+ // We've failed to generate a valid and unused discriminator
+ throw FieldErrors({
+ username: {
+ code: "USERNAME_TOO_MANY_USERS",
+ message: req?.t("auth:register.USERNAME_TOO_MANY_USERS") || "",
+ },
+ });
+ }
+ logTrace("Generate discriminator");
+
+ // TODO: save date_of_birth
+ // apparently discord doesn't save the date of birth and just calculate if nsfw is allowed
+ // if nsfw_allowed is null/undefined it'll require date_of_birth to set it to true/false
+ const language = req?.language === "en" ? "en-US" : req?.language || "en-US";
+
+ const settings = UserSettings.create({
+ locale: language,
+ });
+
+ const user = User.create({
+ username: username,
+ discriminator,
+ id: id || Snowflake.generate(),
+ email: email,
+ data: {
+ hash: password,
+ valid_tokens_since: new Date(),
+ },
+ settings: settings,
+
+ premium_since: Config.get().defaults.user.premium ? new Date() : undefined,
+ rights: Config.get().register.defaultRights,
+ premium: Config.get().defaults.user.premium ?? false,
+ premium_type: Config.get().defaults.user.premiumType ?? 0,
+ verified: Config.get().defaults.user.verified ?? true,
+ created_at: new Date(),
+ bot: !!bot,
+ });
+
+ user.validate();
+ logTrace("Generate/validate user");
+
+ await Promise.all([user.save(), settings.save()]);
+ logTrace("Save user");
+
+ // send verification email if users aren't verified by default and we have an email
+ if (!Config.get().defaults.user.verified && email) {
+ await Email.sendVerifyEmail(user, email).catch((e) => {
+ console.error(`Failed to send verification email to ${user.tag}: ${e}`);
+ });
+ logTrace("Send verify email");
+ }
+
+ const { autoJoin } = Config.get().guild;
+ if (autoJoin.enabled && autoJoin.guilds.length > 0 && !(bot && !autoJoin.bots)) {
+ await Promise.all(autoJoin.guilds.map((guild) => Member.addToGuild(user.id, guild, true).catch((e) => console.error("[Autojoin]", e))));
+ logTrace("Autojoin", autoJoin.guilds.length, "guilds");
+ }
+
+ return user;
+ }
+
+ async getDmChannelWith(user_id: string) {
+ const qry = await Channel.getRepository()
+ .createQueryBuilder()
+ .leftJoinAndSelect("Channel.recipients", "rcp")
+ .where("Channel.type = :type", { type: ChannelType.DM })
+ .andWhere("rcp.user_id IN (:...user_ids)", { user_ids: [this.id, user_id] })
+ .groupBy("Channel.id")
+ .having("COUNT(rcp.user_id) = 2")
+ .getMany();
+
+ // Emma [it/its]@Rory&: is this technically a bug, or am I being too over-cautious?
+ if (qry.length > 1) {
+ console.warn(`[WARN] User(${this.id})#getDmChannel(${user_id}) returned multiple channels:`);
+ for (const channel of qry) {
+ console.warn(JSON.stringify(channel));
+ }
+ throw new Error("Array contains more than one matching element");
+ }
+
+ return qry[0];
+ }
+
+ async getDmChannels() {
+ const qry = await Channel.getRepository()
+ .createQueryBuilder("channel")
+ .leftJoinAndSelect("channel.recipients", "rcp")
+ .where("channel.type = :type", { type: ChannelType.DM })
+ .andWhere("rcp.user_id = :user_id", { user_id: this.id })
+ .groupBy("channel.id")
+ .addGroupBy("rcp.id")
+ .having("COUNT(rcp.id) = 2")
+ .getMany();
+
+ return qry;
+ }
+}
diff --git a/src/database/entities/UserSettings.ts b/src/database/entities/UserSettings.ts
new file mode 100644
index 00000000..7ccb8e1e
--- /dev/null
+++ b/src/database/entities/UserSettings.ts
@@ -0,0 +1,142 @@
+/*
+ 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 { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
+import { BaseClassWithoutId } from "./BaseClass";
+import { CustomStatus, FriendSourceFlags, GuildFolder } from "@spacebar/schemas";
+
+@Entity({
+ name: "user_settings",
+})
+export class UserSettings extends BaseClassWithoutId {
+ @PrimaryGeneratedColumn()
+ index: string;
+
+ @Column({ nullable: true })
+ afk_timeout: number = 3600;
+
+ @Column({ nullable: true })
+ allow_accessibility_detection: boolean = true;
+
+ @Column({ nullable: true })
+ animate_emoji: boolean = true;
+
+ @Column({ nullable: true })
+ animate_stickers: number = 0;
+
+ @Column({ nullable: true })
+ contact_sync_enabled: boolean = false;
+
+ @Column({ nullable: true })
+ convert_emoticons: boolean = false;
+
+ @Column({ nullable: true, type: "jsonb" })
+ custom_status: CustomStatus | null = null;
+
+ @Column({ nullable: true })
+ default_guilds_restricted: boolean = false;
+
+ @Column({ nullable: true })
+ detect_platform_accounts: boolean = false;
+
+ @Column({ nullable: true })
+ developer_mode: boolean = true;
+
+ @Column({ nullable: true })
+ disable_games_tab: boolean = true;
+
+ @Column({ nullable: true })
+ enable_tts_command: boolean = false;
+
+ @Column({ nullable: true })
+ explicit_content_filter: number = 0;
+
+ @Column({ nullable: true })
+ friend_discovery_flags: number = 0;
+
+ @Column({ nullable: true, type: "jsonb" })
+ friend_source_flags: FriendSourceFlags = { all: true };
+
+ @Column({ nullable: true })
+ gateway_connected: boolean = false;
+
+ @Column({ nullable: true })
+ gif_auto_play: boolean = false;
+
+ @Column({ nullable: true, type: "jsonb" })
+ guild_folders: GuildFolder[] = []; // every top guild is displayed as a "folder"
+
+ @Column({ nullable: true, type: "jsonb" })
+ guild_positions: string[] = []; // guild ids ordered by position
+
+ @Column({ nullable: true })
+ inline_attachment_media: boolean = true;
+
+ @Column({ nullable: true })
+ inline_embed_media: boolean = true;
+
+ @Column({ nullable: true })
+ locale: string = "en-US"; // en_US
+
+ @Column({ nullable: true })
+ message_display_compact: boolean = false;
+
+ @Column({ nullable: true })
+ native_phone_integration_enabled: boolean = true;
+
+ @Column({ nullable: true })
+ render_embeds: boolean = true;
+
+ @Column({ nullable: true })
+ render_reactions: boolean = true;
+
+ @Column({ nullable: true, type: "jsonb" })
+ restricted_guilds: string[] = [];
+
+ @Column({ nullable: true })
+ show_current_game: boolean = true;
+
+ @Column({ nullable: true })
+ status: "online" | "offline" | "dnd" | "idle" | "invisible" = "online";
+
+ @Column({ nullable: true })
+ stream_notifications_enabled: boolean = false;
+
+ @Column({ nullable: true })
+ theme: "dark" | "light" = "dark"; // dark
+
+ @Column({ nullable: true })
+ timezone_offset: number = 0; // e.g -60
+
+ @Column({ nullable: true })
+ view_nsfw_guilds: boolean = true;
+
+ public static async getOrDefault(userId: string) {
+ // raw sql query
+ const userSettingsIndex = (await this.getRepository().query('SELECT "settingsIndex" FROM users WHERE id = $1', [userId]))[0]?.settingsIndex as string | null;
+
+ console.log(`[INFO/UserSettings] Fetched settings index for user ${userId}:`, userSettingsIndex);
+
+ if (!userSettingsIndex) return new UserSettings();
+
+ const settings = await UserSettings.findOne({ where: { index: userSettingsIndex } });
+ if (!settings) return new UserSettings();
+
+ return settings;
+ }
+}
diff --git a/src/database/entities/UserSettingsProtos.ts b/src/database/entities/UserSettingsProtos.ts
new file mode 100644
index 00000000..c8ee9d12
--- /dev/null
+++ b/src/database/entities/UserSettingsProtos.ts
@@ -0,0 +1,145 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2025 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 { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from "typeorm";
+import { BaseClassWithoutId } from "./BaseClass";
+import { User } from "./User";
+import { FrecencyUserSettings, PreloadedUserSettings } from "discord-protos";
+
+@Entity({
+ name: "user_settings_protos",
+})
+export class UserSettingsProtos extends BaseClassWithoutId {
+ @OneToOne(() => User, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ eager: false,
+ })
+ @JoinColumn({ name: "user_id" })
+ user: User;
+
+ @PrimaryColumn({ type: "text" })
+ user_id: string;
+
+ @Column({ nullable: true, type: String, name: "userSettings" })
+ _userSettings: string | undefined;
+
+ @Column({ nullable: true, type: String, name: "frecencySettings" })
+ _frecencySettings: string | undefined;
+
+ // @Column({nullable: true, type: "simple-json"})
+ // testSettings: {};
+
+ bigintReplacer(_key: string, value: unknown): unknown {
+ if (typeof value === "bigint") {
+ return (value as bigint).toString();
+ } else if (value instanceof Uint8Array) {
+ return {
+ __type: "Uint8Array",
+ data: Array.from(value as Uint8Array)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join(""),
+ };
+ } else {
+ return value;
+ }
+ }
+
+ bigintReviver(_key: string, value: unknown): unknown {
+ if (typeof value === "string" && /^\d+n$/.test(value)) {
+ return BigInt((value as string).slice(0, -1));
+ } else if (typeof value === "object" && value !== null && "__type" in value) {
+ if (value.__type === "Uint8Array" && "data" in value) {
+ return new Uint8Array((value.data as string).match(/.{1,2}/g)!.map((byte: string) => parseInt(byte, 16)));
+ }
+ }
+ return value;
+ }
+
+ get userSettings(): PreloadedUserSettings | undefined {
+ if (!this._userSettings) return undefined;
+ return PreloadedUserSettings.fromJson(JSON.parse(this._userSettings, this.bigintReviver));
+ }
+
+ set userSettings(value: PreloadedUserSettings | undefined) {
+ if (value) {
+ // this._userSettings = JSON.stringify(value, this.bigintReplacer);
+ this._userSettings = PreloadedUserSettings.toJsonString(value);
+ } else {
+ this._userSettings = undefined;
+ }
+ }
+
+ get frecencySettings(): FrecencyUserSettings | undefined {
+ if (!this._frecencySettings) return undefined;
+ return FrecencyUserSettings.fromJson(JSON.parse(this._frecencySettings, this.bigintReviver));
+ }
+
+ set frecencySettings(value: FrecencyUserSettings | undefined) {
+ if (value) {
+ this._frecencySettings = JSON.stringify(value, this.bigintReplacer);
+ } else {
+ this._frecencySettings = undefined;
+ }
+ }
+
+ static async getOrDefault(user_id: string, save: boolean = false): Promise<UserSettingsProtos> {
+ await User.findOneOrFail({
+ where: { id: user_id },
+ select: { settings: true },
+ });
+
+ let userSettings = await UserSettingsProtos.findOne({
+ where: { user_id },
+ });
+
+ let modified = false;
+ if (!userSettings) {
+ userSettings = UserSettingsProtos.create({
+ user_id,
+ });
+ modified = true;
+ }
+
+ if (!userSettings.userSettings) {
+ userSettings.userSettings = PreloadedUserSettings.create({
+ versions: {
+ dataVersion: 0,
+ clientVersion: 0,
+ serverVersion: 0,
+ },
+ });
+ modified = true;
+ }
+
+ if (!userSettings.frecencySettings) {
+ userSettings.frecencySettings = FrecencyUserSettings.create({
+ versions: {
+ dataVersion: 0,
+ clientVersion: 0,
+ serverVersion: 0,
+ },
+ });
+ modified = true;
+ }
+
+ if (modified && save) userSettings = await userSettings.save();
+
+ return userSettings;
+ }
+}
diff --git a/src/database/entities/ValidRegistrationTokens.ts b/src/database/entities/ValidRegistrationTokens.ts
new file mode 100644
index 00000000..f8cb35a4
--- /dev/null
+++ b/src/database/entities/ValidRegistrationTokens.ts
@@ -0,0 +1,33 @@
+/*
+ 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 { BaseEntity, Column, Entity, PrimaryColumn } from "typeorm";
+
+@Entity({
+ name: "valid_registration_tokens",
+})
+export class ValidRegistrationToken extends BaseEntity {
+ @PrimaryColumn()
+ token: string;
+
+ @Column()
+ created_at: Date = new Date();
+
+ @Column()
+ expires_at: Date;
+}
diff --git a/src/database/entities/VoiceState.ts b/src/database/entities/VoiceState.ts
new file mode 100644
index 00000000..5abdb7da
--- /dev/null
+++ b/src/database/entities/VoiceState.ts
@@ -0,0 +1,107 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Channel } from "./Channel";
+import { Guild } from "./Guild";
+import { Member } from "./Member";
+import { User } from "./User";
+import { PublicVoiceState, PublicVoiceStateProjection } from "@spacebar/schemas";
+
+//https://gist.github.com/vassjozsef/e482c65df6ee1facaace8b3c9ff66145#file-voice_state-ex
+@Entity({
+ name: "voice_states",
+})
+export class VoiceState extends BaseClass {
+ @Column({ nullable: true })
+ @RelationId((voice_state: VoiceState) => voice_state.guild)
+ guild_id: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, (guild) => guild.voice_states, {
+ onDelete: "CASCADE",
+ })
+ guild?: Guild;
+
+ @Column({ nullable: true })
+ @RelationId((voice_state: VoiceState) => voice_state.channel)
+ channel_id: string;
+
+ @JoinColumn({ name: "channel_id" })
+ @ManyToOne(() => Channel, {
+ onDelete: "CASCADE",
+ })
+ channel: Channel;
+
+ @Column({ nullable: true })
+ @RelationId((voice_state: VoiceState) => voice_state.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ // @JoinColumn([{ name: "user_id", referencedColumnName: "id" },{ name: "guild_id", referencedColumnName: "guild_id" }])
+ // @ManyToOne(() => Member, {
+ // onDelete: "CASCADE",
+ // })
+ //TODO find a way to make it work without breaking Guild.voice_states
+ member: Member;
+
+ @Column()
+ session_id: string;
+
+ @Column({ nullable: true })
+ token: string;
+
+ @Column()
+ deaf: boolean;
+
+ @Column()
+ mute: boolean;
+
+ @Column()
+ self_deaf: boolean;
+
+ @Column()
+ self_mute: boolean;
+
+ @Column({ nullable: true })
+ self_stream?: boolean;
+
+ @Column()
+ self_video: boolean;
+
+ @Column()
+ suppress: boolean; // whether this user is muted by the current user
+
+ @Column({ nullable: true, default: null })
+ request_to_speak_timestamp?: Date;
+
+ toPublicVoiceState() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const voiceState: any = {};
+ PublicVoiceStateProjection.forEach((x) => {
+ voiceState[x] = this[x];
+ });
+ return voiceState as PublicVoiceState;
+ }
+}
diff --git a/src/database/entities/Webhook.ts b/src/database/entities/Webhook.ts
new file mode 100644
index 00000000..f631c393
--- /dev/null
+++ b/src/database/entities/Webhook.ts
@@ -0,0 +1,104 @@
+/*
+ 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 { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { Application } from "./Application";
+import { BaseClass } from "./BaseClass";
+import { Channel } from "./Channel";
+import { Guild } from "./Guild";
+import { User } from "./User";
+import { WebhookType } from "@spacebar/schemas";
+
+@Entity({
+ name: "webhooks",
+})
+export class Webhook extends BaseClass {
+ @Column({ type: "int" })
+ type: WebhookType;
+
+ @Column({ nullable: true })
+ name: string;
+
+ @Column({ nullable: true })
+ avatar: string;
+
+ @Column({ nullable: true })
+ token?: string;
+
+ @Column({ nullable: true })
+ @RelationId((webhook: Webhook) => webhook.guild)
+ guild_id?: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, {
+ onDelete: "CASCADE",
+ })
+ guild?: Guild;
+
+ @Column({ nullable: true })
+ @RelationId((webhook: Webhook) => webhook.channel)
+ channel_id: string;
+
+ @JoinColumn({ name: "channel_id" })
+ @ManyToOne(() => Channel, {
+ onDelete: "CASCADE",
+ })
+ channel: Channel;
+
+ @Column({ nullable: true })
+ @RelationId((webhook: Webhook) => webhook.application)
+ application_id: string;
+
+ @JoinColumn({ name: "application_id" })
+ @ManyToOne(() => Application, {
+ onDelete: "CASCADE",
+ })
+ application: Application;
+
+ @Column({ nullable: true })
+ @RelationId((webhook: Webhook) => webhook.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ @Column({ nullable: true })
+ @RelationId((webhook: Webhook) => webhook.guild)
+ source_guild_id?: string;
+
+ @JoinColumn({ name: "source_guild_id" })
+ @ManyToOne(() => Guild, {
+ onDelete: "CASCADE",
+ })
+ source_guild?: Guild;
+
+ @Column({ nullable: true })
+ @RelationId((webhook: Webhook) => webhook.channel)
+ source_channel_id: string;
+
+ @JoinColumn({ name: "source_channel_id" })
+ @ManyToOne(() => Channel, {
+ onDelete: "CASCADE",
+ })
+ source_channel: Channel;
+
+ url: string;
+}
diff --git a/src/database/entities/index.ts b/src/database/entities/index.ts
new file mode 100644
index 00000000..79fceecc
--- /dev/null
+++ b/src/database/entities/index.ts
@@ -0,0 +1,66 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2024 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/>.
+*/
+
+export * from "./Application";
+export * from "./ApplicationCommand";
+export * from "./Attachment";
+export * from "./AuditLog";
+export * from "./AutomodRule";
+export * from "./BackupCodes";
+export * from "./Badge";
+export * from "./Ban";
+export * from "./BaseClass";
+export * from "./Categories";
+export * from "./Channel";
+export * from "./ClientRelease";
+export * from "./CloudAttachment";
+export * from "./Config";
+export * from "./ConnectedAccount";
+export * from "./ConnectionConfigEntity";
+export * from "./EmbedCache";
+export * from "./Emoji";
+export * from "./Encryption";
+export * from "./Guild";
+export * from "./InstanceBan";
+export * from "./Invite";
+export * from "./Member";
+export * from "./Message";
+export * from "./Migration";
+export * from "./Note";
+export * from "./RateLimit";
+export * from "./ReadState";
+export * from "./Recipient";
+export * from "./Relationship";
+export * from "./Role";
+export * from "./SecurityKey";
+export * from "./Session";
+export * from "./Sticker";
+export * from "./StickerPack";
+export * from "./Stream";
+export * from "./StreamSession";
+export * from "./Team";
+export * from "./TeamMember";
+export * from "./Template";
+export * from "./ThreadMember";
+export * from "./User";
+export * from "./UserSettings";
+export * from "./UserSettingsProtos";
+export * from "./ValidRegistrationTokens";
+export * from "./VoiceState";
+export * from "./Webhook";
+export * from "./Tag";
|