diff --git a/src/util/dtos/DmChannelDTO.ts b/src/util/dtos/DmChannelDTO.ts
new file mode 100644
index 00000000..226b2f9d
--- /dev/null
+++ b/src/util/dtos/DmChannelDTO.ts
@@ -0,0 +1,41 @@
+import { MinimalPublicUserDTO } from "./UserDTO";
+import { Channel, PublicUserProjection, User } from "../entities";
+
+export class DmChannelDTO {
+ icon: string | null;
+ id: string;
+ last_message_id: string | null;
+ name: string | null;
+ origin_channel_id: string | null;
+ owner_id?: string;
+ recipients: MinimalPublicUserDTO[];
+ type: number;
+
+ static async from(channel: Channel, excluded_recipients: string[] = [], origin_channel_id?: string) {
+ const obj = new DmChannelDTO();
+ obj.icon = channel.icon || null;
+ obj.id = channel.id;
+ obj.last_message_id = channel.last_message_id || null;
+ obj.name = channel.name || null;
+ obj.origin_channel_id = origin_channel_id || null;
+ obj.owner_id = channel.owner_id;
+ obj.type = channel.type;
+ obj.recipients = (
+ await Promise.all(
+ channel
+ .recipients!.filter((r) => !excluded_recipients.includes(r.user_id))
+ .map(async (r) => {
+ return await User.findOneOrFail({ where: { id: r.user_id }, select: PublicUserProjection });
+ })
+ )
+ ).map((u) => new MinimalPublicUserDTO(u));
+ return obj;
+ }
+
+ excludedRecipients(excluded_recipients: string[]): DmChannelDTO {
+ return {
+ ...this,
+ recipients: this.recipients.filter((r) => !excluded_recipients.includes(r.id)),
+ };
+ }
+}
diff --git a/src/util/dtos/UserDTO.ts b/src/util/dtos/UserDTO.ts
new file mode 100644
index 00000000..ee2752a4
--- /dev/null
+++ b/src/util/dtos/UserDTO.ts
@@ -0,0 +1,17 @@
+import { User } from "../entities";
+
+export class MinimalPublicUserDTO {
+ avatar?: string | null;
+ discriminator: string;
+ id: string;
+ public_flags: number;
+ username: string;
+
+ constructor(user: User) {
+ this.avatar = user.avatar;
+ this.discriminator = user.discriminator;
+ this.id = user.id;
+ this.public_flags = user.public_flags;
+ this.username = user.username;
+ }
+}
diff --git a/src/util/dtos/index.ts b/src/util/dtos/index.ts
new file mode 100644
index 00000000..0e8f8459
--- /dev/null
+++ b/src/util/dtos/index.ts
@@ -0,0 +1,2 @@
+export * from "./DmChannelDTO";
+export * from "./UserDTO";
diff --git a/src/util/entities/Application.ts b/src/util/entities/Application.ts
new file mode 100644
index 00000000..fab3d93f
--- /dev/null
+++ b/src/util/entities/Application.ts
@@ -0,0 +1,109 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { Team } from "./Team";
+import { User } from "./User";
+
+@Entity("applications")
+export class Application extends BaseClass {
+ @Column()
+ name: string;
+
+ @Column({ nullable: true })
+ icon?: string;
+
+ @Column()
+ description: string;
+
+ @Column({ type: "simple-array", nullable: true })
+ rpc_origins?: string[];
+
+ @Column()
+ bot_public: boolean;
+
+ @Column()
+ bot_require_code_grant: boolean;
+
+ @Column({ nullable: true })
+ terms_of_service_url?: string;
+
+ @Column({ nullable: true })
+ privacy_policy_url?: string;
+
+ @JoinColumn({ name: "owner_id" })
+ @ManyToOne(() => User)
+ owner?: User;
+
+ @Column({ nullable: true })
+ summary?: string;
+
+ @Column()
+ verify_key: string;
+
+ @JoinColumn({ name: "team_id" })
+ @ManyToOne(() => Team, {
+ onDelete: "CASCADE",
+ })
+ team?: Team;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild)
+ guild: Guild; // if this application is a game sold, this field will be the guild to which it has been linked
+
+ @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
+
+ @Column({ nullable: true })
+ cover_image?: string; // the application's default rich presence invite cover image hash
+
+ @Column()
+ flags: string; // the application's public flags
+}
+
+export interface ApplicationCommand {
+ id: string;
+ application_id: string;
+ name: string;
+ description: string;
+ options?: ApplicationCommandOption[];
+}
+
+export interface ApplicationCommandOption {
+ type: ApplicationCommandOptionType;
+ name: string;
+ description: string;
+ required?: boolean;
+ choices?: ApplicationCommandOptionChoice[];
+ options?: ApplicationCommandOption[];
+}
+
+export interface ApplicationCommandOptionChoice {
+ name: string;
+ value: string | number;
+}
+
+export enum ApplicationCommandOptionType {
+ SUB_COMMAND = 1,
+ SUB_COMMAND_GROUP = 2,
+ STRING = 3,
+ INTEGER = 4,
+ BOOLEAN = 5,
+ USER = 6,
+ CHANNEL = 7,
+ ROLE = 8,
+}
+
+export interface ApplicationCommandInteractionData {
+ id: string;
+ name: string;
+ options?: ApplicationCommandInteractionDataOption[];
+}
+
+export interface ApplicationCommandInteractionDataOption {
+ name: string;
+ value?: any;
+ options?: ApplicationCommandInteractionDataOption[];
+}
diff --git a/src/util/entities/Attachment.ts b/src/util/entities/Attachment.ts
new file mode 100644
index 00000000..7b4b17eb
--- /dev/null
+++ b/src/util/entities/Attachment.ts
@@ -0,0 +1,43 @@
+import { BeforeRemove, Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { URL } from "url";
+import { deleteFile } from "../util/cdn";
+import { BaseClass } from "./BaseClass";
+
+@Entity("attachments")
+export class Attachment extends BaseClass {
+ @Column()
+ filename: string; // name of file attached
+
+ @Column()
+ size: number; // size of file in bytes
+
+ @Column()
+ url: string; // source url of file
+
+ @Column()
+ proxy_url: string; // a proxied url of file
+
+ @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;
+
+ @JoinColumn({ name: "message_id" })
+ @ManyToOne(() => require("./Message").Message, (message: import("./Message").Message) => message.attachments, {
+ onDelete: "CASCADE",
+ })
+ message: import("./Message").Message;
+
+ @BeforeRemove()
+ onDelete() {
+ return deleteFile(new URL(this.url).pathname);
+ }
+}
diff --git a/src/util/entities/AuditLog.ts b/src/util/entities/AuditLog.ts
new file mode 100644
index 00000000..b003e7ba
--- /dev/null
+++ b/src/util/entities/AuditLog.ts
@@ -0,0 +1,194 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { ChannelPermissionOverwrite } from "./Channel";
+import { User } from "./User";
+
+export enum AuditLogEvents {
+ // guild level
+ GUILD_UPDATE = 1,
+ GUILD_IMPORT = 2,
+ GUILD_EXPORTED = 3,
+ GUILD_ARCHIVE = 4,
+ GUILD_UNARCHIVE = 5,
+ // join-leave
+ USER_JOIN = 6,
+ USER_LEAVE = 7,
+ // channels
+ CHANNEL_CREATE = 10,
+ CHANNEL_UPDATE = 11,
+ CHANNEL_DELETE = 12,
+ // permission overrides
+ CHANNEL_OVERWRITE_CREATE = 13,
+ CHANNEL_OVERWRITE_UPDATE = 14,
+ CHANNEL_OVERWRITE_DELETE = 15,
+ // kick and ban
+ MEMBER_KICK = 20,
+ MEMBER_PRUNE = 21,
+ MEMBER_BAN_ADD = 22,
+ MEMBER_BAN_REMOVE = 23,
+ // member updates
+ MEMBER_UPDATE = 24,
+ MEMBER_ROLE_UPDATE = 25,
+ MEMBER_MOVE = 26,
+ MEMBER_DISCONNECT = 27,
+ BOT_ADD = 28,
+ // roles
+ ROLE_CREATE = 30,
+ ROLE_UPDATE = 31,
+ ROLE_DELETE = 32,
+ ROLE_SWAP = 33,
+ // invites
+ INVITE_CREATE = 40,
+ INVITE_UPDATE = 41,
+ INVITE_DELETE = 42,
+ // webhooks
+ WEBHOOK_CREATE = 50,
+ WEBHOOK_UPDATE = 51,
+ WEBHOOK_DELETE = 52,
+ WEBHOOK_SWAP = 53,
+ // custom emojis
+ EMOJI_CREATE = 60,
+ EMOJI_UPDATE = 61,
+ EMOJI_DELETE = 62,
+ EMOJI_SWAP = 63,
+ // deletion
+ MESSAGE_CREATE = 70, // messages sent using non-primary seat of the user only
+ MESSAGE_EDIT = 71, // non-self edits only
+ MESSAGE_DELETE = 72,
+ MESSAGE_BULK_DELETE = 73,
+ // pinning
+ MESSAGE_PIN = 74,
+ MESSAGE_UNPIN = 75,
+ // integrations
+ INTEGRATION_CREATE = 80,
+ INTEGRATION_UPDATE = 81,
+ INTEGRATION_DELETE = 82,
+ // stage actions
+ STAGE_INSTANCE_CREATE = 83,
+ STAGE_INSTANCE_UPDATE = 84,
+ STAGE_INSTANCE_DELETE = 85,
+ // stickers
+ STICKER_CREATE = 90,
+ STICKER_UPDATE = 91,
+ STICKER_DELETE = 92,
+ STICKER_SWAP = 93,
+ // threads
+ THREAD_CREATE = 110,
+ THREAD_UPDATE = 111,
+ THREAD_DELETE = 112,
+ // application commands
+ APPLICATION_COMMAND_PERMISSION_UPDATE = 121,
+ // automod
+ POLICY_CREATE = 140,
+ POLICY_UPDATE = 141,
+ POLICY_DELETE = 142,
+ MESSAGE_BLOCKED_BY_POLICIES = 143, // in fosscord, blocked messages are stealth-dropped
+ // instance policies affecting the guild
+ GUILD_AFFECTED_BY_POLICIES = 216,
+ // message moves
+ IN_GUILD_MESSAGE_MOVE = 223,
+ CROSS_GUILD_MESSAGE_MOVE = 224,
+ // message routing
+ ROUTE_CREATE = 225,
+ ROUTE_UPDATE = 226,
+}
+
+@Entity("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: "simple-json", 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: "simple-json" })
+ changes: AuditLogChange[];
+
+ @Column({ nullable: true })
+ reason?: string;
+}
+
+export interface AuditLogChange {
+ new_value?: AuditLogChangeValue;
+ old_value?: AuditLogChangeValue;
+ key: string;
+}
+
+export interface AuditLogChangeValue {
+ name?: string;
+ description?: string;
+ icon_hash?: string;
+ splash_hash?: string;
+ discovery_splash_hash?: string;
+ banner_hash?: string;
+ owner_id?: string;
+ region?: string;
+ preferred_locale?: string;
+ afk_channel_id?: string;
+ afk_timeout?: number;
+ rules_channel_id?: string;
+ public_updates_channel_id?: string;
+ mfa_level?: number;
+ verification_level?: number;
+ explicit_content_filter?: number;
+ default_message_notifications?: number;
+ vanity_url_code?: string;
+ $add?: {}[];
+ $remove?: {}[];
+ prune_delete_days?: number;
+ widget_enabled?: boolean;
+ widget_channel_id?: string;
+ system_channel_id?: string;
+ position?: number;
+ topic?: string;
+ bitrate?: number;
+ permission_overwrites?: ChannelPermissionOverwrite[];
+ nsfw?: boolean;
+ application_id?: string;
+ rate_limit_per_user?: number;
+ permissions?: string;
+ color?: number;
+ hoist?: boolean;
+ mentionable?: boolean;
+ allow?: string;
+ deny?: string;
+ code?: string;
+ channel_id?: string;
+ inviter_id?: string;
+ max_uses?: number;
+ uses?: number;
+ max_age?: number;
+ temporary?: boolean;
+ deaf?: boolean;
+ mute?: boolean;
+ nick?: string;
+ avatar_hash?: string;
+ id?: string;
+ type?: number;
+ enable_emoticons?: boolean;
+ expire_behavior?: number;
+ expire_grace_period?: number;
+ user_limit?: number;
+}
diff --git a/src/util/entities/BackupCodes.ts b/src/util/entities/BackupCodes.ts
new file mode 100644
index 00000000..d532a39a
--- /dev/null
+++ b/src/util/entities/BackupCodes.ts
@@ -0,0 +1,35 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+import crypto from "crypto";
+
+@Entity("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) {
+ let backup_codes: BackupCode[] = [];
+ for (let i = 0; i < 10; 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;
+}
\ No newline at end of file
diff --git a/src/util/entities/Ban.ts b/src/util/entities/Ban.ts
new file mode 100644
index 00000000..9504bd8e
--- /dev/null
+++ b/src/util/entities/Ban.ts
@@ -0,0 +1,41 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { User } from "./User";
+
+@Entity("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()
+ ip: string;
+
+ @Column({ nullable: true })
+ reason?: string;
+}
diff --git a/src/util/entities/BaseClass.ts b/src/util/entities/BaseClass.ts
new file mode 100644
index 00000000..d5a7c2bf
--- /dev/null
+++ b/src/util/entities/BaseClass.ts
@@ -0,0 +1,52 @@
+import "reflect-metadata";
+import { BaseEntity, BeforeInsert, BeforeUpdate, FindOptionsWhere, ObjectIdColumn, PrimaryColumn } from "typeorm";
+import { Snowflake } from "../util/Snowflake";
+import "missing-native-js-functions";
+import { getDatabase } from "..";
+import { OrmUtils } from "@fosscord/util";
+
+export class BaseClassWithoutId extends BaseEntity {
+ private get construct(): any {
+ return this.constructor;
+ }
+
+ private get metadata() {
+ return getDatabase()?.getMetadata(this.construct);
+ }
+
+ assign(props: any) {
+ OrmUtils.mergeDeep(this, props);
+ return this;
+ }
+
+ toJSON(): any {
+ return Object.fromEntries(
+ this.metadata!.columns // @ts-ignore
+ .map((x) => [x.propertyName, this[x.propertyName]]) // @ts-ignore
+ .concat(this.metadata.relations.map((x) => [x.propertyName, 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);
+ }
+}
+
+export const PrimaryIdColumn = process.env.DATABASE?.startsWith("mongodb") ? ObjectIdColumn : PrimaryColumn;
+
+export class BaseClass extends BaseClassWithoutId {
+ @PrimaryIdColumn()
+ id: string;
+
+ @BeforeUpdate()
+ @BeforeInsert()
+ do_validate() {
+ if (!this.id) this.id = Snowflake.generate();
+ }
+}
diff --git a/src/util/entities/Categories.ts b/src/util/entities/Categories.ts
new file mode 100644
index 00000000..81fbc303
--- /dev/null
+++ b/src/util/entities/Categories.ts
@@ -0,0 +1,33 @@
+import { PrimaryColumn, Column, Entity} 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("categories")
+export class Categories extends BaseClassWithoutId { // Not using snowflake
+
+ @PrimaryColumn()
+ id: number;
+
+ @Column({ nullable: true })
+ name: string;
+
+ @Column({ type: "simple-json" })
+ localizations: string;
+
+ @Column({ nullable: true })
+ is_primary: boolean;
+
+}
\ No newline at end of file
diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts
new file mode 100644
index 00000000..577b627e
--- /dev/null
+++ b/src/util/entities/Channel.ts
@@ -0,0 +1,390 @@
+import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { PublicUserProjection, User } from "./User";
+import { HTTPError } from "lambert-server";
+import { containsAll, emitEvent, getPermission, Snowflake, trimSpecial, InvisibleCharacters, ChannelTypes } from "../util";
+import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces";
+import { Recipient } from "./Recipient";
+import { Message } from "./Message";
+import { ReadState } from "./ReadState";
+import { Invite } from "./Invite";
+import { VoiceState } from "./VoiceState";
+import { Webhook } from "./Webhook";
+import { DmChannelDTO } from "../dtos";
+
+export enum ChannelType {
+ GUILD_TEXT = 0, // a text channel within a guild
+ DM = 1, // a direct message between users
+ GUILD_VOICE = 2, // a voice channel within a guild
+ GROUP_DM = 3, // a direct message between multiple users
+ GUILD_CATEGORY = 4, // an organizational category that contains zero or more channels
+ GUILD_NEWS = 5, // a channel that users can follow and crosspost into a guild or route
+ GUILD_STORE = 6, // a channel in which game developers can sell their things
+ ENCRYPTED = 7, // end-to-end encrypted channel
+ ENCRYPTED_THREAD = 8, // end-to-end encrypted thread channel
+ TRANSACTIONAL = 9, // event chain style transactional channel
+ GUILD_NEWS_THREAD = 10, // a temporary sub-channel within a GUILD_NEWS channel
+ GUILD_PUBLIC_THREAD = 11, // a temporary sub-channel within a GUILD_TEXT channel
+ GUILD_PRIVATE_THREAD = 12, // a temporary sub-channel within a GUILD_TEXT channel that is only viewable by those invited and those with the MANAGE_THREADS permission
+ GUILD_STAGE_VOICE = 13, // a voice channel for hosting events with an audience
+ DIRECTORY = 14, // guild directory listing channel
+ GUILD_FORUM = 15, // forum composed of IM threads
+ TICKET_TRACKER = 33, // ticket tracker, individual ticket items shall have type 12
+ KANBAN = 34, // confluence like kanban board
+ VOICELESS_WHITEBOARD = 35, // whiteboard but without voice (whiteboard + voice is the same as stage)
+ CUSTOM_START = 64, // start custom channel types from here
+ UNHANDLED = 255 // unhandled unowned pass-through channel type
+}
+
+@Entity("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[];
+
+ @Column({ nullable: true })
+ last_message_id?: string;
+
+ @Column({ nullable: true })
+ @RelationId((channel: Channel) => channel.guild)
+ guild_id?: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, {
+ onDelete: "CASCADE",
+ })
+ guild: Guild;
+
+ @Column({ nullable: true })
+ @RelationId((channel: Channel) => channel.parent)
+ parent_id: string;
+
+ @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 })
+ last_pin_timestamp?: number;
+
+ @Column({ nullable: true })
+ default_auto_archive_duration?: number;
+
+ @Column({ nullable: true })
+ position?: number;
+
+ @Column({ type: "simple-json", 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[];
+
+ // TODO: DM channel
+ static async createChannel(
+ channel: Partial<Channel>,
+ user_id: string = "0",
+ opts?: {
+ keepId?: boolean;
+ skipExistsCheck?: boolean;
+ skipPermissionCheck?: boolean;
+ skipEventEmit?: boolean;
+ skipNameChecks?: boolean;
+ }
+ ) {
+ if (!opts?.skipPermissionCheck) {
+ // Always check if user has permission first
+ const permissions = await getPermission(user_id, channel.guild_id);
+ permissions.hasThrow("MANAGE_CHANNELS");
+ }
+
+ if (!opts?.skipNameChecks) {
+ const guild = await Guild.findOneOrFail({ where: { id: channel.guild_id } });
+ if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) {
+ for (var 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) {
+ 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) {
+ case ChannelType.GUILD_TEXT:
+ 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
+
+ channel = {
+ ...channel,
+ ...(!opts?.keepId && { id: Snowflake.generate() }),
+ created_at: new Date(),
+ position: (channel.type === ChannelType.UNHANDLED ? 0 : channel.position) || 0,
+ };
+
+ await Promise.all([
+ Channel.create(channel).save(),
+ !opts?.skipEventEmit
+ ? emitEvent({
+ event: "CHANNEL_CREATE",
+ data: channel,
+ guild_id: channel.guild_id,
+ } as ChannelCreateEvent)
+ : Promise.resolve(),
+ ]);
+
+ return channel;
+ }
+
+ static async createDMChannel(recipients: string[], creator_user_id: string, name?: string) {
+ recipients = recipients.unique().filter((x) => x !== creator_user_id);
+ //@ts-ignore some typeorm typescript issue
+ const otherRecipientsUsers = await User.find({ where: recipients.map((x) => ({ id: x })) });
+
+ // TODO: check config for max number of recipients
+ /** if you want to disallow note to self channels, uncomment the conditional below
+ 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;
+
+ const channelRecipients = [...recipients, creator_user_id];
+
+ const userRecipients = await Recipient.find({
+ where: { user_id: creator_user_id },
+ relations: ["channel", "channel.recipients"],
+ });
+
+ for (let ur of userRecipients) {
+ let re = ur.channel.recipients!.map((r) => r.user_id);
+ if (re.length === channelRecipients.length) {
+ if (containsAll(re, channelRecipients)) {
+ if (channel == null) {
+ channel = ur.channel;
+ await ur.assign({ closed: false }).save();
+ }
+ }
+ }
+ }
+
+ if (channel == null) {
+ name = trimSpecial(name);
+
+ channel = await Channel.create({
+ name,
+ type,
+ owner_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 (type === ChannelType.GROUP_DM) {
+ for (let 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,
+ } as ChannelRecipientRemoveEvent);
+ }
+
+ static async deleteChannel(channel: Channel) {
+ await Message.delete({ channel_id: channel.id }); //TODO we should also delete the attachments from the cdn but to do that we need to move cdn.ts in util
+ //TODO before deleting the channel we should check and delete other relations
+ await Channel.delete({ id: channel.id });
+ }
+
+ isDm() {
+ return this.type === ChannelType.DM || this.type === ChannelType.GROUP_DM;
+ }
+
+ // Does the channel support sending messages ( eg categories do not )
+ isWritable() {
+ const disallowedChannelTypes = [
+ ChannelType.GUILD_CATEGORY,
+ ChannelType.GUILD_STAGE_VOICE,
+ ChannelType.VOICELESS_WHITEBOARD,
+ ];
+ return disallowedChannelTypes.indexOf(this.type) == -1;
+ }
+}
+
+export interface ChannelPermissionOverwrite {
+ allow: string;
+ deny: string;
+ id: string;
+ type: ChannelPermissionOverwriteType;
+}
+
+export enum ChannelPermissionOverwriteType {
+ role = 0,
+ member = 1,
+ group = 2,
+}
diff --git a/src/util/entities/ClientRelease.ts b/src/util/entities/ClientRelease.ts
new file mode 100644
index 00000000..c5afd307
--- /dev/null
+++ b/src/util/entities/ClientRelease.ts
@@ -0,0 +1,26 @@
+import { Column, Entity} from "typeorm";
+import { BaseClass } from "./BaseClass";
+
+@Entity("client_release")
+export class Release extends BaseClass {
+ @Column()
+ name: string;
+
+ @Column()
+ pub_date: string;
+
+ @Column()
+ url: string;
+
+ @Column()
+ deb_url: string;
+
+ @Column()
+ osx_url: string;
+
+ @Column()
+ win_url: string;
+
+ @Column({ nullable: true })
+ notes?: string;
+}
diff --git a/src/util/entities/Config.ts b/src/util/entities/Config.ts
new file mode 100644
index 00000000..9aabc1a8
--- /dev/null
+++ b/src/util/entities/Config.ts
@@ -0,0 +1,416 @@
+import { Column, Entity } from "typeorm";
+import { BaseClassWithoutId, PrimaryIdColumn } from "./BaseClass";
+import crypto from "crypto";
+import { Snowflake } from "../util/Snowflake";
+import { SessionsReplace } from "..";
+import { hostname } from "os";
+import { Rights } from "../util/Rights";
+
+@Entity("config")
+export class ConfigEntity extends BaseClassWithoutId {
+ @PrimaryIdColumn()
+ key: string;
+
+ @Column({ type: "simple-json", nullable: true })
+ value: number | boolean | null | string | undefined;
+}
+
+export interface RateLimitOptions {
+ bot?: number;
+ count: number;
+ window: number;
+ onyIp?: boolean;
+}
+
+export interface Region {
+ id: string;
+ name: string;
+ endpoint: string;
+ location?: {
+ latitude: number;
+ longitude: number;
+ };
+ vip: boolean;
+ custom: boolean;
+ deprecated: boolean;
+}
+
+export interface KafkaBroker {
+ ip: string;
+ port: number;
+}
+
+export interface ConfigValue {
+ gateway: {
+ endpointClient: string | null;
+ endpointPrivate: string | null;
+ endpointPublic: string | null;
+ };
+ cdn: {
+ endpointClient: string | null;
+ endpointPublic: string | null;
+ endpointPrivate: string | null;
+ resizeHeightMax: number | null;
+ resizeWidthMax: number | null;
+ };
+ api: {
+ defaultVersion: string;
+ activeVersions: string[];
+ useFosscordEnhancements: boolean;
+ };
+ general: {
+ instanceName: string;
+ instanceDescription: string | null;
+ frontPage: string | null;
+ tosPage: string | null;
+ correspondenceEmail: string | null;
+ correspondenceUserID: string | null;
+ image: string | null;
+ instanceId: string;
+ };
+ limits: {
+ user: {
+ maxGuilds: number;
+ maxUsername: number;
+ maxFriends: number;
+ };
+ guild: {
+ maxRoles: number;
+ maxEmojis: number;
+ maxMembers: number;
+ maxChannels: number;
+ maxChannelsInCategory: number;
+ hideOfflineMember: number;
+ };
+ message: {
+ maxCharacters: number;
+ maxTTSCharacters: number;
+ maxReactions: number;
+ maxAttachmentSize: number;
+ maxBulkDelete: number;
+ maxEmbedDownloadSize: number;
+ };
+ channel: {
+ maxPins: number;
+ maxTopic: number;
+ maxWebhooks: number;
+ };
+ rate: {
+ disabled: boolean;
+ ip: Omit<RateLimitOptions, "bot_count">;
+ global: RateLimitOptions;
+ error: RateLimitOptions;
+ routes: {
+ guild: RateLimitOptions;
+ webhook: RateLimitOptions;
+ channel: RateLimitOptions;
+ auth: {
+ login: RateLimitOptions;
+ register: RateLimitOptions;
+ };
+ // TODO: rate limit configuration for all routes
+ };
+ };
+ };
+ security: {
+ autoUpdate: boolean | number;
+ requestSignature: string;
+ jwtSecret: string;
+ forwadedFor: string | null; // header to get the real user ip address
+ captcha: {
+ enabled: boolean;
+ service: "recaptcha" | "hcaptcha" | null; // TODO: hcaptcha, custom
+ sitekey: string | null;
+ secret: string | null;
+ };
+ ipdataApiKey: string | null;
+ defaultRights: string;
+ };
+ login: {
+ requireCaptcha: boolean;
+ };
+ register: {
+ email: {
+ required: boolean;
+ allowlist: boolean;
+ blocklist: boolean;
+ domains: string[];
+ };
+ dateOfBirth: {
+ required: boolean;
+ minimum: number; // in years
+ };
+ disabled: boolean;
+ requireCaptcha: boolean;
+ requireInvite: boolean;
+ guestsRequireInvite: boolean;
+ allowNewRegistration: boolean;
+ allowMultipleAccounts: boolean;
+ blockProxies: boolean;
+ password: {
+ required: boolean;
+ minLength: number;
+ minNumbers: number;
+ minUpperCase: number;
+ minSymbols: number;
+ };
+ incrementingDiscriminators: boolean; // random otherwise
+ };
+ regions: {
+ default: string;
+ useDefaultAsOptimal: boolean;
+ available: Region[];
+ };
+ guild: {
+ discovery: {
+ showAllGuilds: boolean;
+ useRecommendation: boolean; // TODO: Recommendation, privacy concern?
+ offset: number;
+ limit: number;
+ };
+ autoJoin: {
+ enabled: boolean;
+ guilds: string[];
+ canLeave: boolean;
+ };
+ defaultFeatures: string[];
+ };
+ gif: {
+ enabled: boolean;
+ provider: "tenor"; // more coming soon
+ apiKey?: string;
+ };
+ rabbitmq: {
+ host: string | null;
+ };
+ kafka: {
+ brokers: KafkaBroker[] | null;
+ };
+ templates: {
+ enabled: Boolean;
+ allowTemplateCreation: Boolean;
+ allowDiscordTemplates: Boolean;
+ allowRaws: Boolean;
+ },
+ client: {
+ useTestClient: Boolean;
+ releases: {
+ useLocalRelease: Boolean; //TODO
+ upstreamVersion: string;
+ };
+ },
+ metrics: {
+ timeout: number;
+ },
+ sentry: {
+ enabled: boolean;
+ endpoint: string;
+ traceSampleRate: number;
+ environment: string;
+ };
+}
+
+export const DefaultConfigOptions: ConfigValue = {
+ gateway: {
+ endpointClient: null,
+ endpointPrivate: null,
+ endpointPublic: null,
+ },
+ cdn: {
+ endpointClient: null,
+ endpointPrivate: null,
+ endpointPublic: null,
+ resizeHeightMax: 1000,
+ resizeWidthMax: 1000,
+ },
+ api: {
+ defaultVersion: "9",
+ activeVersions: ["6", "7", "8", "9"],
+ useFosscordEnhancements: true,
+ },
+ general: {
+ instanceName: "Fosscord Instance",
+ instanceDescription: "This is a Fosscord instance made in pre-release days",
+ frontPage: null,
+ tosPage: null,
+ correspondenceEmail: "noreply@localhost.local",
+ correspondenceUserID: null,
+ image: null,
+ instanceId: Snowflake.generate(),
+ },
+ limits: {
+ user: {
+ maxGuilds: 1048576,
+ maxUsername: 127,
+ maxFriends: 5000,
+ },
+ guild: {
+ maxRoles: 1000,
+ maxEmojis: 2000,
+ maxMembers: 25000000,
+ maxChannels: 65535,
+ maxChannelsInCategory: 65535,
+ hideOfflineMember: 3,
+ },
+ message: {
+ maxCharacters: 1048576,
+ maxTTSCharacters: 160,
+ maxReactions: 2048,
+ maxAttachmentSize: 1024 * 1024 * 1024,
+ maxEmbedDownloadSize: 1024 * 1024 * 5,
+ maxBulkDelete: 1000,
+ },
+ channel: {
+ maxPins: 500,
+ maxTopic: 1024,
+ maxWebhooks: 100,
+ },
+ rate: {
+ disabled: true,
+ ip: {
+ count: 500,
+ window: 5,
+ },
+ global: {
+ count: 250,
+ window: 5,
+ },
+ error: {
+ count: 10,
+ window: 5,
+ },
+ routes: {
+ guild: {
+ count: 5,
+ window: 5,
+ },
+ webhook: {
+ count: 10,
+ window: 5,
+ },
+ channel: {
+ count: 10,
+ window: 5,
+ },
+ auth: {
+ login: {
+ count: 5,
+ window: 60,
+ },
+ register: {
+ count: 2,
+ window: 60 * 60 * 12,
+ },
+ },
+ },
+ },
+ },
+ security: {
+ autoUpdate: true,
+ requestSignature: crypto.randomBytes(32).toString("base64"),
+ jwtSecret: crypto.randomBytes(256).toString("base64"),
+ forwadedFor: null,
+ // forwadedFor: "X-Forwarded-For" // nginx/reverse proxy
+ // forwadedFor: "CF-Connecting-IP" // cloudflare:
+ captcha: {
+ enabled: false,
+ service: null,
+ sitekey: null,
+ secret: null,
+ },
+ ipdataApiKey: "eca677b284b3bac29eb72f5e496aa9047f26543605efe99ff2ce35c9",
+ defaultRights: "30644591655936", // See util/scripts/rights.js
+ },
+ login: {
+ requireCaptcha: false,
+ },
+ register: {
+ email: {
+ required: false,
+ allowlist: false,
+ blocklist: true,
+ domains: [], // TODO: efficiently save domain blocklist in database
+ // domains: fs.readFileSync(__dirname + "/blockedEmailDomains.txt", { encoding: "utf8" }).split("\n"),
+ },
+ dateOfBirth: {
+ required: true,
+ minimum: 13,
+ },
+ disabled: false,
+ requireInvite: false,
+ guestsRequireInvite: true,
+ requireCaptcha: true,
+ allowNewRegistration: true,
+ allowMultipleAccounts: true,
+ blockProxies: true,
+ password: {
+ required: false,
+ minLength: 8,
+ minNumbers: 2,
+ minUpperCase: 2,
+ minSymbols: 0,
+ },
+ incrementingDiscriminators: false,
+ },
+ regions: {
+ default: "fosscord",
+ useDefaultAsOptimal: true,
+ available: [
+ {
+ id: "fosscord",
+ name: "Fosscord",
+ endpoint: "127.0.0.1:3004",
+ vip: false,
+ custom: false,
+ deprecated: false,
+ },
+ ],
+ },
+ guild: {
+ discovery: {
+ showAllGuilds: false,
+ useRecommendation: false,
+ offset: 0,
+ limit: 24,
+ },
+ autoJoin: {
+ enabled: true,
+ canLeave: true,
+ guilds: [],
+ },
+ defaultFeatures: [],
+ },
+ gif: {
+ enabled: true,
+ provider: "tenor",
+ apiKey: "LIVDSRZULELA",
+ },
+ rabbitmq: {
+ host: null,
+ },
+ kafka: {
+ brokers: null,
+ },
+ templates: {
+ enabled: true,
+ allowTemplateCreation: true,
+ allowDiscordTemplates: true,
+ allowRaws: false
+ },
+ client: {
+ useTestClient: true,
+ releases: {
+ useLocalRelease: true,
+ upstreamVersion: "0.0.264"
+ }
+ },
+ metrics: {
+ timeout: 30000
+ },
+ sentry: {
+ enabled: false,
+ endpoint: "https://05e8e3d005f34b7d97e920ae5870a5e5@sentry.thearcanebrony.net/6",
+ traceSampleRate: 1.0,
+ environment: hostname()
+ }
+};
\ No newline at end of file
diff --git a/src/util/entities/ConnectedAccount.ts b/src/util/entities/ConnectedAccount.ts
new file mode 100644
index 00000000..09ae30ab
--- /dev/null
+++ b/src/util/entities/ConnectedAccount.ts
@@ -0,0 +1,42 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+
+export interface PublicConnectedAccount extends Pick<ConnectedAccount, "name" | "type" | "verified"> {}
+
+@Entity("connected_accounts")
+export class ConnectedAccount extends BaseClass {
+ @Column({ nullable: true })
+ @RelationId((account: ConnectedAccount) => account.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ @Column({ select: false })
+ access_token: string;
+
+ @Column({ select: false })
+ friend_sync: boolean;
+
+ @Column()
+ name: string;
+
+ @Column({ select: false })
+ revoked: boolean;
+
+ @Column({ select: false })
+ show_activity: boolean;
+
+ @Column()
+ type: string;
+
+ @Column()
+ verified: boolean;
+
+ @Column({ select: false })
+ visibility: number;
+}
diff --git a/src/util/entities/Emoji.ts b/src/util/entities/Emoji.ts
new file mode 100644
index 00000000..a3615b7d
--- /dev/null
+++ b/src/util/entities/Emoji.ts
@@ -0,0 +1,46 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { User } from ".";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { Role } from "./Role";
+
+@Entity("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, {
+ 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: "simple-array" })
+ roles: string[]; // roles this emoji is whitelisted to (new discord feature?)
+
+ @Column({ type: "simple-array", nullable: true })
+ groups: string[]; // user groups this emoji is whitelisted to (Fosscord extension)
+}
diff --git a/src/util/entities/Encryption.ts b/src/util/entities/Encryption.ts
new file mode 100644
index 00000000..b597b90a
--- /dev/null
+++ b/src/util/entities/Encryption.ts
@@ -0,0 +1,35 @@
+import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { PublicUserProjection, User } from "./User";
+import { HTTPError } from "lambert-server";
+import { containsAll, emitEvent, getPermission, Snowflake, trimSpecial, InvisibleCharacters } from "../util";
+import { BitField, BitFieldResolvable, BitFlag } from "../util/BitField";
+import { Recipient } from "./Recipient";
+import { Message } from "./Message";
+import { ReadState } from "./ReadState";
+import { Invite } from "./Invite";
+import { DmChannelDTO } from "../dtos";
+
+@Entity("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: "simple-array" })
+ allowed_algorithms: string[];
+
+ @Column()
+ current_algorithm: string;
+
+ @Column({ nullable: true })
+ used_since_message: string;
+
+}
diff --git a/src/util/entities/Guild.ts b/src/util/entities/Guild.ts
new file mode 100644
index 00000000..2ce7c213
--- /dev/null
+++ b/src/util/entities/Guild.ts
@@ -0,0 +1,363 @@
+import { Column, Entity, JoinColumn, ManyToMany, ManyToOne, OneToMany, OneToOne, RelationId } from "typeorm";
+import { Config, handleFile, Snowflake } from "..";
+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",
+ "members",
+ "roles",
+ "stickers",
+ "voice_states",
+ "members.user",
+];
+
+@Entity("guilds")
+export class Guild extends BaseClass {
+ @Column({ nullable: true })
+ @RelationId((guild: Guild) => guild.afk_channel)
+ afk_channel_id?: string;
+
+ @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: "simple-array" })
+ 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({ nullable: true })
+ large?: boolean;
+
+ @Column({ nullable: true })
+ max_members?: number; // e.g. default 100.000
+
+ @Column({ nullable: true })
+ max_presences?: number;
+
+ @Column({ nullable: true })
+ max_video_channel_users?: number; // ? default: 25, is this max 25 streaming or watching
+
+ @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({ nullable: true })
+ premium_tier?: number; // crowd premium level
+
+ @Column({ nullable: true })
+ @RelationId((guild: Guild) => guild.public_updates_channel)
+ public_updates_channel_id: string;
+
+ @JoinColumn({ name: "public_updates_channel_id" })
+ @ManyToOne(() => Channel)
+ public_updates_channel?: Channel;
+
+ @Column({ nullable: true })
+ @RelationId((guild: Guild) => guild.rules_channel)
+ rules_channel_id?: string;
+
+ @JoinColumn({ name: "rules_channel_id" })
+ @ManyToOne(() => Channel)
+ rules_channel?: string;
+
+ @Column({ nullable: true })
+ region?: string;
+
+ @Column({ nullable: true })
+ splash?: string;
+
+ @Column({ nullable: true })
+ @RelationId((guild: Guild) => guild.system_channel)
+ system_channel_id?: string;
+
+ @JoinColumn({ name: "system_channel_id" })
+ @ManyToOne(() => Channel)
+ system_channel?: Channel;
+
+ @Column({ nullable: true })
+ system_channel_flags?: number;
+
+ @Column({ nullable: true })
+ unavailable?: boolean;
+
+ @Column({ nullable: true })
+ verification_level?: number;
+
+ @Column({ type: "simple-json" })
+ welcome_screen: {
+ enabled: boolean;
+ description: string;
+ welcome_channels: {
+ description: string;
+ emoji_id?: string;
+ emoji_name?: string;
+ channel_id: string;
+ }[];
+ };
+
+ @Column({ nullable: true })
+ @RelationId((guild: Guild) => guild.widget_channel)
+ widget_channel_id?: string;
+
+ @JoinColumn({ name: "widget_channel_id" })
+ @ManyToOne(() => Channel)
+ widget_channel?: Channel;
+
+ @Column({ nullable: true })
+ widget_enabled?: boolean;
+
+ @Column({ nullable: true })
+ nsfw_level?: number;
+
+ @Column()
+ nsfw: boolean;
+
+ // TODO: nested guilds
+ @Column({ nullable: true })
+ parent?: string;
+
+ // only for developer portal
+ permissions?: number;
+
+ static async createGuild(body: {
+ name?: string;
+ icon?: string | null;
+ owner_id?: string;
+ channels?: Partial<Channel>[];
+ }) {
+ const guild_id = Snowflake.generate();
+
+ const guild = await Guild.create({
+ name: body.name || "Fosscord",
+ icon: await handleFile(`/icons/${guild_id}`, body.icon as string),
+ region: Config.get().regions.default,
+ owner_id: body.owner_id, // TODO: need to figure out a way for ownerless guilds and multiply-owned guilds
+ afk_timeout: 300,
+ default_message_notifications: 1, // defaults effect: setting the push default at mentions-only will save a lot
+ explicit_content_filter: 0,
+ features: Config.get().guild.defaultFeatures,
+ primary_category_id: undefined,
+ id: guild_id,
+ max_members: 250000,
+ max_presences: 250000,
+ max_video_channel_users: 200,
+ 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
+ unavailable: false,
+ nsfw: false,
+ nsfw_level: 0,
+ verification_level: 0,
+ welcome_screen: {
+ enabled: false,
+ description: "Fill in your description",
+ welcome_channels: [],
+ },
+ widget_enabled: true, // NB: don't set it as false to prevent artificial restrictions
+ }).save();
+
+ // we have to create the role _after_ the guild because else we would get a "SQLITE_CONSTRAINT: FOREIGN KEY constraint failed" 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,
+ hoist: false,
+ managed: false,
+ // NB: in Fosscord, every role will be non-managed, as we use user-groups instead of roles for managed groups
+ mentionable: false,
+ name: "@everyone",
+ permissions: String("2251804225"),
+ position: 0,
+ icon: undefined,
+ unicode_emoji: undefined
+ }).save();
+
+ 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, b) => (a.parent_id ? 1 : -1))) {
+ var id = ids.get(channel.id) || Snowflake.generate();
+
+ var parent_id = ids.get(channel.parent_id);
+
+ await Channel.createChannel({ ...channel, guild_id, id, parent_id }, body.owner_id, {
+ keepId: true,
+ skipExistsCheck: true,
+ skipPermissionCheck: true,
+ skipEventEmit: true,
+ });
+ }
+
+ return guild;
+ }
+}
diff --git a/src/util/entities/Invite.ts b/src/util/entities/Invite.ts
new file mode 100644
index 00000000..4f36f247
--- /dev/null
+++ b/src/util/entities/Invite.ts
@@ -0,0 +1,85 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId, PrimaryColumn } from "typeorm";
+import { Member } from "./Member";
+import { BaseClassWithoutId } from "./BaseClass";
+import { Channel } from "./Channel";
+import { Guild } from "./Guild";
+import { User } from "./User";
+
+export const PublicInviteRelation = ["inviter", "guild", "channel"];
+
+@Entity("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()
+ expires_at: Date;
+
+ @Column({ nullable: true })
+ @RelationId((invite: Invite) => invite.guild)
+ guild_id: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, {
+ 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)
+ 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/fosscord/fosscord/issues/62
+
+ @Column({ nullable: true })
+ target_user_type?: number;
+
+ @Column({ nullable: true })
+ vanity_url?: boolean;
+
+ static async joinGuild(user_id: string, code: string) {
+ const invite = await Invite.findOneOrFail({ where: { code } });
+ 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/util/entities/Member.ts b/src/util/entities/Member.ts
new file mode 100644
index 00000000..d7bcefea
--- /dev/null
+++ b/src/util/entities/Member.ts
@@ -0,0 +1,430 @@
+import { PublicUser, User } from "./User";
+import { Message } from "./Message";
+import {
+ BeforeInsert,
+ BeforeUpdate,
+ Column,
+ Entity,
+ Index,
+ JoinColumn,
+ JoinTable,
+ ManyToMany,
+ ManyToOne,
+ PrimaryGeneratedColumn,
+ RelationId,
+} from "typeorm";
+import { Guild } from "./Guild";
+import { Config, emitEvent, BannedWords, FieldErrors } from "../util";
+import {
+ GuildCreateEvent,
+ GuildDeleteEvent,
+ GuildMemberAddEvent,
+ GuildMemberRemoveEvent,
+ GuildMemberUpdateEvent,
+ MessageCreateEvent,
+
+} from "../interfaces";
+import { HTTPError } from "lambert-server";
+import { Role } from "./Role";
+import { BaseClassWithoutId } from "./BaseClass";
+import { Ban, PublicGuildRelations } from ".";
+import { DiscordApiErrors } from "../util/Constants";
+
+export const MemberPrivateProjection: (keyof Member)[] = [
+ "id",
+ "guild",
+ "guild_id",
+ "deaf",
+ "joined_at",
+ "last_message_id",
+ "mute",
+ "nick",
+ "pending",
+ "premium_since",
+ "roles",
+ "settings",
+ "user",
+];
+
+@Entity("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: "simple-json", 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;
+
+ // TODO: add this when we have proper read receipts
+ // @Column({ type: "simple-json" })
+ // read_state: ReadState;
+
+ @BeforeUpdate()
+ @BeforeInsert()
+ validate() {
+ if (this.nick) {
+ this.nick = this.nick.split("\n").join("");
+ this.nick = this.nick.split("\t").join("");
+ if (BannedWords.find(this.nick)) throw FieldErrors({ nick: { message: "Bad nickname", code: "INVALID_NICKNAME" } });
+ }
+ }
+
+ static async IsInGuildOrFail(user_id: string, guild_id: string) {
+ if (await Member.count({ where: { id: user_id, guild: { id: guild_id } } })) return true;
+ 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"], where: { id: guild_id } });
+ if (guild.owner_id === user_id) throw new Error("The owner cannot be removed of the guild");
+ const member = await Member.findOneOrFail({ where: { id: user_id, guild_id }, relations: ["user"] });
+
+ // 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,
+ } as GuildDeleteEvent),
+ emitEvent({
+ event: "GUILD_MEMBER_REMOVE",
+ data: { guild_id, user: member.user },
+ guild_id,
+ } as GuildMemberRemoveEvent),
+ ]);
+ }
+
+ 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
+ //@ts-ignore
+ select: ["index", "roles.id"], // TODO fix type
+ }),
+ Role.findOneOrFail({ where: { id: role_id, guild_id }, select: ["id"] }),
+ ]);
+ 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,
+ } as 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
+ //@ts-ignore
+ select: ["roles.id", "index"], // TODO: fix type
+ }),
+ 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,
+ } as 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,
+ } as GuildMemberUpdateEvent),
+ ]);
+ }
+
+ static async addToGuild(user_id: string, guild_id: string) {
+ const user = await User.getPublicUser(user_id);
+ const isBanned = await Ban.count({ where: { guild_id, user_id } });
+ if (isBanned) {
+ throw DiscordApiErrors.USER_BANNED;
+ }
+ 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} server limit.`, 403);
+ }
+
+ const guild = await Guild.findOneOrFail({
+ where: {
+ id: guild_id,
+ },
+ relations: [...PublicGuildRelations, "system_channel"],
+ });
+
+ if (await Member.count({ where: { id: user.id, guild: { id: guild_id } } }))
+ throw new HTTPError("You are already a member of this guild", 400);
+
+ const member = {
+ id: user_id,
+ guild_id,
+ nick: undefined,
+ roles: [guild_id], // @everyone role
+ joined_at: new Date(),
+ premium_since: (new Date()).getTime(),
+ deaf: false,
+ mute: false,
+ pending: false,
+ };
+
+ await Promise.all([
+ Member.create({
+ ...member,
+ roles: [Role.create({ id: guild_id })],
+ // 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: 0,
+ 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
+ }).save(),
+ Guild.increment({ id: guild_id }, "member_count", 1),
+ emitEvent({
+ event: "GUILD_MEMBER_ADD",
+ data: {
+ ...member,
+ user,
+ guild_id,
+ },
+ guild_id,
+ } as GuildMemberAddEvent),
+ emitEvent({
+ event: "GUILD_CREATE",
+ data: {
+ ...guild,
+ members: [...guild.members, { ...member, user }],
+ member_count: (guild.member_count || 0) + 1,
+ guild_hashes: {},
+ guild_scheduled_events: [],
+ joined_at: member.joined_at,
+ presences: [],
+ stage_instances: [],
+ threads: [],
+ },
+ user_id,
+ } as GuildCreateEvent),
+ ]);
+
+ if (guild.system_channel_id) {
+ // send welcome message
+ const message = Message.create({
+ type: 7,
+ guild_id: guild.id,
+ channel_id: guild.system_channel_id,
+ author: user,
+ timestamp: new Date(),
+ reactions: [],
+ attachments: [],
+ embeds: [],
+ sticker_items: [],
+ edited_timestamp: undefined,
+ });
+ await Promise.all([
+ message.save(),
+ emitEvent({ event: "MESSAGE_CREATE", channel_id: message.channel_id, data: message } as MessageCreateEvent)
+ ]);
+ }
+ }
+}
+
+export interface ChannelOverride {
+ message_notifications: number;
+ mute_config: MuteConfig;
+ muted: boolean;
+ channel_id: string | null;
+}
+
+export interface UserGuildSettings {
+ // channel_overrides: {
+ // channel_id: string;
+ // message_notifications: number;
+ // mute_config: MuteConfig;
+ // muted: boolean;
+ // }[];
+
+ channel_overrides: {
+ [channel_id: string]: ChannelOverride;
+ } | null,
+ message_notifications: number;
+ mobile_push: boolean;
+ mute_config: MuteConfig | null;
+ muted: boolean;
+ suppress_everyone: boolean;
+ suppress_roles: boolean;
+ version: number;
+ guild_id: string | null;
+ flags: number;
+ mute_scheduled_events: boolean;
+ hide_muted_channels: boolean;
+ notify_highlights: 0;
+}
+
+export const DefaultUserGuildSettings: UserGuildSettings = {
+ channel_overrides: null,
+ message_notifications: 1,
+ flags: 0,
+ hide_muted_channels: false,
+ mobile_push: true,
+ mute_config: null,
+ mute_scheduled_events: false,
+ muted: false,
+ notify_highlights: 0,
+ suppress_everyone: false,
+ suppress_roles: false,
+ version: 453, // ?
+ guild_id: null,
+};
+
+export interface MuteConfig {
+ end_time: number;
+ selected_time_window: number;
+}
+
+export type PublicMemberKeys =
+ | "id"
+ | "guild_id"
+ | "nick"
+ | "roles"
+ | "joined_at"
+ | "pending"
+ | "deaf"
+ | "mute"
+ | "premium_since";
+
+export const PublicMemberProjection: PublicMemberKeys[] = [
+ "id",
+ "guild_id",
+ "nick",
+ "roles",
+ "joined_at",
+ "pending",
+ "deaf",
+ "mute",
+ "premium_since",
+];
+
+// @ts-ignore
+export type PublicMember = Pick<Member, Omit<PublicMemberKeys, "roles">> & {
+ user: PublicUser;
+ roles: string[]; // only role ids not objects
+};
diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts
new file mode 100644
index 00000000..3a3dd5e4
--- /dev/null
+++ b/src/util/entities/Message.ts
@@ -0,0 +1,296 @@
+import { User } from "./User";
+import { Member } from "./Member";
+import { Role } from "./Role";
+import { Channel } from "./Channel";
+import { InteractionType } from "../interfaces/Interaction";
+import { Application } from "./Application";
+import {
+ BeforeInsert,
+ BeforeUpdate,
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ JoinColumn,
+ JoinTable,
+ ManyToMany,
+ ManyToOne,
+ OneToMany,
+ RelationId,
+} from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { Webhook } from "./Webhook";
+import { Sticker } from "./Sticker";
+import { Attachment } from "./Attachment";
+import { BannedWords } from "../util";
+import { HTTPError } from "lambert-server";
+import { ValidatorConstraint } from "class-validator";
+
+export enum MessageType {
+ DEFAULT = 0,
+ RECIPIENT_ADD = 1,
+ RECIPIENT_REMOVE = 2,
+ CALL = 3,
+ CHANNEL_NAME_CHANGE = 4,
+ CHANNEL_ICON_CHANGE = 5,
+ CHANNEL_PINNED_MESSAGE = 6,
+ GUILD_MEMBER_JOIN = 7,
+ USER_PREMIUM_GUILD_SUBSCRIPTION = 8,
+ USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1 = 9,
+ USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2 = 10,
+ USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3 = 11,
+ CHANNEL_FOLLOW_ADD = 12,
+ ACTION = 13, // /me messages
+ GUILD_DISCOVERY_DISQUALIFIED = 14,
+ GUILD_DISCOVERY_REQUALIFIED = 15,
+ ENCRYPTED = 16,
+ REPLY = 19,
+ APPLICATION_COMMAND = 20, // application command or self command invocation
+ ROUTE_ADDED = 41, // custom message routing: new route affecting that channel
+ ROUTE_DISABLED = 42, // custom message routing: given route no longer affecting that channel
+ SELF_COMMAND_SCRIPT = 43, // self command scripts
+ ENCRYPTION = 50,
+ CUSTOM_START = 63,
+ UNHANDLED = 255
+}
+
+@Entity("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.guild)
+ 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)
+ 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, type: process.env.PRODUCTION ? "longtext" : undefined })
+ 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)
+ mention_channels: Channel[];
+
+ @JoinTable({ name: "message_stickers" })
+ @ManyToMany(() => Sticker, { cascade: true, onDelete: "CASCADE" })
+ sticker_items?: Sticker[];
+
+ @OneToMany(() => Attachment, (attachment: Attachment) => attachment.message, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ attachments?: Attachment[];
+
+ @Column({ type: "simple-json" })
+ embeds: Embed[];
+
+ @Column({ type: "simple-json" })
+ reactions: Reaction[];
+
+ @Column({ type: "text", nullable: true })
+ nonce?: string;
+
+ @Column({ nullable: true })
+ pinned?: boolean;
+
+ @Column({ type: "int" })
+ type: MessageType;
+
+ @Column({ type: "simple-json", nullable: true })
+ activity?: {
+ type: number;
+ party_id: string;
+ };
+
+ @Column({ nullable: true })
+ flags?: string;
+
+ @Column({ type: "simple-json", nullable: true })
+ message_reference?: {
+ message_id: string;
+ channel_id?: string;
+ guild_id?: string;
+ };
+
+ @JoinColumn({ name: "message_reference_id" })
+ @ManyToOne(() => Message)
+ referenced_message?: Message;
+
+ @Column({ type: "simple-json", nullable: true })
+ interaction?: {
+ id: string;
+ type: InteractionType;
+ name: string;
+ user_id: string; // the user who invoked the interaction
+ // user: User; // TODO: autopopulate user
+ };
+
+ @Column({ type: "simple-json", nullable: true })
+ components?: MessageComponent[];
+
+ @BeforeUpdate()
+ @BeforeInsert()
+ validate() {
+ if (this.content) {
+ if (BannedWords.find(this.content)) throw new HTTPError("Message was blocked by automatic moderation", 200000);
+ }
+ }
+}
+
+export interface MessageComponent {
+ type: number;
+ style?: number;
+ label?: string;
+ emoji?: PartialEmoji;
+ custom_id?: string;
+ url?: string;
+ disabled?: boolean;
+ components: MessageComponent[];
+}
+
+export enum MessageComponentType {
+ Script = 0, // self command script
+ ActionRow = 1,
+ Button = 2,
+}
+
+export interface Embed {
+ title?: string; //title of embed
+ type?: EmbedType; // type of embed (always "rich" for webhook embeds)
+ description?: string; // description of embed
+ url?: string; // url of embed
+ timestamp?: Date; // timestamp of embed content
+ color?: number; // color code of the embed
+ footer?: {
+ text: string;
+ icon_url?: string;
+ proxy_icon_url?: string;
+ }; // footer object footer information
+ image?: EmbedImage; // image object image information
+ thumbnail?: EmbedImage; // thumbnail object thumbnail information
+ video?: EmbedImage; // video object video information
+ provider?: {
+ name?: string;
+ url?: string;
+ }; // provider object provider information
+ author?: {
+ name?: string;
+ url?: string;
+ icon_url?: string;
+ proxy_icon_url?: string;
+ }; // author object author information
+ fields?: {
+ name: string;
+ value: string;
+ inline?: boolean;
+ }[];
+}
+
+export enum EmbedType {
+ rich = "rich",
+ image = "image",
+ video = "video",
+ gifv = "gifv",
+ article = "article",
+ link = "link",
+}
+
+export interface EmbedImage {
+ url?: string;
+ proxy_url?: string;
+ height?: number;
+ width?: number;
+}
+
+export interface Reaction {
+ count: number;
+ //// not saved in the database // me: boolean; // whether the current user reacted using this emoji
+ emoji: PartialEmoji;
+ user_ids: string[];
+}
+
+export interface PartialEmoji {
+ id?: string;
+ name: string;
+ animated?: boolean;
+}
+
+export interface AllowedMentions {
+ parse?: ("users" | "roles" | "everyone")[];
+ roles?: string[];
+ users?: string[];
+ replied_user?: boolean;
+}
diff --git a/src/util/entities/Migration.ts b/src/util/entities/Migration.ts
new file mode 100644
index 00000000..3f39ae72
--- /dev/null
+++ b/src/util/entities/Migration.ts
@@ -0,0 +1,18 @@
+import { Column, Entity, ObjectIdColumn, PrimaryGeneratedColumn } from "typeorm";
+import { BaseClassWithoutId } from ".";
+
+export const PrimaryIdAutoGenerated = process.env.DATABASE?.startsWith("mongodb")
+ ? ObjectIdColumn
+ : PrimaryGeneratedColumn;
+
+@Entity("migrations")
+export class Migration extends BaseClassWithoutId {
+ @PrimaryIdAutoGenerated()
+ id: number;
+
+ @Column({ type: "bigint" })
+ timestamp: number;
+
+ @Column()
+ name: string;
+}
diff --git a/src/util/entities/Note.ts b/src/util/entities/Note.ts
new file mode 100644
index 00000000..36017c5e
--- /dev/null
+++ b/src/util/entities/Note.ts
@@ -0,0 +1,18 @@
+import { Column, Entity, JoinColumn, ManyToOne, Unique } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+
+@Entity("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;
+}
\ No newline at end of file
diff --git a/src/util/entities/RateLimit.ts b/src/util/entities/RateLimit.ts
new file mode 100644
index 00000000..f5916f6b
--- /dev/null
+++ b/src/util/entities/RateLimit.ts
@@ -0,0 +1,17 @@
+import { Column, Entity } from "typeorm";
+import { BaseClass } from "./BaseClass";
+
+@Entity("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/util/entities/ReadState.ts b/src/util/entities/ReadState.ts
new file mode 100644
index 00000000..b915573b
--- /dev/null
+++ b/src/util/entities/ReadState.ts
@@ -0,0 +1,55 @@
+import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Channel } from "./Channel";
+import { Message } from "./Message";
+import { User } from "./User";
+
+// 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("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;
+
+ // fully read marker
+ @Column({ nullable: true })
+ last_message_id: string;
+
+ // public read receipt
+ @Column({ nullable: true })
+ public_ack: string;
+
+ // notification cursor / private read receipt
+ @Column({ nullable: true })
+ notifications_cursor: string;
+
+ @Column({ nullable: true })
+ last_pin_timestamp?: Date;
+
+ @Column({ nullable: true })
+ mention_count: number;
+
+ // @Column({ nullable: true })
+ // TODO: derive this from (last_message_id=notifications_cursor=public_ack)=true
+ manual: boolean;
+}
diff --git a/src/util/entities/Recipient.ts b/src/util/entities/Recipient.ts
new file mode 100644
index 00000000..a945f938
--- /dev/null
+++ b/src/util/entities/Recipient.ts
@@ -0,0 +1,30 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+
+@Entity("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/util/entities/Relationship.ts b/src/util/entities/Relationship.ts
new file mode 100644
index 00000000..c3592c76
--- /dev/null
+++ b/src/util/entities/Relationship.ts
@@ -0,0 +1,49 @@
+import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+
+export enum RelationshipType {
+ outgoing = 4,
+ incoming = 3,
+ blocked = 2,
+ friends = 1,
+}
+
+@Entity("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/util/entities/Role.ts b/src/util/entities/Role.ts
new file mode 100644
index 00000000..d87b835f
--- /dev/null
+++ b/src/util/entities/Role.ts
@@ -0,0 +1,51 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+
+@Entity("roles")
+export class Role extends BaseClass {
+ @Column({ nullable: true })
+ @RelationId((role: Role) => role.guild)
+ guild_id: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, {
+ onDelete: "CASCADE",
+ })
+ guild: Guild;
+
+ @Column()
+ color: number;
+
+ @Column()
+ hoist: boolean;
+
+ @Column()
+ 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: "simple-json", nullable: true })
+ tags?: {
+ bot_id?: string;
+ integration_id?: string;
+ premium_subscriber?: boolean;
+ };
+}
diff --git a/src/util/entities/Session.ts b/src/util/entities/Session.ts
new file mode 100644
index 00000000..969efa89
--- /dev/null
+++ b/src/util/entities/Session.ts
@@ -0,0 +1,46 @@
+import { User } from "./User";
+import { BaseClass } from "./BaseClass";
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { Status } from "../interfaces/Status";
+import { Activity } from "../interfaces/Activity";
+
+//TODO we need to remove all sessions on server start because if the server crashes without closing websockets it won't delete them
+
+@Entity("sessions")
+export class Session extends BaseClass {
+ @Column({ nullable: true })
+ @RelationId((session: Session) => session.user)
+ user_id: string;
+
+ @JoinColumn({ name: "user_id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ //TODO check, should be 32 char long hex string
+ @Column({ nullable: false, select: false })
+ session_id: string;
+
+ @Column({ type: "simple-json", nullable: true })
+ activities: Activity[];
+
+ // TODO client_status
+ @Column({ type: "simple-json", select: false })
+ client_info: {
+ client: string;
+ os: string;
+ version: number;
+ };
+
+ @Column({ nullable: false, type: "varchar" })
+ status: Status; //TODO enum
+}
+
+export const PrivateSessionProjection: (keyof Session)[] = [
+ "user_id",
+ "session_id",
+ "activities",
+ "client_info",
+ "status",
+];
diff --git a/src/util/entities/Sticker.ts b/src/util/entities/Sticker.ts
new file mode 100644
index 00000000..37bc6fbe
--- /dev/null
+++ b/src/util/entities/Sticker.ts
@@ -0,0 +1,66 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { User } from "./User";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+
+export enum StickerType {
+ STANDARD = 1,
+ GUILD = 2,
+}
+
+export enum StickerFormatType {
+ GIF = 0, // gif is a custom format type and not in discord spec
+ PNG = 1,
+ APNG = 2,
+ LOTTIE = 3,
+}
+
+@Entity("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, {
+ 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/util/entities/StickerPack.ts b/src/util/entities/StickerPack.ts
new file mode 100644
index 00000000..ec8c69a2
--- /dev/null
+++ b/src/util/entities/StickerPack.ts
@@ -0,0 +1,31 @@
+import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, RelationId } from "typeorm";
+import { Sticker } from ".";
+import { BaseClass } from "./BaseClass";
+
+@Entity("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/util/entities/Team.ts b/src/util/entities/Team.ts
new file mode 100644
index 00000000..22140b7f
--- /dev/null
+++ b/src/util/entities/Team.ts
@@ -0,0 +1,27 @@
+import { Column, Entity, JoinColumn, ManyToMany, ManyToOne, OneToMany, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { TeamMember } from "./TeamMember";
+import { User } from "./User";
+
+@Entity("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/util/entities/TeamMember.ts b/src/util/entities/TeamMember.ts
new file mode 100644
index 00000000..b726e1e8
--- /dev/null
+++ b/src/util/entities/TeamMember.ts
@@ -0,0 +1,37 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { User } from "./User";
+
+export enum TeamMemberState {
+ INVITED = 1,
+ ACCEPTED = 2,
+}
+
+@Entity("team_members")
+export class TeamMember extends BaseClass {
+ @Column({ type: "int" })
+ membership_state: TeamMemberState;
+
+ @Column({ type: "simple-array" })
+ permissions: string[];
+
+ @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/util/entities/Template.ts b/src/util/entities/Template.ts
new file mode 100644
index 00000000..1d952283
--- /dev/null
+++ b/src/util/entities/Template.ts
@@ -0,0 +1,44 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { User } from "./User";
+
+@Entity("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)
+ source_guild: Guild;
+
+ @Column({ type: "simple-json" })
+ serialized_source_guild: Guild;
+}
diff --git a/src/util/entities/User.ts b/src/util/entities/User.ts
new file mode 100644
index 00000000..84a8a674
--- /dev/null
+++ b/src/util/entities/User.ts
@@ -0,0 +1,430 @@
+import { BeforeInsert, BeforeUpdate, Column, Entity, FindOneOptions, JoinColumn, OneToMany } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { BitField } from "../util/BitField";
+import { Relationship } from "./Relationship";
+import { ConnectedAccount } from "./ConnectedAccount";
+import { Config, FieldErrors, Snowflake, trimSpecial, BannedWords, adjustEmail } from "..";
+import { Member, Session } from ".";
+
+export enum PublicUserEnum {
+ username,
+ discriminator,
+ id,
+ public_flags,
+ avatar,
+ accent_color,
+ banner,
+ bio,
+ bot,
+ premium_since,
+}
+export type PublicUserKeys = keyof typeof PublicUserEnum;
+
+export enum PrivateUserEnum {
+ flags,
+ mfa_enabled,
+ email,
+ phone,
+ verified,
+ nsfw_allowed,
+ premium,
+ premium_type,
+ purchased_flags,
+ premium_usage_flags,
+ disabled,
+ settings,
+ // locale
+}
+export type PrivateUserKeys = keyof typeof PrivateUserEnum | PublicUserKeys;
+
+export const PublicUserProjection = Object.values(PublicUserEnum).filter(
+ (x) => typeof x === "string"
+) as PublicUserKeys[];
+export const PrivateUserProjection = [
+ ...PublicUserProjection,
+ ...Object.values(PrivateUserEnum).filter((x) => typeof x === "string"),
+] as PrivateUserKeys[];
+
+// Private user data that should never get sent to the client
+export type PublicUser = Pick<User, PublicUserKeys>;
+
+export interface UserPublic extends Pick<User, PublicUserKeys> { }
+
+export interface UserPrivate extends Pick<User, PrivateUserKeys> {
+ locale: string;
+}
+
+@Entity("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
+
+ @Column({ nullable: true, select: false })
+ phone?: string; // phone number of the user
+
+ @Column({ select: false })
+ desktop: boolean; // if the user has desktop app installed
+
+ @Column({ select: false })
+ mobile: boolean; // 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; // if user is bot
+
+ @Column()
+ bio: string; // short description of the user (max 190 chars -> should be configurable)
+
+ @Column()
+ system: boolean; // 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; // if the user can do age-restricted actions (NSFW channels/guilds/commands)
+
+ @Column({ select: false })
+ mfa_enabled: boolean; // if 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; // if the user is offically verified
+
+ @Column()
+ disabled: boolean; // if the account is disabled
+
+ @Column()
+ deleted: boolean; // if the user was deleted
+
+ @Column({ nullable: true, select: false })
+ email?: string; // email of the user
+
+ @Column()
+ flags: string; // UserFlags
+
+ @Column()
+ public_flags: number;
+
+ @Column()
+ purchased_flags: number;
+
+ @Column()
+ premium_usage_flags: number;
+
+ @Column({ type: "bigint" })
+ rights: string; // Rights
+
+ @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: "simple-json", 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: "simple-array", select: false })
+ fingerprints: string[]; // array of fingerprints -> used to prevent multiple accounts
+
+ @Column({ type: "simple-json", select: false })
+ settings: UserSettings;
+
+ // workaround to prevent fossord-unaware clients from deleting settings not used by them
+ @Column({ type: "simple-json", select: false })
+ extended_settings: string;
+
+ @BeforeUpdate()
+ @BeforeInsert()
+ validate() {
+ this.email = adjustEmail(this.email);
+ if (!this.email) throw FieldErrors({ email: { message: "Invalid email", code: "EMAIL_INVALID" } });
+ if (!this.email.match(/([a-z\d.-]{3,})@([a-z\d.-]+).([a-z]{2,})/g)) throw FieldErrors({ email: { message: "Invalid email", code: "EMAIL_INVALID" } });
+
+ const discrim = Number(this.discriminator);
+ if (this.discriminator.length > 4) throw FieldErrors({ email: { message: "Discriminator cannot be more than 4 digits.", code: "DISCRIMINATOR_INVALID" } });
+ if (isNaN(discrim)) throw FieldErrors({ email: { message: "Discriminator must be a number.", code: "DISCRIMINATOR_INVALID" } });
+ if (discrim <= 0 || discrim >= 10000) throw FieldErrors({ email: { message: "Discriminator must be a number.", code: "DISCRIMINATOR_INVALID" } });
+ this.discriminator = discrim.toString().padStart(4, "0");
+
+ if (BannedWords.find(this.username)) throw FieldErrors({ username: { message: "Bad username", code: "INVALID_USERNAME" } });
+ }
+
+ toPublicUser() {
+ const user: any = {};
+ PublicUserProjection.forEach((x) => {
+ user[x] = this[x];
+ });
+ return user as PublicUser;
+ }
+
+ static async getPublicUser(user_id: string, opts?: FindOneOptions<User>) {
+ return await User.findOneOrFail({
+ where: { id: user_id },
+ ...opts,
+ //@ts-ignore
+ select: [...PublicUserProjection, ...(opts?.select || [])], // TODO: fix
+ });
+ }
+
+ private 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"] });
+ 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?
+ for (let tries = 0; tries < 5; tries++) {
+ const discriminator = Math.randomIntBetween(1, 9999).toString().padStart(4, "0");
+ const exists = await User.findOne({ where: { discriminator, username: username }, select: ["id"] });
+ if (!exists) return discriminator;
+ }
+
+ return undefined;
+ }
+ }
+
+ static async register({
+ email,
+ username,
+ password,
+ date_of_birth,
+ req,
+ }: {
+ username: string;
+ password?: string;
+ email?: string;
+ date_of_birth?: Date; // "2000-04-03"
+ req?: any;
+ }) {
+ // trim special uf8 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"),
+ },
+ });
+ }
+
+ // TODO: save date_of_birth
+ // appearently 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 user = User.create({
+ created_at: new Date(),
+ username: username,
+ discriminator,
+ id: Snowflake.generate(),
+ bot: false,
+ system: false,
+ premium_since: new Date(),
+ desktop: false,
+ mobile: false,
+ premium: true,
+ premium_type: 2,
+ bio: "",
+ mfa_enabled: false,
+ verified: true,
+ disabled: false,
+ deleted: false,
+ email: email,
+ rights: Config.get().security.defaultRights,
+ nsfw_allowed: true, // TODO: depending on age
+ public_flags: 0,
+ flags: "0", // TODO: generate
+ data: {
+ hash: password,
+ valid_tokens_since: new Date(),
+ },
+ settings: { ...defaultSettings, locale: language },
+ purchased_flags: 5, // TODO: idk what the values for this are
+ premium_usage_flags: 2, // TODO: idk what the values for this are
+ extended_settings: "", // TODO: was {}
+ fingerprints: [],
+ });
+
+ await user.save();
+
+ setImmediate(async () => {
+ if (Config.get().guild.autoJoin.enabled) {
+ for (const guild of Config.get().guild.autoJoin.guilds || []) {
+ await Member.addToGuild(user.id, guild).catch((e) => { });
+ }
+ }
+ });
+
+ return user;
+ }
+}
+
+export const defaultSettings: UserSettings = {
+ afk_timeout: 3600,
+ allow_accessibility_detection: true,
+ animate_emoji: true,
+ animate_stickers: 0,
+ contact_sync_enabled: false,
+ convert_emoticons: false,
+ custom_status: null,
+ default_guilds_restricted: false,
+ detect_platform_accounts: false,
+ developer_mode: true,
+ disable_games_tab: true,
+ enable_tts_command: false,
+ explicit_content_filter: 0,
+ friend_source_flags: { all: true },
+ gateway_connected: false,
+ gif_auto_play: true,
+ guild_folders: [],
+ guild_positions: [],
+ inline_attachment_media: true,
+ inline_embed_media: true,
+ locale: "en-US",
+ message_display_compact: false,
+ native_phone_integration_enabled: true,
+ render_embeds: true,
+ render_reactions: true,
+ restricted_guilds: [],
+ show_current_game: true,
+ status: "online",
+ stream_notifications_enabled: false,
+ theme: "dark",
+ timezone_offset: 0, // TODO: timezone from request
+
+ banner_color: null,
+ friend_discovery_flags: 0,
+ view_nsfw_guilds: true,
+ passwordless: false,
+};
+
+export interface UserSettings {
+ afk_timeout: number;
+ allow_accessibility_detection: boolean;
+ animate_emoji: boolean;
+ animate_stickers: number;
+ contact_sync_enabled: boolean;
+ convert_emoticons: boolean;
+ custom_status: {
+ emoji_id?: string;
+ emoji_name?: string;
+ expires_at?: number;
+ text?: string;
+ } | null;
+ default_guilds_restricted: boolean;
+ detect_platform_accounts: boolean;
+ developer_mode: boolean;
+ disable_games_tab: boolean;
+ enable_tts_command: boolean;
+ explicit_content_filter: number;
+ friend_source_flags: { all: boolean; };
+ gateway_connected: boolean;
+ gif_auto_play: boolean;
+ // every top guild is displayed as a "folder"
+ guild_folders: {
+ color: number;
+ guild_ids: string[];
+ id: number;
+ name: string;
+ }[];
+ guild_positions: string[]; // guild ids ordered by position
+ inline_attachment_media: boolean;
+ inline_embed_media: boolean;
+ locale: string; // en_US
+ message_display_compact: boolean;
+ native_phone_integration_enabled: boolean;
+ render_embeds: boolean;
+ render_reactions: boolean;
+ restricted_guilds: string[];
+ show_current_game: boolean;
+ status: "online" | "offline" | "dnd" | "idle" | "invisible";
+ stream_notifications_enabled: boolean;
+ theme: "dark" | "white"; // dark
+ timezone_offset: number; // e.g -60
+ banner_color: string | null;
+ friend_discovery_flags: number;
+ view_nsfw_guilds: boolean;
+ passwordless: boolean;
+}
+
+export const CUSTOM_USER_FLAG_OFFSET = BigInt(1) << BigInt(32);
+
+export class UserFlags extends BitField {
+ static FLAGS = {
+ DISCORD_EMPLOYEE: BigInt(1) << BigInt(0),
+ PARTNERED_SERVER_OWNER: BigInt(1) << BigInt(1),
+ HYPESQUAD_EVENTS: BigInt(1) << BigInt(2),
+ BUGHUNTER_LEVEL_1: BigInt(1) << BigInt(3),
+ MFA_SMS: BigInt(1) << BigInt(4),
+ PREMIUM_PROMO_DISMISSED: BigInt(1) << BigInt(5),
+ HOUSE_BRAVERY: BigInt(1) << BigInt(6),
+ HOUSE_BRILLIANCE: BigInt(1) << BigInt(7),
+ HOUSE_BALANCE: BigInt(1) << BigInt(8),
+ EARLY_SUPPORTER: BigInt(1) << BigInt(9),
+ TEAM_USER: BigInt(1) << BigInt(10),
+ TRUST_AND_SAFETY: BigInt(1) << BigInt(11),
+ SYSTEM: BigInt(1) << BigInt(12),
+ HAS_UNREAD_URGENT_MESSAGES: BigInt(1) << BigInt(13),
+ BUGHUNTER_LEVEL_2: BigInt(1) << BigInt(14),
+ UNDERAGE_DELETED: BigInt(1) << BigInt(15),
+ VERIFIED_BOT: BigInt(1) << BigInt(16),
+ EARLY_VERIFIED_BOT_DEVELOPER: BigInt(1) << BigInt(17),
+ CERTIFIED_MODERATOR: BigInt(1) << BigInt(18),
+ BOT_HTTP_INTERACTIONS: BigInt(1) << BigInt(19),
+ };
+}
diff --git a/src/util/entities/VoiceState.ts b/src/util/entities/VoiceState.ts
new file mode 100644
index 00000000..75748a01
--- /dev/null
+++ b/src/util/entities/VoiceState.ts
@@ -0,0 +1,77 @@
+import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Channel } from "./Channel";
+import { Guild } from "./Guild";
+import { User } from "./User";
+import { Member } from "./Member";
+
+//https://gist.github.com/vassjozsef/e482c65df6ee1facaace8b3c9ff66145#file-voice_state-ex
+@Entity("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, {
+ 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;
+}
diff --git a/src/util/entities/Webhook.ts b/src/util/entities/Webhook.ts
new file mode 100644
index 00000000..89538417
--- /dev/null
+++ b/src/util/entities/Webhook.ts
@@ -0,0 +1,76 @@
+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";
+
+export enum WebhookType {
+ Incoming = 1,
+ ChannelFollower = 2,
+}
+
+@Entity("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;
+}
diff --git a/src/util/entities/index.ts b/src/util/entities/index.ts
new file mode 100644
index 00000000..49793810
--- /dev/null
+++ b/src/util/entities/index.ts
@@ -0,0 +1,32 @@
+export * from "./Application";
+export * from "./Attachment";
+export * from "./AuditLog";
+export * from "./Ban";
+export * from "./BaseClass";
+export * from "./Categories";
+export * from "./Channel";
+export * from "./Config";
+export * from "./ConnectedAccount";
+export * from "./Emoji";
+export * from "./Guild";
+export * from "./Invite";
+export * from "./Member";
+export * from "./Message";
+export * from "./Migration";
+export * from "./RateLimit";
+export * from "./ReadState";
+export * from "./Recipient";
+export * from "./Relationship";
+export * from "./Role";
+export * from "./Session";
+export * from "./Sticker";
+export * from "./StickerPack";
+export * from "./Team";
+export * from "./TeamMember";
+export * from "./Template";
+export * from "./User";
+export * from "./VoiceState";
+export * from "./Webhook";
+export * from "./ClientRelease";
+export * from "./BackupCodes";
+export * from "./Note";
\ No newline at end of file
diff --git a/src/util/imports/OrmUtils.ts b/src/util/imports/OrmUtils.ts
new file mode 100644
index 00000000..68a1932c
--- /dev/null
+++ b/src/util/imports/OrmUtils.ts
@@ -0,0 +1,96 @@
+//source: https://github.com/typeorm/typeorm/blob/master/src/util/OrmUtils.ts
+export class OrmUtils {
+ // Checks if it's an object made by Object.create(null), {} or new Object()
+ private static isPlainObject(item: any) {
+ if (item === null || item === undefined) {
+ return false;
+ }
+
+ return !item.constructor || item.constructor === Object;
+ }
+
+ private static mergeArrayKey(target: any, key: number, value: any, memo: Map<any, any>) {
+ // Have we seen this before? Prevent infinite recursion.
+ if (memo.has(value)) {
+ target[key] = memo.get(value);
+ return;
+ }
+
+ if (value instanceof Promise) {
+ // Skip promises entirely.
+ // This is a hold-over from the old code & is because we don't want to pull in
+ // the lazy fields. Ideally we'd remove these promises via another function first
+ // but for now we have to do it here.
+ return;
+ }
+
+ if (!this.isPlainObject(value) && !Array.isArray(value)) {
+ target[key] = value;
+ return;
+ }
+
+ if (!target[key]) {
+ target[key] = Array.isArray(value) ? [] : {};
+ }
+
+ memo.set(value, target[key]);
+ this.merge(target[key], value, memo);
+ memo.delete(value);
+ }
+
+ private static mergeObjectKey(target: any, key: string, value: any, memo: Map<any, any>) {
+ // Have we seen this before? Prevent infinite recursion.
+ if (memo.has(value)) {
+ Object.assign(target, { [key]: memo.get(value) });
+ return;
+ }
+
+ if (value instanceof Promise) {
+ // Skip promises entirely.
+ // This is a hold-over from the old code & is because we don't want to pull in
+ // the lazy fields. Ideally we'd remove these promises via another function first
+ // but for now we have to do it here.
+ return;
+ }
+
+ if (!this.isPlainObject(value) && !Array.isArray(value)) {
+ Object.assign(target, { [key]: value });
+ return;
+ }
+
+ if (!target[key]) {
+ Object.assign(target, { [key]: value });
+ }
+
+ memo.set(value, target[key]);
+ this.merge(target[key], value, memo);
+ memo.delete(value);
+ }
+
+ private static merge(target: any, source: any, memo: Map<any, any> = new Map()): any {
+ if (Array.isArray(target) && Array.isArray(source)) {
+ for (let key = 0; key < source.length; key++) {
+ this.mergeArrayKey(target, key, source[key], memo);
+ }
+ } else {
+ for (const key of Object.keys(source)) {
+ this.mergeObjectKey(target, key, source[key], memo);
+ }
+ }
+ }
+
+ /**
+ * Deep Object.assign.
+ */
+ static mergeDeep(target: any, ...sources: any[]): any {
+ if (!sources.length) {
+ return target;
+ }
+
+ for (const source of sources) {
+ OrmUtils.merge(target, source);
+ }
+
+ return target;
+ }
+}
\ No newline at end of file
diff --git a/src/util/imports/index.ts b/src/util/imports/index.ts
new file mode 100644
index 00000000..5d9bfb5f
--- /dev/null
+++ b/src/util/imports/index.ts
@@ -0,0 +1 @@
+export * from "./OrmUtils";
\ No newline at end of file
diff --git a/src/util/index.ts b/src/util/index.ts
new file mode 100644
index 00000000..385070a3
--- /dev/null
+++ b/src/util/index.ts
@@ -0,0 +1,8 @@
+import "reflect-metadata";
+
+export * from "./util/index";
+export * from "./interfaces/index";
+export * from "./entities/index";
+export * from "./dtos/index";
+export * from "./schemas";
+export * from "./imports";
\ No newline at end of file
diff --git a/src/util/interfaces/Activity.ts b/src/util/interfaces/Activity.ts
new file mode 100644
index 00000000..9912e197
--- /dev/null
+++ b/src/util/interfaces/Activity.ts
@@ -0,0 +1,53 @@
+export interface Activity {
+ name: string; // the activity's name
+ type: ActivityType; // activity type // TODO: check if its between range 0-5
+ url?: string; // stream url, is validated when type is 1
+ created_at?: number; // unix timestamp of when the activity was added to the user's session
+ timestamps?: {
+ // unix timestamps for start and/or end of the game
+ start: number;
+ end: number;
+ };
+ application_id?: string; // application id for the game
+ details?: string;
+ state?: string;
+ emoji?: {
+ name: string;
+ id?: string;
+ animated: boolean;
+ };
+ party?: {
+ id?: string;
+ size?: [number]; // used to show the party's current and maximum size // TODO: array length 2
+ };
+ assets?: {
+ large_image?: string; // the id for a large asset of the activity, usually a snowflake
+ large_text?: string; // text displayed when hovering over the large image of the activity
+ small_image?: string; // the id for a small asset of the activity, usually a snowflake
+ small_text?: string; // text displayed when hovering over the small image of the activity
+ };
+ secrets?: {
+ join?: string; // the secret for joining a party
+ spectate?: string; // the secret for spectating a game
+ match?: string; // the secret for a specific instanced match
+ };
+ instance?: boolean;
+ flags: string; // activity flags OR d together, describes what the payload includes
+
+ id?: string;
+ sync_id?: string;
+ metadata?: { // spotify
+ context_uri?: string;
+ album_id: string;
+ artist_ids: string[];
+ };
+ session_id: string;
+}
+
+export enum ActivityType {
+ GAME = 0,
+ STREAMING = 1,
+ LISTENING = 2,
+ CUSTOM = 4,
+ COMPETING = 5,
+}
diff --git a/src/util/interfaces/Event.ts b/src/util/interfaces/Event.ts
new file mode 100644
index 00000000..59f995db
--- /dev/null
+++ b/src/util/interfaces/Event.ts
@@ -0,0 +1,641 @@
+import { PublicUser, User, UserSettings } from "../entities/User";
+import { Channel } from "../entities/Channel";
+import { Guild } from "../entities/Guild";
+import { Member, PublicMember, UserGuildSettings } from "../entities/Member";
+import { Emoji } from "../entities/Emoji";
+import { Role } from "../entities/Role";
+import { Invite } from "../entities/Invite";
+import { Message, PartialEmoji } from "../entities/Message";
+import { VoiceState } from "../entities/VoiceState";
+import { ApplicationCommand } from "../entities/Application";
+import { Interaction } from "./Interaction";
+import { ConnectedAccount } from "../entities/ConnectedAccount";
+import { Relationship, RelationshipType } from "../entities/Relationship";
+import { Presence } from "./Presence";
+import { Sticker } from "..";
+import { Activity, Status } from ".";
+
+export interface Event {
+ guild_id?: string;
+ user_id?: string;
+ channel_id?: string;
+ created_at?: Date;
+ event: EVENT;
+ data?: any;
+}
+
+// ! Custom Events that shouldn't get sent to the client but processed by the server
+
+export interface InvalidatedEvent extends Event {
+ event: "INVALIDATED";
+}
+
+export interface PublicRelationship {
+ id: string;
+ user: PublicUser;
+ type: RelationshipType;
+}
+
+// ! END Custom Events that shouldn't get sent to the client but processed by the server
+
+export interface ReadyEventData {
+ v: number;
+ user: PublicUser & {
+ mobile: boolean;
+ desktop: boolean;
+ email: string | undefined;
+ flags: string;
+ mfa_enabled: boolean;
+ nsfw_allowed: boolean;
+ phone: string | undefined;
+ premium: boolean;
+ premium_type: number;
+ verified: boolean;
+ bot: boolean;
+ };
+ private_channels: Channel[]; // this will be empty for bots
+ session_id: string; // resuming
+ guilds: Guild[];
+ analytics_token?: string;
+ connected_accounts?: ConnectedAccount[];
+ consents?: {
+ personalization?: {
+ consented?: boolean;
+ };
+ };
+ country_code?: string; // e.g. DE
+ friend_suggestion_count?: number;
+ geo_ordered_rtc_regions?: string[]; // ["europe","russie","india","us-east","us-central"]
+ experiments?: [number, number, number, number, number][];
+ guild_experiments?: [
+ // ? what are guild_experiments?
+ // this is the structure of it:
+ number,
+ null,
+ number,
+ [[number, { e: number; s: number }[]]],
+ [number, [[number, [number, number]]]],
+ { b: number; k: bigint[] }[]
+ ][];
+ guild_join_requests?: any[]; // ? what is this? this is new
+ shard?: [number, number];
+ user_settings?: UserSettings;
+ relationships?: PublicRelationship[]; // TODO
+ read_state: {
+ entries: any[]; // TODO
+ partial: boolean;
+ version: number;
+ };
+ user_guild_settings?: {
+ entries: UserGuildSettings[];
+ version: number;
+ partial: boolean;
+ };
+ application?: {
+ id: string;
+ flags: string;
+ };
+ merged_members?: PublicMember[][];
+ // probably all users who the user is in contact with
+ users?: PublicUser[];
+ sessions: any[];
+}
+
+export interface ReadyEvent extends Event {
+ event: "READY";
+ data: ReadyEventData;
+}
+
+export interface ChannelCreateEvent extends Event {
+ event: "CHANNEL_CREATE";
+ data: Channel;
+}
+
+export interface ChannelUpdateEvent extends Event {
+ event: "CHANNEL_UPDATE";
+ data: Channel;
+}
+
+export interface ChannelDeleteEvent extends Event {
+ event: "CHANNEL_DELETE";
+ data: Channel;
+}
+
+export interface ChannelPinsUpdateEvent extends Event {
+ event: "CHANNEL_PINS_UPDATE";
+ data: {
+ guild_id?: string;
+ channel_id: string;
+ last_pin_timestamp?: number;
+ };
+}
+
+export interface ChannelRecipientAddEvent extends Event {
+ event: "CHANNEL_RECIPIENT_ADD";
+ data: {
+ channel_id: string;
+ user: User;
+ };
+}
+
+export interface ChannelRecipientRemoveEvent extends Event {
+ event: "CHANNEL_RECIPIENT_REMOVE";
+ data: {
+ channel_id: string;
+ user: User;
+ };
+}
+
+export interface GuildCreateEvent extends Event {
+ event: "GUILD_CREATE";
+ data: Guild & {
+ joined_at: Date;
+ // TODO: add them to guild
+ guild_scheduled_events: never[];
+ guild_hashes: {};
+ presences: never[];
+ stage_instances: never[];
+ threads: never[];
+ };
+}
+
+export interface GuildUpdateEvent extends Event {
+ event: "GUILD_UPDATE";
+ data: Guild;
+}
+
+export interface GuildDeleteEvent extends Event {
+ event: "GUILD_DELETE";
+ data: {
+ id: string;
+ unavailable?: boolean;
+ };
+}
+
+export interface GuildBanAddEvent extends Event {
+ event: "GUILD_BAN_ADD";
+ data: {
+ guild_id: string;
+ user: User;
+ };
+}
+
+export interface GuildBanRemoveEvent extends Event {
+ event: "GUILD_BAN_REMOVE";
+ data: {
+ guild_id: string;
+ user: User;
+ };
+}
+
+export interface GuildEmojisUpdateEvent extends Event {
+ event: "GUILD_EMOJIS_UPDATE";
+ data: {
+ guild_id: string;
+ emojis: Emoji[];
+ };
+}
+
+export interface GuildStickersUpdateEvent extends Event {
+ event: "GUILD_STICKERS_UPDATE";
+ data: {
+ guild_id: string;
+ stickers: Sticker[];
+ };
+}
+
+export interface GuildIntegrationUpdateEvent extends Event {
+ event: "GUILD_INTEGRATIONS_UPDATE";
+ data: {
+ guild_id: string;
+ };
+}
+
+export interface GuildMemberAddEvent extends Event {
+ event: "GUILD_MEMBER_ADD";
+ data: PublicMember & {
+ guild_id: string;
+ };
+}
+
+export interface GuildMemberRemoveEvent extends Event {
+ event: "GUILD_MEMBER_REMOVE";
+ data: {
+ guild_id: string;
+ user: User;
+ };
+}
+
+export interface GuildMemberUpdateEvent extends Event {
+ event: "GUILD_MEMBER_UPDATE";
+ data: {
+ guild_id: string;
+ roles: string[];
+ user: User;
+ nick?: string;
+ joined_at?: Date;
+ premium_since?: number;
+ pending?: boolean;
+ };
+}
+
+export interface GuildMembersChunkEvent extends Event {
+ event: "GUILD_MEMBERS_CHUNK";
+ data: {
+ guild_id: string;
+ members: PublicMember[];
+ chunk_index: number;
+ chunk_count: number;
+ not_found: string[];
+ presences: Presence[];
+ nonce?: string;
+ };
+}
+
+export interface GuildRoleCreateEvent extends Event {
+ event: "GUILD_ROLE_CREATE";
+ data: {
+ guild_id: string;
+ role: Role;
+ };
+}
+
+export interface GuildRoleUpdateEvent extends Event {
+ event: "GUILD_ROLE_UPDATE";
+ data: {
+ guild_id: string;
+ role: Role;
+ };
+}
+
+export interface GuildRoleDeleteEvent extends Event {
+ event: "GUILD_ROLE_DELETE";
+ data: {
+ guild_id: string;
+ role_id: string;
+ };
+}
+
+export interface InviteCreateEvent extends Event {
+ event: "INVITE_CREATE";
+ data: Omit<Invite, "guild" | "channel"> & {
+ channel_id: string;
+ guild_id?: string;
+ };
+}
+
+export interface InviteDeleteEvent extends Event {
+ event: "INVITE_DELETE";
+ data: {
+ channel_id: string;
+ guild_id?: string;
+ code: string;
+ };
+}
+
+export interface MessageCreateEvent extends Event {
+ event: "MESSAGE_CREATE";
+ data: Message;
+}
+
+export interface MessageUpdateEvent extends Event {
+ event: "MESSAGE_UPDATE";
+ data: Message;
+}
+
+export interface MessageDeleteEvent extends Event {
+ event: "MESSAGE_DELETE";
+ data: {
+ id: string;
+ channel_id: string;
+ guild_id?: string;
+ };
+}
+
+export interface MessageDeleteBulkEvent extends Event {
+ event: "MESSAGE_DELETE_BULK";
+ data: {
+ ids: string[];
+ channel_id: string;
+ guild_id?: string;
+ };
+}
+
+export interface MessageReactionAddEvent extends Event {
+ event: "MESSAGE_REACTION_ADD";
+ data: {
+ user_id: string;
+ channel_id: string;
+ message_id: string;
+ guild_id?: string;
+ member?: PublicMember;
+ emoji: PartialEmoji;
+ };
+}
+
+export interface MessageReactionRemoveEvent extends Event {
+ event: "MESSAGE_REACTION_REMOVE";
+ data: {
+ user_id: string;
+ channel_id: string;
+ message_id: string;
+ guild_id?: string;
+ emoji: PartialEmoji;
+ };
+}
+
+export interface MessageReactionRemoveAllEvent extends Event {
+ event: "MESSAGE_REACTION_REMOVE_ALL";
+ data: {
+ channel_id: string;
+ message_id: string;
+ guild_id?: string;
+ };
+}
+
+export interface MessageReactionRemoveEmojiEvent extends Event {
+ event: "MESSAGE_REACTION_REMOVE_EMOJI";
+ data: {
+ channel_id: string;
+ message_id: string;
+ guild_id?: string;
+ emoji: PartialEmoji;
+ };
+}
+
+export interface PresenceUpdateEvent extends Event {
+ event: "PRESENCE_UPDATE";
+ data: Presence;
+}
+
+export interface TypingStartEvent extends Event {
+ event: "TYPING_START";
+ data: {
+ channel_id: string;
+ user_id: string;
+ timestamp: number;
+ guild_id?: string;
+ member?: PublicMember;
+ };
+}
+
+export interface UserUpdateEvent extends Event {
+ event: "USER_UPDATE";
+ data: User;
+}
+
+export interface VoiceStateUpdateEvent extends Event {
+ event: "VOICE_STATE_UPDATE";
+ data: VoiceState & {
+ member: PublicMember;
+ };
+}
+
+export interface VoiceServerUpdateEvent extends Event {
+ event: "VOICE_SERVER_UPDATE";
+ data: {
+ token: string;
+ guild_id: string;
+ endpoint: string;
+ };
+}
+
+export interface WebhooksUpdateEvent extends Event {
+ event: "WEBHOOKS_UPDATE";
+ data: {
+ guild_id: string;
+ channel_id: string;
+ };
+}
+
+export type ApplicationCommandPayload = ApplicationCommand & {
+ guild_id: string;
+};
+
+export interface ApplicationCommandCreateEvent extends Event {
+ event: "APPLICATION_COMMAND_CREATE";
+ data: ApplicationCommandPayload;
+}
+
+export interface ApplicationCommandUpdateEvent extends Event {
+ event: "APPLICATION_COMMAND_UPDATE";
+ data: ApplicationCommandPayload;
+}
+
+export interface ApplicationCommandDeleteEvent extends Event {
+ event: "APPLICATION_COMMAND_DELETE";
+ data: ApplicationCommandPayload;
+}
+
+export interface InteractionCreateEvent extends Event {
+ event: "INTERACTION_CREATE";
+ data: Interaction;
+}
+
+export interface MessageAckEvent extends Event {
+ event: "MESSAGE_ACK";
+ data: {
+ channel_id: string;
+ message_id: string;
+ version?: number;
+ manual?: boolean;
+ mention_count?: number;
+ };
+}
+
+export interface RelationshipAddEvent extends Event {
+ event: "RELATIONSHIP_ADD";
+ data: PublicRelationship & {
+ should_notify?: boolean;
+ user: PublicUser;
+ };
+}
+
+export interface RelationshipRemoveEvent extends Event {
+ event: "RELATIONSHIP_REMOVE";
+ data: Omit<PublicRelationship, "nickname">;
+}
+
+export interface SessionsReplace extends Event {
+ event: "SESSIONS_REPLACE";
+ data: {
+ activities: Activity[];
+ client_info: {
+ version: number;
+ os: string;
+ client: string;
+ };
+ status: Status;
+ }[];
+}
+
+export interface GuildMemberListUpdate extends Event {
+ event: "GUILD_MEMBER_LIST_UPDATE";
+ data: {
+ groups: { id: string; count: number }[];
+ guild_id: string;
+ id: string;
+ member_count: number;
+ online_count: number;
+ ops: {
+ index: number;
+ item: {
+ member?: PublicMember & { presence: Presence };
+ group?: { id: string; count: number }[];
+ };
+ }[];
+ };
+}
+
+export type EventData =
+ | InvalidatedEvent
+ | ReadyEvent
+ | ChannelCreateEvent
+ | ChannelUpdateEvent
+ | ChannelDeleteEvent
+ | ChannelPinsUpdateEvent
+ | ChannelRecipientAddEvent
+ | ChannelRecipientRemoveEvent
+ | GuildCreateEvent
+ | GuildUpdateEvent
+ | GuildDeleteEvent
+ | GuildBanAddEvent
+ | GuildBanRemoveEvent
+ | GuildEmojisUpdateEvent
+ | GuildIntegrationUpdateEvent
+ | GuildMemberAddEvent
+ | GuildMemberRemoveEvent
+ | GuildMemberUpdateEvent
+ | GuildMembersChunkEvent
+ | GuildMemberListUpdate
+ | GuildRoleCreateEvent
+ | GuildRoleUpdateEvent
+ | GuildRoleDeleteEvent
+ | InviteCreateEvent
+ | InviteDeleteEvent
+ | MessageCreateEvent
+ | MessageUpdateEvent
+ | MessageDeleteEvent
+ | MessageDeleteBulkEvent
+ | MessageReactionAddEvent
+ | MessageReactionRemoveEvent
+ | MessageReactionRemoveAllEvent
+ | MessageReactionRemoveEmojiEvent
+ | PresenceUpdateEvent
+ | TypingStartEvent
+ | UserUpdateEvent
+ | VoiceStateUpdateEvent
+ | VoiceServerUpdateEvent
+ | WebhooksUpdateEvent
+ | ApplicationCommandCreateEvent
+ | ApplicationCommandUpdateEvent
+ | ApplicationCommandDeleteEvent
+ | InteractionCreateEvent
+ | MessageAckEvent
+ | RelationshipAddEvent
+ | RelationshipRemoveEvent;
+
+// located in collection events
+
+export enum EVENTEnum {
+ Ready = "READY",
+ ChannelCreate = "CHANNEL_CREATE",
+ ChannelUpdate = "CHANNEL_UPDATE",
+ ChannelDelete = "CHANNEL_DELETE",
+ ChannelPinsUpdate = "CHANNEL_PINS_UPDATE",
+ ChannelRecipientAdd = "CHANNEL_RECIPIENT_ADD",
+ ChannelRecipientRemove = "CHANNEL_RECIPIENT_REMOVE",
+ GuildCreate = "GUILD_CREATE",
+ GuildUpdate = "GUILD_UPDATE",
+ GuildDelete = "GUILD_DELETE",
+ GuildBanAdd = "GUILD_BAN_ADD",
+ GuildBanRemove = "GUILD_BAN_REMOVE",
+ GuildEmojUpdate = "GUILD_EMOJI_UPDATE",
+ GuildIntegrationsUpdate = "GUILD_INTEGRATIONS_UPDATE",
+ GuildMemberAdd = "GUILD_MEMBER_ADD",
+ GuildMemberRempve = "GUILD_MEMBER_REMOVE",
+ GuildMemberUpdate = "GUILD_MEMBER_UPDATE",
+ GuildMemberSpeaking = "GUILD_MEMBER_SPEAKING",
+ GuildMembersChunk = "GUILD_MEMBERS_CHUNK",
+ GuildMemberListUpdate = "GUILD_MEMBER_LIST_UPDATE",
+ GuildRoleCreate = "GUILD_ROLE_CREATE",
+ GuildRoleDelete = "GUILD_ROLE_DELETE",
+ GuildRoleUpdate = "GUILD_ROLE_UPDATE",
+ InviteCreate = "INVITE_CREATE",
+ InviteDelete = "INVITE_DELETE",
+ MessageCreate = "MESSAGE_CREATE",
+ MessageUpdate = "MESSAGE_UPDATE",
+ MessageDelete = "MESSAGE_DELETE",
+ MessageDeleteBulk = "MESSAGE_DELETE_BULK",
+ MessageReactionAdd = "MESSAGE_REACTION_ADD",
+ MessageReactionRemove = "MESSAGE_REACTION_REMOVE",
+ MessageReactionRemoveAll = "MESSAGE_REACTION_REMOVE_ALL",
+ MessageReactionRemoveEmoji = "MESSAGE_REACTION_REMOVE_EMOJI",
+ PresenceUpdate = "PRESENCE_UPDATE",
+ TypingStart = "TYPING_START",
+ UserUpdate = "USER_UPDATE",
+ WebhooksUpdate = "WEBHOOKS_UPDATE",
+ InteractionCreate = "INTERACTION_CREATE",
+ VoiceStateUpdate = "VOICE_STATE_UPDATE",
+ VoiceServerUpdate = "VOICE_SERVER_UPDATE",
+ ApplicationCommandCreate = "APPLICATION_COMMAND_CREATE",
+ ApplicationCommandUpdate = "APPLICATION_COMMAND_UPDATE",
+ ApplicationCommandDelete = "APPLICATION_COMMAND_DELETE",
+ SessionsReplace = "SESSIONS_REPLACE",
+}
+
+export type EVENT =
+ | "READY"
+ | "CHANNEL_CREATE"
+ | "CHANNEL_UPDATE"
+ | "CHANNEL_DELETE"
+ | "CHANNEL_PINS_UPDATE"
+ | "CHANNEL_RECIPIENT_ADD"
+ | "CHANNEL_RECIPIENT_REMOVE"
+ | "GUILD_CREATE"
+ | "GUILD_UPDATE"
+ | "GUILD_DELETE"
+ | "GUILD_BAN_ADD"
+ | "GUILD_BAN_REMOVE"
+ | "GUILD_EMOJIS_UPDATE"
+ | "GUILD_STICKERS_UPDATE"
+ | "GUILD_INTEGRATIONS_UPDATE"
+ | "GUILD_MEMBER_ADD"
+ | "GUILD_MEMBER_REMOVE"
+ | "GUILD_MEMBER_UPDATE"
+ | "GUILD_MEMBER_SPEAKING"
+ | "GUILD_MEMBERS_CHUNK"
+ | "GUILD_MEMBER_LIST_UPDATE"
+ | "GUILD_ROLE_CREATE"
+ | "GUILD_ROLE_DELETE"
+ | "GUILD_ROLE_UPDATE"
+ | "INVITE_CREATE"
+ | "INVITE_DELETE"
+ | "MESSAGE_CREATE"
+ | "MESSAGE_UPDATE"
+ | "MESSAGE_DELETE"
+ | "MESSAGE_DELETE_BULK"
+ | "MESSAGE_REACTION_ADD"
+ // TODO: add a new event: bulk add reaction:
+ // | "MESSAGE_REACTION_BULK_ADD"
+ | "MESSAGE_REACTION_REMOVE"
+ | "MESSAGE_REACTION_REMOVE_ALL"
+ | "MESSAGE_REACTION_REMOVE_EMOJI"
+ | "PRESENCE_UPDATE"
+ | "TYPING_START"
+ | "USER_UPDATE"
+ | "USER_NOTE_UPDATE"
+ | "WEBHOOKS_UPDATE"
+ | "INTERACTION_CREATE"
+ | "VOICE_STATE_UPDATE"
+ | "VOICE_SERVER_UPDATE"
+ | "APPLICATION_COMMAND_CREATE"
+ | "APPLICATION_COMMAND_UPDATE"
+ | "APPLICATION_COMMAND_DELETE"
+ | "MESSAGE_ACK"
+ | "RELATIONSHIP_ADD"
+ | "RELATIONSHIP_REMOVE"
+ | "SESSIONS_REPLACE"
+ | CUSTOMEVENTS;
+
+export type CUSTOMEVENTS = "INVALIDATED" | "RATELIMIT";
diff --git a/src/util/interfaces/Interaction.ts b/src/util/interfaces/Interaction.ts
new file mode 100644
index 00000000..5d3aae24
--- /dev/null
+++ b/src/util/interfaces/Interaction.ts
@@ -0,0 +1,34 @@
+import { AllowedMentions, Embed } from "../entities/Message";
+
+export interface Interaction {
+ id: string;
+ type: InteractionType;
+ data?: {};
+ guild_id: string;
+ channel_id: string;
+ member_id: string;
+ token: string;
+ version: number;
+}
+
+export enum InteractionType {
+ SelfCommand = 0,
+ Ping = 1,
+ ApplicationCommand = 2,
+}
+
+export enum InteractionResponseType {
+ SelfCommandResponse = 0,
+ Pong = 1,
+ Acknowledge = 2,
+ ChannelMessage = 3,
+ ChannelMessageWithSource = 4,
+ AcknowledgeWithSource = 5,
+}
+
+export interface InteractionApplicationCommandCallbackData {
+ tts?: boolean;
+ content: string;
+ embeds?: Embed[];
+ allowed_mentions?: AllowedMentions;
+}
diff --git a/src/util/interfaces/Presence.ts b/src/util/interfaces/Presence.ts
new file mode 100644
index 00000000..7663891a
--- /dev/null
+++ b/src/util/interfaces/Presence.ts
@@ -0,0 +1,12 @@
+import { ClientStatus, Status } from "./Status";
+import { Activity } from "./Activity";
+import { PublicUser } from "../entities/User";
+
+export interface Presence {
+ user: PublicUser;
+ guild_id?: string;
+ status: Status;
+ activities: Activity[];
+ client_status: ClientStatus;
+ // TODO: game
+}
diff --git a/src/util/interfaces/Status.ts b/src/util/interfaces/Status.ts
new file mode 100644
index 00000000..5d2e1bba
--- /dev/null
+++ b/src/util/interfaces/Status.ts
@@ -0,0 +1,7 @@
+export type Status = "idle" | "dnd" | "online" | "offline" | "invisible";
+
+export interface ClientStatus {
+ desktop?: string; // e.g. Windows/Linux/Mac
+ mobile?: string; // e.g. iOS/Android
+ web?: string; // e.g. browser, bot account
+}
diff --git a/src/util/interfaces/index.ts b/src/util/interfaces/index.ts
new file mode 100644
index 00000000..ab7fa429
--- /dev/null
+++ b/src/util/interfaces/index.ts
@@ -0,0 +1,5 @@
+export * from "./Activity";
+export * from "./Presence";
+export * from "./Interaction";
+export * from "./Event";
+export * from "./Status";
diff --git a/src/util/migrations/1633864260873-EmojiRoles.ts b/src/util/migrations/1633864260873-EmojiRoles.ts
new file mode 100644
index 00000000..f0d709f2
--- /dev/null
+++ b/src/util/migrations/1633864260873-EmojiRoles.ts
@@ -0,0 +1,13 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class EmojiRoles1633864260873 implements MigrationInterface {
+ name = "EmojiRoles1633864260873";
+
+ public async up(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.query(`ALTER TABLE "emojis" ADD "roles" text NOT NULL DEFAULT ''`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.query(`ALTER TABLE "emojis" DROP COLUMN column_name "roles"`);
+ }
+}
diff --git a/src/util/migrations/1633864669243-EmojiUser.ts b/src/util/migrations/1633864669243-EmojiUser.ts
new file mode 100644
index 00000000..982405d7
--- /dev/null
+++ b/src/util/migrations/1633864669243-EmojiUser.ts
@@ -0,0 +1,23 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class EmojiUser1633864669243 implements MigrationInterface {
+ name = "EmojiUser1633864669243";
+
+ public async up(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.query(`ALTER TABLE "emojis" ADD "user_id" varchar`);
+ try {
+ await queryRunner.query(
+ `ALTER TABLE "emojis" ADD CONSTRAINT FK_fa7ddd5f9a214e28ce596548421 FOREIGN KEY (user_id) REFERENCES users(id)`
+ );
+ } catch (error) {
+ console.error(
+ "sqlite doesn't support altering foreign keys: https://stackoverflow.com/questions/1884818/how-do-i-add-a-foreign-key-to-an-existing-sqlite-table"
+ );
+ }
+ }
+
+ public async down(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.query(`ALTER TABLE "emojis" DROP COLUMN column_name "user_id"`);
+ await queryRunner.query(`ALTER TABLE "emojis" DROP CONSTRAINT FK_fa7ddd5f9a214e28ce596548421`);
+ }
+}
diff --git a/src/util/migrations/1633881705509-VanityInvite.ts b/src/util/migrations/1633881705509-VanityInvite.ts
new file mode 100644
index 00000000..45485310
--- /dev/null
+++ b/src/util/migrations/1633881705509-VanityInvite.ts
@@ -0,0 +1,19 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class VanityInvite1633881705509 implements MigrationInterface {
+ name = "VanityInvite1633881705509";
+
+ public async up(queryRunner: QueryRunner): Promise<void> {
+ try {
+ await queryRunner.query(`ALTER TABLE "emojis" DROP COLUMN vanity_url_code`);
+ await queryRunner.query(`ALTER TABLE "emojis" DROP CONSTRAINT FK_c2c1809d79eb120ea0cb8d342ad`);
+ } catch (error) {}
+ }
+
+ public async down(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.query(`ALTER TABLE "emojis" ADD vanity_url_code varchar`);
+ await queryRunner.query(
+ `ALTER TABLE "emojis" ADD CONSTRAINT FK_c2c1809d79eb120ea0cb8d342ad FOREIGN KEY ("vanity_url_code") REFERENCES "invites"("code") ON DELETE NO ACTION ON UPDATE NO ACTION`
+ );
+ }
+}
diff --git a/src/util/migrations/1634308884591-Stickers.ts b/src/util/migrations/1634308884591-Stickers.ts
new file mode 100644
index 00000000..fbc4649f
--- /dev/null
+++ b/src/util/migrations/1634308884591-Stickers.ts
@@ -0,0 +1,66 @@
+import { MigrationInterface, QueryRunner, Table, TableColumn, TableForeignKey } from "typeorm";
+
+export class Stickers1634308884591 implements MigrationInterface {
+ name = "Stickers1634308884591";
+
+ public async up(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.dropForeignKey("read_states", "FK_6f255d873cfbfd7a93849b7ff74");
+ await queryRunner.changeColumn(
+ "stickers",
+ "tags",
+ new TableColumn({ name: "tags", type: "varchar", isNullable: true })
+ );
+ await queryRunner.changeColumn(
+ "stickers",
+ "pack_id",
+ new TableColumn({ name: "pack_id", type: "varchar", isNullable: true })
+ );
+ await queryRunner.changeColumn("stickers", "type", new TableColumn({ name: "type", type: "integer" }));
+ await queryRunner.changeColumn(
+ "stickers",
+ "format_type",
+ new TableColumn({ name: "format_type", type: "integer" })
+ );
+ await queryRunner.changeColumn(
+ "stickers",
+ "available",
+ new TableColumn({ name: "available", type: "boolean", isNullable: true })
+ );
+ await queryRunner.changeColumn(
+ "stickers",
+ "user_id",
+ new TableColumn({ name: "user_id", type: "boolean", isNullable: true })
+ );
+ await queryRunner.createForeignKey(
+ "stickers",
+ new TableForeignKey({
+ name: "FK_8f4ee73f2bb2325ff980502e158",
+ columnNames: ["user_id"],
+ referencedColumnNames: ["id"],
+ referencedTableName: "users",
+ onDelete: "CASCADE",
+ })
+ );
+ await queryRunner.createTable(
+ new Table({
+ name: "sticker_packs",
+ columns: [
+ new TableColumn({ name: "id", type: "varchar", isPrimary: true }),
+ new TableColumn({ name: "name", type: "varchar" }),
+ new TableColumn({ name: "description", type: "varchar", isNullable: true }),
+ new TableColumn({ name: "banner_asset_id", type: "varchar", isNullable: true }),
+ new TableColumn({ name: "cover_sticker_id", type: "varchar", isNullable: true }),
+ ],
+ foreignKeys: [
+ new TableForeignKey({
+ columnNames: ["cover_sticker_id"],
+ referencedColumnNames: ["id"],
+ referencedTableName: "stickers",
+ }),
+ ],
+ })
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise<void> {}
+}
diff --git a/src/util/migrations/1634424361103-Presence.ts b/src/util/migrations/1634424361103-Presence.ts
new file mode 100644
index 00000000..729955b8
--- /dev/null
+++ b/src/util/migrations/1634424361103-Presence.ts
@@ -0,0 +1,11 @@
+import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
+
+export class Presence1634424361103 implements MigrationInterface {
+ name = "Presence1634424361103";
+
+ public async up(queryRunner: QueryRunner): Promise<void> {
+ queryRunner.addColumn("sessions", new TableColumn({ name: "activites", type: "text" }));
+ }
+
+ public async down(queryRunner: QueryRunner): Promise<void> {}
+}
diff --git a/src/util/migrations/1634426540271-MigrationTimestamp.ts b/src/util/migrations/1634426540271-MigrationTimestamp.ts
new file mode 100644
index 00000000..3208b25b
--- /dev/null
+++ b/src/util/migrations/1634426540271-MigrationTimestamp.ts
@@ -0,0 +1,15 @@
+import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
+
+export class MigrationTimestamp1634426540271 implements MigrationInterface {
+ name = "MigrationTimestamp1634426540271";
+
+ public async up(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.changeColumn(
+ "migrations",
+ "timestamp",
+ new TableColumn({ name: "timestampe", type: "bigint", isNullable: false })
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise<void> {}
+}
diff --git a/src/util/migrations/1648643945733-ReleaseTypo.ts b/src/util/migrations/1648643945733-ReleaseTypo.ts
new file mode 100644
index 00000000..944b9dd9
--- /dev/null
+++ b/src/util/migrations/1648643945733-ReleaseTypo.ts
@@ -0,0 +1,16 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class ReleaseTypo1648643945733 implements MigrationInterface {
+ name = "ReleaseTypo1648643945733";
+
+ public async up(queryRunner: QueryRunner): Promise<void> {
+ //drop table first because typeorm creates it before migrations run
+ await queryRunner.dropTable("client_release", true);
+ await queryRunner.renameTable("client_relase", "client_release");
+ }
+
+ public async down(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.dropTable("client_relase", true);
+ await queryRunner.renameTable("client_release", "client_relase");
+ }
+}
diff --git a/src/util/migrations/1660678870706-opencordFixes.ts b/src/util/migrations/1660678870706-opencordFixes.ts
new file mode 100644
index 00000000..1f10c212
--- /dev/null
+++ b/src/util/migrations/1660678870706-opencordFixes.ts
@@ -0,0 +1,53 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class opencordFixes1660678870706 implements MigrationInterface {
+ name = 'opencordFixes1660678870706'
+
+ public async up(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.query(`
+ ALTER TABLE \`users\`
+ ADD \`purchased_flags\` int NOT NULL
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`users\`
+ ADD \`premium_usage_flags\` int NOT NULL
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`user_settings\`
+ ADD \`friend_discovery_flags\` int NOT NULL
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`user_settings\`
+ ADD \`view_nsfw_guilds\` tinyint NOT NULL
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`user_settings\`
+ ADD \`passwordless\` tinyint NOT NULL
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`users\` CHANGE \`mfa_enabled\` \`mfa_enabled\` tinyint NOT NULL
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.query(`
+ ALTER TABLE \`users\` CHANGE \`mfa_enabled\` \`mfa_enabled\` tinyint NULL
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`user_settings\` DROP COLUMN \`passwordless\`
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`user_settings\` DROP COLUMN \`view_nsfw_guilds\`
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`user_settings\` DROP COLUMN \`friend_discovery_flags\`
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`users\` DROP COLUMN \`premium_usage_flags\`
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`users\` DROP COLUMN \`purchased_flags\`
+ `);
+ }
+
+}
\ No newline at end of file
diff --git a/src/util/migrations/1660689892073-mobileFixes2.ts b/src/util/migrations/1660689892073-mobileFixes2.ts
new file mode 100644
index 00000000..bd28694e
--- /dev/null
+++ b/src/util/migrations/1660689892073-mobileFixes2.ts
@@ -0,0 +1,37 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class mobileFixes21660689892073 implements MigrationInterface {
+ name = 'mobileFixes21660689892073'
+
+ public async up(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.query(`
+ ALTER TABLE \`user_settings\`
+ ADD \`banner_color\` varchar(255) NULL
+ `);
+ await queryRunner.query(`
+ UPDATE \`channels\` SET \`nsfw\` = 0 WHERE \`nsfw\` = NULL
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`channels\` CHANGE \`nsfw\` \`nsfw\` tinyint NOT NULL
+ `);
+ await queryRunner.query(`
+ UPDATE \`guilds\` SET \`nsfw\` = 0 WHERE \`nsfw\` = NULL
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`guilds\` CHANGE \`nsfw\` \`nsfw\` tinyint NOT NULL
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise<void> {
+ await queryRunner.query(`
+ ALTER TABLE \`guilds\` CHANGE \`nsfw\` \`nsfw\` tinyint NULL
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`channels\` CHANGE \`nsfw\` \`nsfw\` tinyint NULL
+ `);
+ await queryRunner.query(`
+ ALTER TABLE \`user_settings\` DROP COLUMN \`banner_color\`
+ `);
+ }
+
+}
\ No newline at end of file
diff --git a/src/util/schemas/Validator.ts b/src/util/schemas/Validator.ts
new file mode 100644
index 00000000..b71bf6a1
--- /dev/null
+++ b/src/util/schemas/Validator.ts
@@ -0,0 +1,54 @@
+import Ajv from "ajv";
+import addFormats from "ajv-formats";
+import fs from "fs";
+import path from "path";
+
+const SchemaPath = path.join(__dirname, "..", "..", "..", "assets", "schemas.json");
+const schemas = JSON.parse(fs.readFileSync(SchemaPath, { encoding: "utf8" }));
+
+export const ajv = new Ajv({
+ allErrors: true,
+ parseDate: true,
+ allowDate: true,
+ schemas,
+ coerceTypes: true,
+ messages: true,
+ strict: true,
+ strictRequired: true
+});
+
+addFormats(ajv);
+
+export function validateSchema<G>(schema: string, data: G): G {
+ const valid = ajv.validate(schema, normalizeBody(data));
+ if (!valid) throw ajv.errors;
+ return data;
+}
+
+// Normalizer is introduced to workaround https://github.com/ajv-validator/ajv/issues/1287
+// this removes null values as ajv doesn't treat them as undefined
+// normalizeBody allows to handle circular structures without issues
+// taken from https://github.com/serverless/serverless/blob/master/lib/classes/ConfigSchemaHandler/index.js#L30 (MIT license)
+export const normalizeBody = (body: any = {}) => {
+ const normalizedObjectsSet = new WeakSet();
+ const normalizeObject = (object: any) => {
+ if (normalizedObjectsSet.has(object)) return;
+ normalizedObjectsSet.add(object);
+ if (Array.isArray(object)) {
+ for (const [index, value] of object.entries()) {
+ if (typeof value === "object") normalizeObject(value);
+ }
+ } else {
+ for (const [key, value] of Object.entries(object)) {
+ if (value == null) {
+ if (key === "icon" || key === "avatar" || key === "banner" || key === "splash" || key === "discovery_splash") continue;
+ delete object[key];
+ } else if (typeof value === "object") {
+ normalizeObject(value);
+ }
+ }
+ }
+ };
+ normalizeObject(body);
+ return body;
+};
\ No newline at end of file
diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts
new file mode 100644
index 00000000..662152dc
--- /dev/null
+++ b/src/util/schemas/index.ts
@@ -0,0 +1,2 @@
+export * from "./Validator";
+export * from "./voice";
\ No newline at end of file
diff --git a/src/util/schemas/voice.ts b/src/util/schemas/voice.ts
new file mode 100644
index 00000000..61c12f92
--- /dev/null
+++ b/src/util/schemas/voice.ts
@@ -0,0 +1,69 @@
+export interface VoiceVideoSchema {
+ audio_ssrc: number;
+ video_ssrc: number;
+ rtx_ssrc?: number;
+ user_id?: string;
+ streams?: {
+ type: "video" | "audio";
+ rid: string;
+ ssrc: number;
+ active: boolean;
+ quality: number;
+ rtx_ssrc: number;
+ max_bitrate: number;
+ max_framerate: number;
+ max_resolution: { type: string; width: number; height: number; };
+ }[];
+}
+
+export const VoiceStateUpdateSchema = {
+ $guild_id: String,
+ $channel_id: String,
+ self_mute: Boolean,
+ self_deaf: Boolean,
+ self_video: Boolean
+};
+
+//TODO need more testing when community guild and voice stage channel are working
+export interface VoiceStateUpdateSchema {
+ channel_id: string;
+ guild_id?: string;
+ suppress?: boolean;
+ request_to_speak_timestamp?: Date;
+ self_mute?: boolean;
+ self_deaf?: boolean;
+ self_video?: boolean;
+}
+
+export interface VoiceIdentifySchema {
+ server_id: string;
+ user_id: string;
+ session_id: string;
+ token: string;
+ video?: boolean;
+ streams?: {
+ type: string;
+ rid: string;
+ quality: number;
+ }[];
+}
+
+export interface SelectProtocolSchema {
+ protocol: "webrtc" | "udp";
+ data:
+ | string
+ | {
+ address: string;
+ port: number;
+ mode: string;
+ };
+ sdp?: string;
+ codecs?: {
+ name: "opus" | "VP8" | "VP9" | "H264";
+ type: "audio" | "video";
+ priority: number;
+ payload_type: number;
+ rtx_payload_type?: number | null;
+ }[];
+ rtc_connection_id?: string; // uuid
+}
\ No newline at end of file
diff --git a/src/util/util/ApiError.ts b/src/util/util/ApiError.ts
new file mode 100644
index 00000000..f1a9b4f6
--- /dev/null
+++ b/src/util/util/ApiError.ts
@@ -0,0 +1,28 @@
+export class ApiError extends Error {
+ constructor(
+ readonly message: string,
+ public readonly code: number,
+ public readonly httpStatus: number = 400,
+ public readonly defaultParams?: string[]
+ ) {
+ super(message);
+ }
+
+ withDefaultParams(): ApiError {
+ if (this.defaultParams)
+ return new ApiError(applyParamsToString(this.message, this.defaultParams), this.code, this.httpStatus);
+ return this;
+ }
+
+ withParams(...params: (string | number)[]): ApiError {
+ return new ApiError(applyParamsToString(this.message, params), this.code, this.httpStatus);
+ }
+}
+
+export function applyParamsToString(s: string, params: (string | number)[]): string {
+ let newString = s;
+ params.forEach((a) => {
+ newString = newString.replace("{}", "" + a);
+ });
+ return newString;
+}
diff --git a/src/util/util/Array.ts b/src/util/util/Array.ts
new file mode 100644
index 00000000..5a45d1b5
--- /dev/null
+++ b/src/util/util/Array.ts
@@ -0,0 +1,3 @@
+export function containsAll(arr: any[], target: any[]) {
+ return target.every((v) => arr.includes(v));
+}
diff --git a/src/util/util/AutoUpdate.ts b/src/util/util/AutoUpdate.ts
new file mode 100644
index 00000000..fd65ecf5
--- /dev/null
+++ b/src/util/util/AutoUpdate.ts
@@ -0,0 +1,84 @@
+import "missing-native-js-functions";
+import fetch from "node-fetch";
+import ProxyAgent from 'proxy-agent';
+import readline from "readline";
+import fs from "fs/promises";
+import path from "path";
+
+const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+});
+
+export function enableAutoUpdate(opts: {
+ checkInterval: number | boolean;
+ packageJsonLink: string;
+ path: string;
+ downloadUrl: string;
+ downloadType?: "zip";
+}) {
+ if (!opts.checkInterval) return;
+ var interval = 1000 * 60 * 60 * 24;
+ if (typeof opts.checkInterval === "number") opts.checkInterval = 1000 * interval;
+
+ const i = setInterval(async () => {
+ const currentVersion = await getCurrentVersion(opts.path);
+ const latestVersion = await getLatestVersion(opts.packageJsonLink);
+ if (currentVersion !== latestVersion) {
+ clearInterval(i);
+ console.log(`[Auto Update] Current version (${currentVersion}) is out of date, updating ...`);
+ await download(opts.downloadUrl, opts.path);
+ }
+ }, interval);
+ setImmediate(async () => {
+ const currentVersion = await getCurrentVersion(opts.path);
+ const latestVersion = await getLatestVersion(opts.packageJsonLink);
+ if (currentVersion !== latestVersion) {
+ rl.question(
+ `[Auto Update] Current version (${currentVersion}) is out of date, would you like to update? (yes/no)`,
+ (answer) => {
+ if (answer.toBoolean()) {
+ console.log(`[Auto update] updating ...`);
+ download(opts.downloadUrl, opts.path);
+ } else {
+ console.log(`[Auto update] aborted`);
+ }
+ }
+ );
+ }
+ });
+}
+
+async function download(url: string, dir: string) {
+ try {
+ // TODO: use file stream instead of buffer (to prevent crash because of high memory usage for big files)
+ // TODO check file hash
+ const agent = new ProxyAgent();
+ const response = await fetch(url, { agent });
+ const buffer = await response.buffer();
+ const tempDir = await fs.mkdtemp("fosscord");
+ fs.writeFile(path.join(tempDir, "Fosscord.zip"), buffer);
+ } catch (error) {
+ console.error(`[Auto Update] download failed`, error);
+ }
+}
+
+async function getCurrentVersion(dir: string) {
+ try {
+ const content = await fs.readFile(path.join(dir, "package.json"), { encoding: "utf8" });
+ return JSON.parse(content).version;
+ } catch (error) {
+ throw new Error("[Auto update] couldn't get current version in " + dir);
+ }
+}
+
+async function getLatestVersion(url: string) {
+ try {
+ const agent = new ProxyAgent();
+ const response = await fetch(url, { agent });
+ const content = await response.json() as any; // TODO: types
+ return content.version;
+ } catch (error) {
+ throw new Error("[Auto update] check failed for " + url);
+ }
+}
diff --git a/src/util/util/BannedWords.ts b/src/util/util/BannedWords.ts
new file mode 100644
index 00000000..891a5980
--- /dev/null
+++ b/src/util/util/BannedWords.ts
@@ -0,0 +1,23 @@
+import fs from "fs/promises";
+import path from "path";
+
+var words: string[];
+
+export const BannedWords = {
+ init: async function init() {
+ if (words) return words;
+ const file = (await fs.readFile(path.join(process.cwd(), "bannedWords"))).toString();
+ if (!file) {
+ words = [];
+ return [];
+ }
+ words = file.trim().split("\n");
+ return words;
+ },
+
+ get: () => words,
+
+ find: (val: string) => {
+ return words.some(x => val.indexOf(x) != -1);
+ }
+};
\ No newline at end of file
diff --git a/src/util/util/BitField.ts b/src/util/util/BitField.ts
new file mode 100644
index 00000000..fb887e05
--- /dev/null
+++ b/src/util/util/BitField.ts
@@ -0,0 +1,147 @@
+"use strict";
+
+// https://github.com/discordjs/discord.js/blob/master/src/util/BitField.js
+// Apache License Version 2.0 Copyright 2015 - 2021 Amish Shah
+
+export type BitFieldResolvable = number | BigInt | BitField | string | BitFieldResolvable[];
+
+/**
+ * Data structure that makes it easy to interact with a bitfield.
+ */
+export class BitField {
+ public bitfield: bigint = BigInt(0);
+
+ public static FLAGS: Record<string, bigint> = {};
+
+ constructor(bits: BitFieldResolvable = 0) {
+ this.bitfield = BitField.resolve.call(this, bits);
+ }
+
+ /**
+ * Checks whether the bitfield has a bit, or any of multiple bits.
+ */
+ any(bit: BitFieldResolvable): boolean {
+ return (this.bitfield & BitField.resolve.call(this, bit)) !== BigInt(0);
+ }
+
+ /**
+ * Checks if this bitfield equals another
+ */
+ equals(bit: BitFieldResolvable): boolean {
+ return this.bitfield === BitField.resolve.call(this, bit);
+ }
+
+ /**
+ * Checks whether the bitfield has a bit, or multiple bits.
+ */
+ has(bit: BitFieldResolvable): boolean {
+ if (Array.isArray(bit)) return bit.every((p) => this.has(p));
+ const BIT = BitField.resolve.call(this, bit);
+ return (this.bitfield & BIT) === BIT;
+ }
+
+ /**
+ * Gets all given bits that are missing from the bitfield.
+ */
+ missing(bits: BitFieldResolvable) {
+ if (!Array.isArray(bits)) bits = new BitField(bits).toArray();
+ return bits.filter((p) => !this.has(p));
+ }
+
+ /**
+ * Freezes these bits, making them immutable.
+ */
+ freeze(): Readonly<BitField> {
+ return Object.freeze(this);
+ }
+
+ /**
+ * Adds bits to these ones.
+ * @param {...BitFieldResolvable} [bits] Bits to add
+ * @returns {BitField} These bits or new BitField if the instance is frozen.
+ */
+ add(...bits: BitFieldResolvable[]): BitField {
+ let total = BigInt(0);
+ for (const bit of bits) {
+ total |= BitField.resolve.call(this, bit);
+ }
+ if (Object.isFrozen(this)) return new BitField(this.bitfield | total);
+ this.bitfield |= total;
+ return this;
+ }
+
+ /**
+ * Removes bits from these.
+ * @param {...BitFieldResolvable} [bits] Bits to remove
+ */
+ remove(...bits: BitFieldResolvable[]) {
+ let total = BigInt(0);
+ for (const bit of bits) {
+ total |= BitField.resolve.call(this, bit);
+ }
+ if (Object.isFrozen(this)) return new BitField(this.bitfield & ~total);
+ this.bitfield &= ~total;
+ return this;
+ }
+
+ /**
+ * Gets an object mapping field names to a {@link boolean} indicating whether the
+ * bit is available.
+ * @param {...*} hasParams Additional parameters for the has method, if any
+ */
+ serialize() {
+ const serialized: Record<string, boolean> = {};
+ for (const [flag, bit] of Object.entries(BitField.FLAGS)) serialized[flag] = this.has(bit);
+ return serialized;
+ }
+
+ /**
+ * Gets an {@link Array} of bitfield names based on the bits available.
+ */
+ toArray(): string[] {
+ return Object.keys(BitField.FLAGS).filter((bit) => this.has(bit));
+ }
+
+ toJSON() {
+ return this.bitfield;
+ }
+
+ valueOf() {
+ return this.bitfield;
+ }
+
+ *[Symbol.iterator]() {
+ yield* this.toArray();
+ }
+
+ /**
+ * Data that can be resolved to give a bitfield. This can be:
+ * * A bit number (this can be a number literal or a value taken from {@link BitField.FLAGS})
+ * * An instance of BitField
+ * * An Array of BitFieldResolvable
+ * @typedef {number|BitField|BitFieldResolvable[]} BitFieldResolvable
+ */
+
+ /**
+ * Resolves bitfields to their numeric form.
+ * @param {BitFieldResolvable} [bit=0] - bit(s) to resolve
+ * @returns {number}
+ */
+ static resolve(bit: BitFieldResolvable = BigInt(0)): bigint {
+ // @ts-ignore
+ const FLAGS = this.FLAGS || this.constructor?.FLAGS;
+ if ((typeof bit === "number" || typeof bit === "bigint") && bit >= BigInt(0)) return BigInt(bit);
+ if (bit instanceof BitField) return bit.bitfield;
+ if (Array.isArray(bit)) {
+ // @ts-ignore
+ const resolve = this.constructor?.resolve || this.resolve;
+ return bit.map((p) => resolve.call(this, p)).reduce((prev, p) => BigInt(prev) | BigInt(p), BigInt(0));
+ }
+ if (typeof bit === "string" && typeof FLAGS[bit] !== "undefined") return FLAGS[bit];
+ throw new RangeError("BITFIELD_INVALID: " + bit);
+ }
+}
+
+export function BitFlag(x: bigint | number) {
+ return BigInt(1) << BigInt(x);
+}
diff --git a/src/util/util/Categories.ts b/src/util/util/Categories.ts
new file mode 100644
index 00000000..a3c69da7
--- /dev/null
+++ b/src/util/util/Categories.ts
@@ -0,0 +1 @@
+//TODO: populate default discord categories + init, get and set methods
\ No newline at end of file
diff --git a/src/util/util/Config.ts b/src/util/util/Config.ts
new file mode 100644
index 00000000..31b8d97c
--- /dev/null
+++ b/src/util/util/Config.ts
@@ -0,0 +1,81 @@
+import "missing-native-js-functions";
+import { ConfigValue, ConfigEntity, DefaultConfigOptions } from "../entities/Config";
+import path from "path";
+import fs from "fs";
+
+// TODO: yaml instead of json
+// const overridePath = path.join(process.cwd(), "config.json");
+
+var config: ConfigValue;
+var pairs: ConfigEntity[];
+
+// TODO: use events to inform about config updates
+// Config keys are separated with _
+
+export const Config = {
+ init: async function init() {
+ if (config) return config;
+ pairs = await ConfigEntity.find();
+ config = pairsToConfig(pairs);
+ config = (config || {}).merge(DefaultConfigOptions);
+
+ // try {
+ // const overrideConfig = JSON.parse(fs.readFileSync(overridePath, { encoding: "utf8" }));
+ // config = overrideConfig.merge(config);
+ // } catch (error) {
+ // fs.writeFileSync(overridePath, JSON.stringify(config, null, 4));
+ // }
+
+ return this.set(config);
+ },
+ get: function get() {
+ return config;
+ },
+ set: function set(val: Partial<ConfigValue>) {
+ if (!config || !val) return;
+ config = val.merge(config);
+
+ return applyConfig(config);
+ },
+};
+
+function applyConfig(val: ConfigValue) {
+ async function apply(obj: any, key = ""): Promise<any> {
+ if (typeof obj === "object" && obj !== null)
+ return Promise.all(Object.keys(obj).map((k) => apply(obj[k], key ? `${key}_${k}` : k)));
+
+ let pair = pairs.find((x) => x.key === key);
+ if (!pair) pair = new ConfigEntity();
+
+ pair.key = key;
+ pair.value = obj;
+ return pair.save();
+ }
+ // fs.writeFileSync(overridePath, JSON.stringify(val, null, 4));
+
+ return apply(val);
+}
+
+function pairsToConfig(pairs: ConfigEntity[]) {
+ var value: any = {};
+
+ pairs.forEach((p) => {
+ const keys = p.key.split("_");
+ let obj = value;
+ let prev = "";
+ let prevObj = obj;
+ let i = 0;
+
+ for (const key of keys) {
+ if (!isNaN(Number(key)) && !prevObj[prev]?.length) prevObj[prev] = obj = [];
+ if (i++ === keys.length - 1) obj[key] = p.value;
+ else if (!obj[key]) obj[key] = {};
+
+ prev = key;
+ prevObj = obj;
+ obj = obj[key];
+ }
+ });
+
+ return value as ConfigValue;
+}
diff --git a/src/util/util/Constants.ts b/src/util/util/Constants.ts
new file mode 100644
index 00000000..81a7165d
--- /dev/null
+++ b/src/util/util/Constants.ts
@@ -0,0 +1,796 @@
+import { ApiError } from "./ApiError";
+
+export const WSCodes = {
+ 1000: "WS_CLOSE_REQUESTED",
+ 4004: "TOKEN_INVALID",
+ 4010: "SHARDING_INVALID",
+ 4011: "SHARDING_REQUIRED",
+ 4013: "INVALID_INTENTS",
+ 4014: "DISALLOWED_INTENTS",
+};
+
+/**
+ * The current status of the client. Here are the available statuses:
+ * * READY: 0
+ * * CONNECTING: 1
+ * * RECONNECTING: 2
+ * * IDLE: 3
+ * * NEARLY: 4
+ * * DISCONNECTED: 5
+ * * WAITING_FOR_GUILDS: 6
+ * * IDENTIFYING: 7
+ * * RESUMING: 8
+ * @typedef {number} Status
+ */
+export const WsStatus = {
+ READY: 0,
+ CONNECTING: 1,
+ RECONNECTING: 2,
+ IDLE: 3,
+ NEARLY: 4,
+ DISCONNECTED: 5,
+ WAITING_FOR_GUILDS: 6,
+ IDENTIFYING: 7,
+ RESUMING: 8,
+};
+
+/**
+ * The current status of a voice connection. Here are the available statuses:
+ * * CONNECTED: 0
+ * * CONNECTING: 1
+ * * AUTHENTICATING: 2
+ * * RECONNECTING: 3
+ * * DISCONNECTED: 4
+ * @typedef {number} VoiceStatus
+ */
+export const VoiceStatus = {
+ CONNECTED: 0,
+ CONNECTING: 1,
+ AUTHENTICATING: 2,
+ RECONNECTING: 3,
+ DISCONNECTED: 4,
+};
+
+export const OPCodes = {
+ DISPATCH: 0,
+ HEARTBEAT: 1,
+ IDENTIFY: 2,
+ STATUS_UPDATE: 3,
+ VOICE_STATE_UPDATE: 4,
+ VOICE_GUILD_PING: 5,
+ RESUME: 6,
+ RECONNECT: 7,
+ REQUEST_GUILD_MEMBERS: 8,
+ INVALID_SESSION: 9,
+ HELLO: 10,
+ HEARTBEAT_ACK: 11,
+};
+
+export const VoiceOPCodes = {
+ IDENTIFY: 0,
+ SELECT_PROTOCOL: 1,
+ READY: 2,
+ HEARTBEAT: 3,
+ SESSION_DESCRIPTION: 4,
+ SPEAKING: 5,
+ HEARTBEAT_ACK: 6,
+ RESUME: 7,
+ HELLO: 8,
+ RESUMED: 9,
+ CLIENT_CONNECT: 12, // incorrect, op 12 is probably used for video
+ CLIENT_DISCONNECT: 13, // incorrect
+ VERSION: 16, //not documented
+};
+
+export const Events = {
+ RATE_LIMIT: "rateLimit",
+ CLIENT_READY: "ready",
+ GUILD_CREATE: "guildCreate",
+ GUILD_DELETE: "guildDelete",
+ GUILD_UPDATE: "guildUpdate",
+ GUILD_UNAVAILABLE: "guildUnavailable",
+ GUILD_AVAILABLE: "guildAvailable",
+ GUILD_MEMBER_ADD: "guildMemberAdd",
+ GUILD_MEMBER_REMOVE: "guildMemberRemove",
+ GUILD_MEMBER_UPDATE: "guildMemberUpdate",
+ GUILD_MEMBER_AVAILABLE: "guildMemberAvailable",
+ GUILD_MEMBER_SPEAKING: "guildMemberSpeaking",
+ GUILD_MEMBERS_CHUNK: "guildMembersChunk",
+ GUILD_INTEGRATIONS_UPDATE: "guildIntegrationsUpdate",
+ GUILD_ROLE_CREATE: "roleCreate",
+ GUILD_ROLE_DELETE: "roleDelete",
+ INVITE_CREATE: "inviteCreate",
+ INVITE_DELETE: "inviteDelete",
+ GUILD_ROLE_UPDATE: "roleUpdate",
+ GUILD_EMOJI_CREATE: "emojiCreate",
+ GUILD_EMOJI_DELETE: "emojiDelete",
+ GUILD_EMOJI_UPDATE: "emojiUpdate",
+ GUILD_BAN_ADD: "guildBanAdd",
+ GUILD_BAN_REMOVE: "guildBanRemove",
+ CHANNEL_CREATE: "channelCreate",
+ CHANNEL_DELETE: "channelDelete",
+ CHANNEL_UPDATE: "channelUpdate",
+ CHANNEL_PINS_UPDATE: "channelPinsUpdate",
+ MESSAGE_CREATE: "message",
+ MESSAGE_DELETE: "messageDelete",
+ MESSAGE_UPDATE: "messageUpdate",
+ MESSAGE_BULK_DELETE: "messageDeleteBulk",
+ MESSAGE_REACTION_ADD: "messageReactionAdd",
+ MESSAGE_REACTION_REMOVE: "messageReactionRemove",
+ MESSAGE_REACTION_REMOVE_ALL: "messageReactionRemoveAll",
+ MESSAGE_REACTION_REMOVE_EMOJI: "messageReactionRemoveEmoji",
+ USER_UPDATE: "userUpdate",
+ PRESENCE_UPDATE: "presenceUpdate",
+ VOICE_SERVER_UPDATE: "voiceServerUpdate",
+ VOICE_STATE_UPDATE: "voiceStateUpdate",
+ VOICE_BROADCAST_SUBSCRIBE: "subscribe",
+ VOICE_BROADCAST_UNSUBSCRIBE: "unsubscribe",
+ TYPING_START: "typingStart",
+ TYPING_STOP: "typingStop",
+ WEBHOOKS_UPDATE: "webhookUpdate",
+ ERROR: "error",
+ WARN: "warn",
+ DEBUG: "debug",
+ SHARD_DISCONNECT: "shardDisconnect",
+ SHARD_ERROR: "shardError",
+ SHARD_RECONNECTING: "shardReconnecting",
+ SHARD_READY: "shardReady",
+ SHARD_RESUME: "shardResume",
+ INVALIDATED: "invalidated",
+ RAW: "raw",
+};
+
+export const ShardEvents = {
+ CLOSE: "close",
+ DESTROYED: "destroyed",
+ INVALID_SESSION: "invalidSession",
+ READY: "ready",
+ RESUMED: "resumed",
+ ALL_READY: "allReady",
+};
+
+/**
+ * The type of Structure allowed to be a partial:
+ * * USER
+ * * CHANNEL (only affects DMChannels)
+ * * GUILD_MEMBER
+ * * MESSAGE
+ * * REACTION
+ * <warn>Partials require you to put checks in place when handling data, read the Partials topic listed in the
+ * sidebar for more information.</warn>
+ * @typedef {string} PartialType
+ */
+export const PartialTypes = keyMirror(["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE", "REACTION"]);
+
+/**
+ * The type of a websocket message event, e.g. `MESSAGE_CREATE`. Here are the available events:
+ * * READY
+ * * RESUMED
+ * * GUILD_CREATE
+ * * GUILD_DELETE
+ * * GUILD_UPDATE
+ * * INVITE_CREATE
+ * * INVITE_DELETE
+ * * GUILD_MEMBER_ADD
+ * * GUILD_MEMBER_REMOVE
+ * * GUILD_MEMBER_UPDATE
+ * * GUILD_MEMBERS_CHUNK
+ * * GUILD_INTEGRATIONS_UPDATE
+ * * GUILD_ROLE_CREATE
+ * * GUILD_ROLE_DELETE
+ * * GUILD_ROLE_UPDATE
+ * * GUILD_BAN_ADD
+ * * GUILD_BAN_REMOVE
+ * * GUILD_EMOJIS_UPDATE
+ * * CHANNEL_CREATE
+ * * CHANNEL_DELETE
+ * * CHANNEL_UPDATE
+ * * CHANNEL_PINS_UPDATE
+ * * MESSAGE_CREATE
+ * * MESSAGE_DELETE
+ * * MESSAGE_UPDATE
+ * * MESSAGE_DELETE_BULK
+ * * MESSAGE_REACTION_ADD
+ * * MESSAGE_REACTION_REMOVE
+ * * MESSAGE_REACTION_REMOVE_ALL
+ * * MESSAGE_REACTION_REMOVE_EMOJI
+ * * USER_UPDATE
+ * * PRESENCE_UPDATE
+ * * TYPING_START
+ * * VOICE_STATE_UPDATE
+ * * VOICE_SERVER_UPDATE
+ * * WEBHOOKS_UPDATE
+ * @typedef {string} WSEventType
+ */
+export const WSEvents = keyMirror([
+ "READY",
+ "RESUMED",
+ "GUILD_CREATE",
+ "GUILD_DELETE",
+ "GUILD_UPDATE",
+ "INVITE_CREATE",
+ "INVITE_DELETE",
+ "GUILD_MEMBER_ADD",
+ "GUILD_MEMBER_REMOVE",
+ "GUILD_MEMBER_UPDATE",
+ "GUILD_MEMBERS_CHUNK",
+ "GUILD_INTEGRATIONS_UPDATE",
+ "GUILD_ROLE_CREATE",
+ "GUILD_ROLE_DELETE",
+ "GUILD_ROLE_UPDATE",
+ "GUILD_BAN_ADD",
+ "GUILD_BAN_REMOVE",
+ "GUILD_EMOJIS_UPDATE",
+ "CHANNEL_CREATE",
+ "CHANNEL_DELETE",
+ "CHANNEL_UPDATE",
+ "CHANNEL_PINS_UPDATE",
+ "MESSAGE_CREATE",
+ "MESSAGE_DELETE",
+ "MESSAGE_UPDATE",
+ "MESSAGE_DELETE_BULK",
+ "MESSAGE_REACTION_ADD",
+ "MESSAGE_REACTION_REMOVE",
+ "MESSAGE_REACTION_REMOVE_ALL",
+ "MESSAGE_REACTION_REMOVE_EMOJI",
+ "USER_UPDATE",
+ "PRESENCE_UPDATE",
+ "TYPING_START",
+ "VOICE_STATE_UPDATE",
+ "VOICE_SERVER_UPDATE",
+ "WEBHOOKS_UPDATE",
+]);
+
+/**
+ * The type of a message, e.g. `DEFAULT`. Here are the available types:
+ * * DEFAULT
+ * * RECIPIENT_ADD
+ * * RECIPIENT_REMOVE
+ * * CALL
+ * * CHANNEL_NAME_CHANGE
+ * * CHANNEL_ICON_CHANGE
+ * * PINS_ADD
+ * * GUILD_MEMBER_JOIN
+ * * USER_PREMIUM_GUILD_SUBSCRIPTION
+ * * USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1
+ * * USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2
+ * * USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3
+ * * CHANNEL_FOLLOW_ADD
+ * * GUILD_DISCOVERY_DISQUALIFIED
+ * * GUILD_DISCOVERY_REQUALIFIED
+ * * REPLY
+ * @typedef {string} MessageType
+ */
+export const MessageTypes = [
+ "DEFAULT",
+ "RECIPIENT_ADD",
+ "RECIPIENT_REMOVE",
+ "CALL",
+ "CHANNEL_NAME_CHANGE",
+ "CHANNEL_ICON_CHANGE",
+ "PINS_ADD",
+ "GUILD_MEMBER_JOIN",
+ "USER_PREMIUM_GUILD_SUBSCRIPTION",
+ "USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1",
+ "USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2",
+ "USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3",
+ "CHANNEL_FOLLOW_ADD",
+ null,
+ "GUILD_DISCOVERY_DISQUALIFIED",
+ "GUILD_DISCOVERY_REQUALIFIED",
+ null,
+ null,
+ null,
+ "REPLY",
+];
+
+/**
+ * The types of messages that are `System`. The available types are `MessageTypes` excluding:
+ * * DEFAULT
+ * * REPLY
+ * @typedef {string} SystemMessageType
+ */
+export const SystemMessageTypes = MessageTypes.filter(
+ (type: string | null) => type && type !== "DEFAULT" && type !== "REPLY"
+);
+
+/**
+ * <info>Bots cannot set a `CUSTOM_STATUS`, it is only for custom statuses received from users</info>
+ * The type of an activity of a users presence, e.g. `PLAYING`. Here are the available types:
+ * * PLAYING
+ * * STREAMING
+ * * LISTENING
+ * * WATCHING
+ * * CUSTOM_STATUS
+ * * COMPETING
+ * @typedef {string} ActivityType
+ */
+export const ActivityTypes = ["PLAYING", "STREAMING", "LISTENING", "WATCHING", "CUSTOM_STATUS", "COMPETING"];
+
+export const ChannelTypes = {
+ TEXT: 0,
+ DM: 1,
+ VOICE: 2,
+ GROUP: 3,
+ CATEGORY: 4,
+ NEWS: 5,
+ STORE: 6,
+};
+
+export const ClientApplicationAssetTypes = {
+ SMALL: 1,
+ BIG: 2,
+};
+
+export const Colors = {
+ DEFAULT: 0x000000,
+ WHITE: 0xffffff,
+ AQUA: 0x1abc9c,
+ GREEN: 0x2ecc71,
+ BLUE: 0x3498db,
+ YELLOW: 0xffff00,
+ PURPLE: 0x9b59b6,
+ LUMINOUS_VIVID_PINK: 0xe91e63,
+ GOLD: 0xf1c40f,
+ ORANGE: 0xe67e22,
+ RED: 0xe74c3c,
+ GREY: 0x95a5a6,
+ NAVY: 0x34495e,
+ DARK_AQUA: 0x11806a,
+ DARK_GREEN: 0x1f8b4c,
+ DARK_BLUE: 0x206694,
+ DARK_PURPLE: 0x71368a,
+ DARK_VIVID_PINK: 0xad1457,
+ DARK_GOLD: 0xc27c0e,
+ DARK_ORANGE: 0xa84300,
+ DARK_RED: 0x992d22,
+ DARK_GREY: 0x979c9f,
+ DARKER_GREY: 0x7f8c8d,
+ LIGHT_GREY: 0xbcc0c0,
+ DARK_NAVY: 0x2c3e50,
+ BLURPLE: 0x7289da,
+ GREYPLE: 0x99aab5,
+ DARK_BUT_NOT_BLACK: 0x2c2f33,
+ NOT_QUITE_BLACK: 0x23272a,
+};
+
+/**
+ * The value set for the explicit content filter levels for a guild:
+ * * DISABLED
+ * * MEMBERS_WITHOUT_ROLES
+ * * ALL_MEMBERS
+ * @typedef {string} ExplicitContentFilterLevel
+ */
+export const ExplicitContentFilterLevels = ["DISABLED", "MEMBERS_WITHOUT_ROLES", "ALL_MEMBERS"];
+
+/**
+ * The value set for the verification levels for a guild:
+ * * NONE
+ * * LOW
+ * * MEDIUM
+ * * HIGH
+ * * VERY_HIGH
+ * @typedef {string} VerificationLevel
+ */
+export const VerificationLevels = ["NONE", "LOW", "MEDIUM", "HIGH", "VERY_HIGH"];
+
+/**
+ * An error encountered while performing an API request. Here are the potential errors:
+ * * GENERAL_ERROR
+ * * UNKNOWN_ACCOUNT
+ * * UNKNOWN_APPLICATION
+ * * UNKNOWN_CHANNEL
+ * * UNKNOWN_GUILD
+ * * UNKNOWN_INTEGRATION
+ * * UNKNOWN_INVITE
+ * * UNKNOWN_MEMBER
+ * * UNKNOWN_MESSAGE
+ * * UNKNOWN_OVERWRITE
+ * * UNKNOWN_PROVIDER
+ * * UNKNOWN_ROLE
+ * * UNKNOWN_TOKEN
+ * * UNKNOWN_USER
+ * * UNKNOWN_EMOJI
+ * * UNKNOWN_WEBHOOK
+ * * UNKNOWN_WEBHOOK_SERVICE
+ * * UNKNOWN_SESSION
+ * * UNKNOWN_BAN
+ * * UNKNOWN_SKU
+ * * UNKNOWN_STORE_LISTING
+ * * UNKNOWN_ENTITLEMENT
+ * * UNKNOWN_BUILD
+ * * UNKNOWN_LOBBY
+ * * UNKNOWN_BRANCH
+ * * UNKNOWN_STORE_DIRECTORY_LAYOUT
+ * * UNKNOWN_REDISTRIBUTABLE
+ * * UNKNOWN_GIFT_CODE
+ * * UNKNOWN_STREAM
+ * * UNKNOWN_PREMIUM_SERVER_SUBSCRIBE_COOLDOWN
+ * * UNKNOWN_GUILD_TEMPLATE
+ * * UNKNOWN_DISCOVERABLE_SERVER_CATEGORY
+ * * UNKNOWN_STICKER
+ * * UNKNOWN_INTERACTION
+ * * UNKNOWN_APPLICATION_COMMAND
+ * * UNKNOWN_APPLICATION_COMMAND_PERMISSIONS
+ * * UNKNOWN_STAGE_INSTANCE
+ * * UNKNOWN_GUILD_MEMBER_VERIFICATION_FORM
+ * * UNKNOWN_GUILD_WELCOME_SCREEN
+ * * UNKNOWN_GUILD_SCHEDULED_EVENT
+ * * UNKNOWN_GUILD_SCHEDULED_EVENT_USER
+ * * BOT_PROHIBITED_ENDPOINT
+ * * BOT_ONLY_ENDPOINT
+ * * EXPLICIT_CONTENT_CANNOT_BE_SENT_TO_RECIPIENT
+ * * ACTION_NOT_AUTHORIZED_ON_APPLICATION
+ * * SLOWMODE_RATE_LIMIT
+ * * ONLY_OWNER
+ * * ANNOUNCEMENT_RATE_LIMITS
+ * * CHANNEL_WRITE_RATELIMIT
+ * * WORDS_NOT_ALLOWED
+ * * GUILD_PREMIUM_LEVEL_TOO_LOW
+ * * MAXIMUM_GUILDS
+ * * MAXIMUM_FRIENDS
+ * * MAXIMUM_PINS
+ * * MAXIMUM_NUMBER_OF_RECIPIENTS_REACHED
+ * * MAXIMUM_ROLES
+ * * MAXIMUM_WEBHOOKS
+ * * MAXIMUM_NUMBER_OF_EMOJIS_REACHED
+ * * MAXIMUM_REACTIONS
+ * * MAXIMUM_CHANNELS
+ * * MAXIMUM_ATTACHMENTS
+ * * MAXIMUM_INVITES
+ * * MAXIMUM_ANIMATED_EMOJIS
+ * * MAXIMUM_SERVER_MEMBERS
+ * * MAXIMUM_SERVER_CATEGORIES
+ * * GUILD_ALREADY_HAS_TEMPLATE
+ * * MAXIMUM_THREAD_PARTICIPANTS
+ * * MAXIMUM_BANS_FOR_NON_GUILD_MEMBERS
+ * * MAXIMUM_BANS_FETCHES
+ * * MAXIMUM_STICKERS
+ * * MAXIMUM_PRUNE_REQUESTS
+ * * UNAUTHORIZED
+ * * ACCOUNT_VERIFICATION_REQUIRED
+ * * OPENING_DIRECT_MESSAGES_TOO_FAST
+ * * REQUEST_ENTITY_TOO_LARGE
+ * * FEATURE_TEMPORARILY_DISABLED
+ * * USER_BANNED
+ * * TARGET_USER_IS_NOT_CONNECTED_TO_VOICE
+ * * ALREADY_CROSSPOSTED
+ * * APPLICATION_COMMAND_ALREADY_EXISTS
+ * * MISSING_ACCESS
+ * * INVALID_ACCOUNT_TYPE
+ * * CANNOT_EXECUTE_ON_DM
+ * * EMBED_DISABLED
+ * * CANNOT_EDIT_MESSAGE_BY_OTHER
+ * * CANNOT_SEND_EMPTY_MESSAGE
+ * * CANNOT_MESSAGE_USER
+ * * CANNOT_SEND_MESSAGES_IN_VOICE_CHANNEL
+ * * CHANNEL_VERIFICATION_LEVEL_TOO_HIGH
+ * * OAUTH2_APPLICATION_BOT_ABSENT
+ * * MAXIMUM_OAUTH2_APPLICATIONS
+ * * INVALID_OAUTH_STATE
+ * * MISSING_PERMISSIONS
+ * * INVALID_AUTHENTICATION_TOKEN
+ * * NOTE_TOO_LONG
+ * * INVALID_BULK_DELETE_QUANTITY
+ * * CANNOT_PIN_MESSAGE_IN_OTHER_CHANNEL
+ * * INVALID_OR_TAKEN_INVITE_CODE
+ * * CANNOT_EXECUTE_ON_SYSTEM_MESSAGE
+ * * CANNOT_EXECUTE_ON_THIS_CHANNEL_TYPE
+ * * INVALID_OAUTH_TOKEN
+ * * MISSING_REQUIRED_OAUTH2_SCOPE
+ * * INVALID_WEBHOOK_TOKEN_PROVIDED
+ * * INVALID_ROLE
+ * * INVALID_RECIPIENT
+ * * BULK_DELETE_MESSAGE_TOO_OLD
+ * * INVALID_FORM_BODY
+ * * INVITE_ACCEPTED_TO_GUILD_NOT_CONTAINING_BOT
+ * * INVALID_API_VERSION
+ * * FILE_EXCEEDS_MAXIMUM_SIZE
+ * * INVALID_FILE_UPLOADED
+ * * CANNOT_SELF_REDEEM_GIFT
+ * * PAYMENT_SOURCE_REQUIRED
+ * * CANNOT_DELETE_COMMUNITY_REQUIRED_CHANNEL
+ * * INVALID_STICKER_SENT
+ * * CANNOT_EDIT_ARCHIVED_THREAD
+ * * INVALID_THREAD_NOTIFICATION_SETTINGS
+ * * BEFORE_EARLIER_THAN_THREAD_CREATION_DATE
+ * * SERVER_NOT_AVAILABLE_IN_YOUR_LOCATION
+ * * SERVER_NEEDS_MONETIZATION_ENABLED
+ * * TWO_FACTOR_REQUIRED
+ * * NO_USERS_WITH_DISCORDTAG_EXIST
+ * * REACTION_BLOCKED
+ * * RESOURCE_OVERLOADED
+ * * STAGE_ALREADY_OPEN
+ * * THREAD_ALREADY_CREATED_FOR_THIS_MESSAGE
+ * * THREAD_IS_LOCKED
+ * * MAXIMUM_NUMBER_OF_ACTIVE_THREADS
+ * * MAXIMUM_NUMBER_OF_ACTIVE_ANNOUNCEMENT_THREADS
+ * * INVALID_JSON_FOR_UPLOADED_LOTTIE_FILE
+ * * LOTTIES_CANNOT_CONTAIN_RASTERIZED_IMAGES
+ * * STICKER_MAXIMUM_FRAMERATE
+ * * STICKER_MAXIMUM_FRAME_COUNT
+ * * LOTTIE_ANIMATION_MAXIMUM_DIMENSIONS
+ * * STICKER_FRAME_RATE_TOO_SMALL_OR_TOO_LARGE
+ * * STICKER_ANIMATION_DURATION_MAXIMUM
+ * * UNKNOWN_VOICE_STATE
+ * @typedef {string} APIError
+ */
+export const DiscordApiErrors = {
+ //https://discord.com/developers/docs/topics/opcodes-and-status-codes#json-json-error-codes
+ GENERAL_ERROR: new ApiError("General error (such as a malformed request body, amongst other things)", 0),
+ UNKNOWN_ACCOUNT: new ApiError("Unknown account", 10001),
+ UNKNOWN_APPLICATION: new ApiError("Unknown application", 10002),
+ UNKNOWN_CHANNEL: new ApiError("Unknown channel", 10003),
+ UNKNOWN_GUILD: new ApiError("Unknown guild", 10004),
+ UNKNOWN_INTEGRATION: new ApiError("Unknown integration", 10005),
+ UNKNOWN_INVITE: new ApiError("Unknown invite", 10006),
+ UNKNOWN_MEMBER: new ApiError("Unknown member", 10007),
+ UNKNOWN_MESSAGE: new ApiError("Unknown message", 10008),
+ UNKNOWN_OVERWRITE: new ApiError("Unknown permission overwrite", 10009),
+ UNKNOWN_PROVIDER: new ApiError("Unknown provider", 10010),
+ UNKNOWN_ROLE: new ApiError("Unknown role", 10011),
+ UNKNOWN_TOKEN: new ApiError("Unknown token", 10012),
+ UNKNOWN_USER: new ApiError("Unknown user", 10013),
+ UNKNOWN_EMOJI: new ApiError("Unknown emoji", 10014),
+ UNKNOWN_WEBHOOK: new ApiError("Unknown webhook", 10015),
+ UNKNOWN_WEBHOOK_SERVICE: new ApiError("Unknown webhook service", 10016),
+ UNKNOWN_SESSION: new ApiError("Unknown session", 10020),
+ UNKNOWN_BAN: new ApiError("Unknown ban", 10026),
+ UNKNOWN_SKU: new ApiError("Unknown SKU", 10027),
+ UNKNOWN_STORE_LISTING: new ApiError("Unknown Store Listing", 10028),
+ UNKNOWN_ENTITLEMENT: new ApiError("Unknown entitlement", 10029),
+ UNKNOWN_BUILD: new ApiError("Unknown build", 10030),
+ UNKNOWN_LOBBY: new ApiError("Unknown lobby", 10031),
+ UNKNOWN_BRANCH: new ApiError("Unknown branch", 10032),
+ UNKNOWN_STORE_DIRECTORY_LAYOUT: new ApiError("Unknown store directory layout", 10033),
+ UNKNOWN_REDISTRIBUTABLE: new ApiError("Unknown redistributable", 10036),
+ UNKNOWN_GIFT_CODE: new ApiError("Unknown gift code", 10038),
+ UNKNOWN_STREAM: new ApiError("Unknown stream", 10049),
+ UNKNOWN_PREMIUM_SERVER_SUBSCRIBE_COOLDOWN: new ApiError("Unknown premium server subscribe cooldown", 10050),
+ UNKNOWN_GUILD_TEMPLATE: new ApiError("Unknown guild template", 10057),
+ UNKNOWN_DISCOVERABLE_SERVER_CATEGORY: new ApiError("Unknown discoverable server category", 10059),
+ UNKNOWN_STICKER: new ApiError("Unknown sticker", 10060),
+ UNKNOWN_INTERACTION: new ApiError("Unknown interaction", 10062),
+ UNKNOWN_APPLICATION_COMMAND: new ApiError("Unknown application command", 10063),
+ UNKNOWN_APPLICATION_COMMAND_PERMISSIONS: new ApiError("Unknown application command permissions", 10066),
+ UNKNOWN_STAGE_INSTANCE: new ApiError("Unknown Stage Instance", 10067),
+ UNKNOWN_GUILD_MEMBER_VERIFICATION_FORM: new ApiError("Unknown Guild Member Verification Form", 10068),
+ UNKNOWN_GUILD_WELCOME_SCREEN: new ApiError("Unknown Guild Welcome Screen", 10069),
+ UNKNOWN_GUILD_SCHEDULED_EVENT: new ApiError("Unknown Guild Scheduled Event", 10070),
+ UNKNOWN_GUILD_SCHEDULED_EVENT_USER: new ApiError("Unknown Guild Scheduled Event User", 10071),
+ BOT_PROHIBITED_ENDPOINT: new ApiError("Bots cannot use this endpoint", 20001),
+ BOT_ONLY_ENDPOINT: new ApiError("Only bots can use this endpoint", 20002),
+ EXPLICIT_CONTENT_CANNOT_BE_SENT_TO_RECIPIENT: new ApiError(
+ "Explicit content cannot be sent to the desired recipient(s)",
+ 20009
+ ),
+ ACTION_NOT_AUTHORIZED_ON_APPLICATION: new ApiError(
+ "You are not authorized to perform this action on this application",
+ 20012
+ ),
+ SLOWMODE_RATE_LIMIT: new ApiError("This action cannot be performed due to slowmode rate limit", 20016),
+ ONLY_OWNER: new ApiError("Only the owner of this account can perform this action", 20018),
+ ANNOUNCEMENT_RATE_LIMITS: new ApiError("This message cannot be edited due to announcement rate limits", 20022),
+ CHANNEL_WRITE_RATELIMIT: new ApiError("The channel you are writing has hit the write rate limit", 20028),
+ WORDS_NOT_ALLOWED: new ApiError(
+ "Your Stage topic, server name, server description, or channel names contain words that are not allowed",
+ 20031
+ ),
+ GUILD_PREMIUM_LEVEL_TOO_LOW: new ApiError("Guild premium subscription level too low", 20035),
+ MAXIMUM_GUILDS: new ApiError("Maximum number of guilds reached ({})", 30001, undefined, ["100"]),
+ MAXIMUM_FRIENDS: new ApiError("Maximum number of friends reached ({})", 30002, undefined, ["1000"]),
+ MAXIMUM_PINS: new ApiError("Maximum number of pins reached for the channel ({})", 30003, undefined, ["50"]),
+ MAXIMUM_NUMBER_OF_RECIPIENTS_REACHED: new ApiError("Maximum number of recipients reached ({})", 30004, undefined, [
+ "10",
+ ]),
+ MAXIMUM_ROLES: new ApiError("Maximum number of guild roles reached ({})", 30005, undefined, ["250"]),
+ MAXIMUM_WEBHOOKS: new ApiError("Maximum number of webhooks reached ({})", 30007, undefined, ["10"]),
+ MAXIMUM_NUMBER_OF_EMOJIS_REACHED: new ApiError("Maximum number of emojis reached", 30008),
+ MAXIMUM_REACTIONS: new ApiError("Maximum number of reactions reached ({})", 30010, undefined, ["20"]),
+ MAXIMUM_CHANNELS: new ApiError("Maximum number of guild channels reached ({})", 30013, undefined, ["500"]),
+ MAXIMUM_ATTACHMENTS: new ApiError("Maximum number of attachments in a message reached ({})", 30015, undefined, [
+ "10",
+ ]),
+ MAXIMUM_INVITES: new ApiError("Maximum number of invites reached ({})", 30016, undefined, ["1000"]),
+ MAXIMUM_ANIMATED_EMOJIS: new ApiError("Maximum number of animated emojis reached", 30018),
+ MAXIMUM_SERVER_MEMBERS: new ApiError("Maximum number of server members reached", 30019),
+ MAXIMUM_SERVER_CATEGORIES: new ApiError(
+ "Maximum number of server categories has been reached ({})",
+ 30030,
+ undefined,
+ ["5"]
+ ),
+ GUILD_ALREADY_HAS_TEMPLATE: new ApiError("Guild already has a template", 30031),
+ MAXIMUM_THREAD_PARTICIPANTS: new ApiError("Max number of thread participants has been reached", 30033),
+ MAXIMUM_BANS_FOR_NON_GUILD_MEMBERS: new ApiError(
+ "Maximum number of bans for non-guild members have been exceeded",
+ 30035
+ ),
+ MAXIMUM_BANS_FETCHES: new ApiError("Maximum number of bans fetches has been reached", 30037),
+ MAXIMUM_STICKERS: new ApiError("Maximum number of stickers reached", 30039),
+ MAXIMUM_PRUNE_REQUESTS: new ApiError("Maximum number of prune requests has been reached. Try again later", 30040),
+ UNAUTHORIZED: new ApiError("Unauthorized. Provide a valid token and try again", 40001),
+ ACCOUNT_VERIFICATION_REQUIRED: new ApiError(
+ "You need to verify your account in order to perform this action",
+ 40002
+ ),
+ OPENING_DIRECT_MESSAGES_TOO_FAST: new ApiError("You are opening direct messages too fast", 40003),
+ REQUEST_ENTITY_TOO_LARGE: new ApiError("Request entity too large. Try sending something smaller in size", 40005),
+ FEATURE_TEMPORARILY_DISABLED: new ApiError("This feature has been temporarily disabled server-side", 40006),
+ USER_BANNED: new ApiError("The user is banned from this guild", 40007),
+ TARGET_USER_IS_NOT_CONNECTED_TO_VOICE: new ApiError("Target user is not connected to voice", 40032),
+ ALREADY_CROSSPOSTED: new ApiError("This message has already been crossposted", 40033),
+ APPLICATION_COMMAND_ALREADY_EXISTS: new ApiError("An application command with that name already exists", 40041),
+ MISSING_ACCESS: new ApiError("Missing access", 50001),
+ INVALID_ACCOUNT_TYPE: new ApiError("Invalid account type", 50002),
+ CANNOT_EXECUTE_ON_DM: new ApiError("Cannot execute action on a DM channel", 50003),
+ EMBED_DISABLED: new ApiError("Guild widget disabled", 50004),
+ CANNOT_EDIT_MESSAGE_BY_OTHER: new ApiError("Cannot edit a message authored by another user", 50005),
+ CANNOT_SEND_EMPTY_MESSAGE: new ApiError("Cannot send an empty message", 50006),
+ CANNOT_MESSAGE_USER: new ApiError("Cannot send messages to this user", 50007),
+ CANNOT_SEND_MESSAGES_IN_VOICE_CHANNEL: new ApiError("Cannot send messages in a voice channel", 50008),
+ CHANNEL_VERIFICATION_LEVEL_TOO_HIGH: new ApiError(
+ "Channel verification level is too high for you to gain access",
+ 50009
+ ),
+ OAUTH2_APPLICATION_BOT_ABSENT: new ApiError("OAuth2 application does not have a bot", 50010),
+ MAXIMUM_OAUTH2_APPLICATIONS: new ApiError("OAuth2 application limit reached", 50011),
+ INVALID_OAUTH_STATE: new ApiError("Invalid OAuth2 state", 50012),
+ MISSING_PERMISSIONS: new ApiError("You lack permissions to perform that action ({})", 50013, undefined, [""]),
+ INVALID_AUTHENTICATION_TOKEN: new ApiError("Invalid authentication token provided", 50014),
+ NOTE_TOO_LONG: new ApiError("Note was too long", 50015),
+ INVALID_BULK_DELETE_QUANTITY: new ApiError(
+ "Provided too few or too many messages to delete. Must provide at least {} and fewer than {} messages to delete",
+ 50016,
+ undefined,
+ ["2", "100"]
+ ),
+ CANNOT_PIN_MESSAGE_IN_OTHER_CHANNEL: new ApiError(
+ "A message can only be pinned to the channel it was sent in",
+ 50019
+ ),
+ INVALID_OR_TAKEN_INVITE_CODE: new ApiError("Invite code was either invalid or taken", 50020),
+ CANNOT_EXECUTE_ON_SYSTEM_MESSAGE: new ApiError("Cannot execute action on a system message", 50021),
+ CANNOT_EXECUTE_ON_THIS_CHANNEL_TYPE: new ApiError("Cannot execute action on this channel type", 50024),
+ INVALID_OAUTH_TOKEN: new ApiError("Invalid OAuth2 access token provided", 50025),
+ MISSING_REQUIRED_OAUTH2_SCOPE: new ApiError("Missing required OAuth2 scope", 50026),
+ INVALID_WEBHOOK_TOKEN_PROVIDED: new ApiError("Invalid webhook token provided", 50027),
+ INVALID_ROLE: new ApiError("Invalid role", 50028),
+ INVALID_RECIPIENT: new ApiError("Invalid Recipient(s)", 50033),
+ BULK_DELETE_MESSAGE_TOO_OLD: new ApiError("A message provided was too old to bulk delete", 50034),
+ INVALID_FORM_BODY: new ApiError(
+ "Invalid form body (returned for both application/json and multipart/form-data bodies), or invalid Content-Type provided",
+ 50035
+ ),
+ INVITE_ACCEPTED_TO_GUILD_NOT_CONTAINING_BOT: new ApiError(
+ "An invite was accepted to a guild the application's bot is not in",
+ 50036
+ ),
+ INVALID_API_VERSION: new ApiError("Invalid API version provided", 50041),
+ FILE_EXCEEDS_MAXIMUM_SIZE: new ApiError("File uploaded exceeds the maximum size", 50045),
+ INVALID_FILE_UPLOADED: new ApiError("Invalid file uploaded", 50046),
+ CANNOT_SELF_REDEEM_GIFT: new ApiError("Cannot self-redeem this gift", 50054),
+ PAYMENT_SOURCE_REQUIRED: new ApiError("Payment source required to redeem gift", 50070),
+ CANNOT_DELETE_COMMUNITY_REQUIRED_CHANNEL: new ApiError(
+ "Cannot delete a channel required for Community guilds",
+ 50074
+ ),
+ INVALID_STICKER_SENT: new ApiError("Invalid sticker sent", 50081),
+ CANNOT_EDIT_ARCHIVED_THREAD: new ApiError(
+ "Tried to perform an operation on an archived thread, such as editing a message or adding a user to the thread",
+ 50083
+ ),
+ INVALID_THREAD_NOTIFICATION_SETTINGS: new ApiError("Invalid thread notification settings", 50084),
+ BEFORE_EARLIER_THAN_THREAD_CREATION_DATE: new ApiError(
+ "before value is earlier than the thread creation date",
+ 50085
+ ),
+ SERVER_NOT_AVAILABLE_IN_YOUR_LOCATION: new ApiError("This server is not available in your location", 50095),
+ SERVER_NEEDS_MONETIZATION_ENABLED: new ApiError(
+ "This server needs monetization enabled in order to perform this action",
+ 50097
+ ),
+ TWO_FACTOR_REQUIRED: new ApiError("Two factor is required for this operation", 60003),
+ NO_USERS_WITH_DISCORDTAG_EXIST: new ApiError("No users with DiscordTag exist", 80004),
+ REACTION_BLOCKED: new ApiError("Reaction was blocked", 90001),
+ RESOURCE_OVERLOADED: new ApiError("API resource is currently overloaded. Try again a little later", 130000),
+ STAGE_ALREADY_OPEN: new ApiError("The Stage is already open", 150006),
+ THREAD_ALREADY_CREATED_FOR_THIS_MESSAGE: new ApiError("A thread has already been created for this message", 160004),
+ THREAD_IS_LOCKED: new ApiError("Thread is locked", 160005),
+ MAXIMUM_NUMBER_OF_ACTIVE_THREADS: new ApiError("Maximum number of active threads reached", 160006),
+ MAXIMUM_NUMBER_OF_ACTIVE_ANNOUNCEMENT_THREADS: new ApiError(
+ "Maximum number of active announcement threads reached",
+ 160007
+ ),
+ INVALID_JSON_FOR_UPLOADED_LOTTIE_FILE: new ApiError("Invalid JSON for uploaded Lottie file", 170001),
+ LOTTIES_CANNOT_CONTAIN_RASTERIZED_IMAGES: new ApiError(
+ "Uploaded Lotties cannot contain rasterized images such as PNG or JPEG",
+ 170002
+ ),
+ STICKER_MAXIMUM_FRAMERATE: new ApiError("Sticker maximum framerate exceeded", 170003),
+ STICKER_MAXIMUM_FRAME_COUNT: new ApiError("Sticker frame count exceeds maximum of {} frames", 170004, undefined, [
+ "1000",
+ ]),
+ LOTTIE_ANIMATION_MAXIMUM_DIMENSIONS: new ApiError("Lottie animation maximum dimensions exceeded", 170005),
+ STICKER_FRAME_RATE_TOO_SMALL_OR_TOO_LARGE: new ApiError(
+ "Sticker frame rate is either too small or too large",
+ 170006
+ ),
+ STICKER_ANIMATION_DURATION_MAXIMUM: new ApiError(
+ "Sticker animation duration exceeds maximum of {} seconds",
+ 170007,
+ undefined,
+ ["5"]
+ ),
+
+ //Other errors
+ UNKNOWN_VOICE_STATE: new ApiError("Unknown Voice State", 10065, 404),
+};
+
+/**
+ * An error encountered while performing an API request (Fosscord only). Here are the potential errors:
+ */
+export const FosscordApiErrors = {
+ MANUALLY_TRIGGERED_ERROR: new ApiError("This is an artificial error", 1, 500),
+ PREMIUM_DISABLED_FOR_GUILD: new ApiError("This guild cannot be boosted", 25001),
+ NO_FURTHER_PREMIUM: new ApiError("This guild does not receive further boosts", 25002),
+ GUILD_PREMIUM_DISABLED_FOR_YOU: new ApiError("This guild cannot be boosted by you", 25003, 403),
+ CANNOT_FRIEND_SELF: new ApiError("Cannot friend oneself", 25009),
+ USER_SPECIFIC_INVITE_WRONG_RECIPIENT: new ApiError("This invite is not meant for you", 25010),
+ USER_SPECIFIC_INVITE_FAILED: new ApiError("Failed to invite user", 25011),
+ CANNOT_MODIFY_USER_GROUP: new ApiError("This user cannot manipulate this group", 25050, 403),
+ CANNOT_REMOVE_SELF_FROM_GROUP: new ApiError("This user cannot remove oneself from user group", 25051),
+ CANNOT_BAN_OPERATOR: new ApiError("Non-OPERATOR cannot ban OPERATOR from instance", 25052),
+ CANNOT_LEAVE_GUILD: new ApiError("You are not allowed to leave guilds that you joined by yourself", 25059, 403),
+ EDITS_DISABLED: new ApiError("You are not allowed to edit your own messages", 25060, 403),
+ DELETE_MESSAGE_DISABLED: new ApiError("You are not allowed to delete your own messages", 25061, 403),
+ FEATURE_PERMANENTLY_DISABLED: new ApiError("This feature has been disabled server-side", 45006, 501),
+ MISSING_RIGHTS: new ApiError("You lack rights to perform that action ({})", 50013, undefined, [""]),
+ CANNOT_REPLACE_BY_BACKFILL: new ApiError("Cannot backfill to message ID that already exists", 55002, 409),
+ CANNOT_BACKFILL_TO_THE_FUTURE: new ApiError("You cannot backfill messages in the future", 55003),
+ CANNOT_GRANT_PERMISSIONS_EXCEEDING_RIGHTS: new ApiError("You cannot grant permissions exceeding your own rights", 50050),
+ ROUTES_LOOPING: new ApiError("Loops in the route definition ({})", 50060, undefined, [""]),
+ CANNOT_REMOVE_ROUTE: new ApiError("Cannot remove message route while it is in effect and being used", 50061),
+};
+
+/**
+ * The value set for a guild's default message notifications, e.g. `ALL`. Here are the available types:
+ * * ALL
+ * * MENTIONS
+ * * MUTED (Fosscord extension)
+ * @typedef {string} DefaultMessageNotifications
+ */
+export const DefaultMessageNotifications = ["ALL", "MENTIONS", "MUTED"];
+
+/**
+ * The value set for a team members's membership state:
+ * * INVITED
+ * * ACCEPTED
+ * * INSERTED (Fosscord extension)
+ * @typedef {string} MembershipStates
+ */
+export const MembershipStates = [
+ "INSERTED",
+ "INVITED",
+ "ACCEPTED",
+];
+
+/**
+ * The value set for a webhook's type:
+ * * Incoming
+ * * Channel Follower
+ * * Custom (Fosscord extension)
+ * @typedef {string} WebhookTypes
+ */
+export const WebhookTypes = [
+ "Custom",
+ "Incoming",
+ "Channel Follower",
+];
+
+function keyMirror(arr: string[]) {
+ let tmp = Object.create(null);
+ for (const value of arr) tmp[value] = value;
+ return tmp;
+}
+
diff --git a/src/util/util/Database.ts b/src/util/util/Database.ts
new file mode 100644
index 00000000..ddbea57d
--- /dev/null
+++ b/src/util/util/Database.ts
@@ -0,0 +1,96 @@
+import path from "path";
+import "reflect-metadata";
+import { DataSource } from "typeorm";
+import * as Models from "../entities";
+import { Migration } from "../entities/Migration";
+import { yellow, green, red } from "picocolors";
+
+// UUID extension option is only supported with postgres
+// We want to generate all id's with Snowflakes that's why we have our own BaseEntity class
+
+var dbConnection: DataSource | undefined;
+let dbConnectionString = process.env.DATABASE || path.join(process.cwd(), "database.db");
+
+export function getDatabase(): DataSource | null {
+ // if (!dbConnection) throw new Error("Tried to get database before it was initialised");
+ if (!dbConnection) return null;
+ return dbConnection;
+}
+
+export async function initDatabase(): Promise<DataSource> {
+ if (dbConnection) return dbConnection;
+
+ const type = dbConnectionString.includes("://") ? dbConnectionString.split(":")[0]?.replace("+srv", "") : "sqlite";
+ const isSqlite = type.includes("sqlite");
+
+ console.log(`[Database] ${yellow(`connecting to ${type} db`)}`);
+ if (isSqlite) {
+ console.log(`[Database] ${red(`You are running sqlite! Please keep in mind that we recommend setting up a dedicated database!`)}`);
+ }
+
+ const dataSource = new DataSource({
+ //@ts-ignore
+ type,
+ charset: 'utf8mb4',
+ url: isSqlite ? undefined : dbConnectionString,
+ database: isSqlite ? dbConnectionString : undefined,
+ entities: ["dist/util/entities/*.js"],
+ synchronize: type !== "mongodb",
+ logging: false,
+ bigNumberStrings: false,
+ supportBigNumbers: true,
+ name: "default",
+ // migrations: [path.join(__dirname, "..", "migrations", "*.js")],
+ });
+
+ dbConnection = await dataSource.initialize();
+
+ // // @ts-ignore
+ // promise = createConnection({
+ // type,
+ // charset: 'utf8mb4',
+ // url: isSqlite ? undefined : dbConnectionString,
+ // database: isSqlite ? dbConnectionString : undefined,
+ // // @ts-ignore
+ // entities: Object.values(Models).filter((x) => x?.constructor?.name !== "Object" && x?.name),
+ // synchronize: type !== "mongodb",
+ // logging: false,
+ // // cache: { // cache is used only by query builder and entity manager
+ // // duration: 1000 * 30,
+ // // type: "redis",
+ // // options: {
+ // // host: "localhost",
+ // // port: 6379,
+ // // },
+ // // },
+ // bigNumberStrings: false,
+ // supportBigNumbers: true,
+ // name: "default",
+ // migrations: [path.join(__dirname, "..", "migrations", "*.js")],
+ // });
+
+ // // run migrations, and if it is a new fresh database, set it to the last migration
+ // if (dbConnection.migrations.length) {
+ // if (!(await Migration.findOne({ }))) {
+ // let i = 0;
+
+ // await Migration.insert(
+ // dbConnection.migrations.map((x) => ({
+ // id: i++,
+ // name: x.name,
+ // timestamp: Date.now(),
+ // }))
+ // );
+ // }
+ // }
+ await dbConnection.runMigrations();
+ console.log(`[Database] ${green("connected")}`);
+
+ return dbConnection;
+}
+
+export { dbConnection };
+
+export function closeDatabase() {
+ dbConnection?.destroy();
+}
diff --git a/src/util/util/Email.ts b/src/util/util/Email.ts
new file mode 100644
index 00000000..6885da33
--- /dev/null
+++ b/src/util/util/Email.ts
@@ -0,0 +1,25 @@
+export const EMAIL_REGEX =
+ /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+
+export function adjustEmail(email?: string): string | undefined {
+ if (!email) return email;
+ // body parser already checked if it is a valid email
+ const parts = <RegExpMatchArray>email.match(EMAIL_REGEX);
+ // @ts-ignore
+ if (!parts || parts.length < 5) return undefined;
+ const domain = parts[5];
+ const user = parts[1];
+
+ // TODO: check accounts with uncommon email domains
+ if (domain === "gmail.com" || domain === "googlemail.com") {
+ // replace .dots and +alternatives -> Gmail Dot Trick https://support.google.com/mail/answer/7436150 and https://generator.email/blog/gmail-generator
+ let v = user.replace(/[.]|(\+.*)/g, "") + "@gmail.com";
+ }
+
+ if (domain === "google.com") {
+ // replace .dots and +alternatives -> Google Staff GMail Dot Trick
+ let v = user.replace(/[.]|(\+.*)/g, "") + "@google.com";
+ }
+
+ return email;
+}
diff --git a/src/util/util/Event.ts b/src/util/util/Event.ts
new file mode 100644
index 00000000..20a638a0
--- /dev/null
+++ b/src/util/util/Event.ts
@@ -0,0 +1,123 @@
+import { Channel } from "amqplib";
+import { RabbitMQ } from "./RabbitMQ";
+import EventEmitter from "events";
+import { EVENT, Event } from "../interfaces";
+export const events = new EventEmitter();
+
+export async function emitEvent(payload: Omit<Event, "created_at">) {
+ const id = (payload.channel_id || payload.user_id || payload.guild_id) as string;
+ if (!id) return console.error("event doesn't contain any id", payload);
+
+ if (RabbitMQ.connection) {
+ const data = typeof payload.data === "object" ? JSON.stringify(payload.data) : payload.data; // use rabbitmq for event transmission
+ await RabbitMQ.channel?.assertExchange(id, "fanout", { durable: false });
+
+ // assertQueue isn't needed, because a queue will automatically created if it doesn't exist
+ const successful = RabbitMQ.channel?.publish(id, "", Buffer.from(`${data}`), { type: payload.event });
+ if (!successful) throw new Error("failed to send event");
+ } else if (process.env.EVENT_TRANSMISSION === "process") {
+ process.send?.({ type: "event", event: payload, id } as ProcessEvent);
+ } else {
+ events.emit(id, payload);
+ }
+}
+
+export async function initEvent() {
+ await RabbitMQ.init(); // does nothing if rabbitmq is not setup
+ if (RabbitMQ.connection) {
+ } else {
+ // use event emitter
+ // use process messages
+ }
+}
+
+export interface EventOpts extends Event {
+ acknowledge?: Function;
+ channel?: Channel;
+ cancel: Function;
+}
+
+export interface ListenEventOpts {
+ channel?: Channel;
+ acknowledge?: boolean;
+}
+
+export interface ProcessEvent {
+ type: "event";
+ event: Event;
+ id: string;
+}
+
+export async function listenEvent(event: string, callback: (event: EventOpts) => any, opts?: ListenEventOpts) {
+ if (RabbitMQ.connection) {
+ // @ts-ignore
+ return rabbitListen(opts?.channel || RabbitMQ.channel, event, callback, { acknowledge: opts?.acknowledge });
+ } else if (process.env.EVENT_TRANSMISSION === "process") {
+ const cancel = () => {
+ process.removeListener("message", listener);
+ process.setMaxListeners(process.getMaxListeners() - 1);
+ };
+
+ const listener = (msg: ProcessEvent) => {
+ msg.type === "event" && msg.id === event && callback({ ...msg.event, cancel });
+ };
+
+ //@ts-ignore apparently theres no function addListener with this signature
+ process.addListener("message", listener);
+ process.setMaxListeners(process.getMaxListeners() + 1);
+
+ return cancel;
+ } else {
+ const listener = (opts: any) => callback({ ...opts, cancel });
+ const cancel = () => {
+ events.removeListener(event, listener);
+ events.setMaxListeners(events.getMaxListeners() - 1);
+ };
+ events.setMaxListeners(events.getMaxListeners() + 1);
+ events.addListener(event, listener);
+
+ return cancel;
+ }
+}
+
+async function rabbitListen(
+ channel: Channel,
+ id: string,
+ callback: (event: EventOpts) => any,
+ opts?: { acknowledge?: boolean }
+) {
+ await channel.assertExchange(id, "fanout", { durable: false });
+ const q = await channel.assertQueue("", { exclusive: true, autoDelete: true });
+
+ const cancel = () => {
+ channel.cancel(q.queue);
+ channel.unbindQueue(q.queue, id, "");
+ };
+
+ channel.bindQueue(q.queue, id, "");
+ channel.consume(
+ q.queue,
+ (opts) => {
+ if (!opts) return;
+
+ const data = JSON.parse(opts.content.toString());
+ const event = opts.properties.type as EVENT;
+
+ callback({
+ event,
+ data,
+ acknowledge() {
+ channel.ack(opts);
+ },
+ channel,
+ cancel,
+ });
+ // rabbitCh.ack(opts);
+ },
+ {
+ noAck: !opts?.acknowledge,
+ }
+ );
+
+ return cancel;
+}
diff --git a/src/util/util/FieldError.ts b/src/util/util/FieldError.ts
new file mode 100644
index 00000000..406b33e8
--- /dev/null
+++ b/src/util/util/FieldError.ts
@@ -0,0 +1,25 @@
+import "missing-native-js-functions";
+
+export function FieldErrors(fields: Record<string, { code?: string; message: string }>) {
+ return new FieldError(
+ 50035,
+ "Invalid Form Body",
+ fields.map(({ message, code }) => ({
+ _errors: [
+ {
+ message,
+ code: code || "BASE_TYPE_INVALID",
+ },
+ ],
+ }))
+ );
+}
+
+// TODO: implement Image data type: Data URI scheme that supports JPG, GIF, and PNG formats. An example Data URI format is: data:image/jpeg;base64,BASE64_ENCODED_JPEG_IMAGE_DATA
+// Ensure you use the proper content type (image/jpeg, image/png, image/gif) that matches the image data being provided.
+
+export class FieldError extends Error {
+ constructor(public code: string | number, public message: string, public errors?: any) {
+ super(message);
+ }
+}
diff --git a/src/util/util/Intents.ts b/src/util/util/Intents.ts
new file mode 100644
index 00000000..1e840b76
--- /dev/null
+++ b/src/util/util/Intents.ts
@@ -0,0 +1,34 @@
+import { BitField } from "./BitField";
+
+export class Intents extends BitField {
+ static FLAGS = {
+ GUILDS: BigInt(1) << BigInt(0), // guilds and guild merge-split events affecting the user
+ GUILD_MEMBERS: BigInt(1) << BigInt(1), // memberships
+ GUILD_BANS: BigInt(1) << BigInt(2), // bans and ban lists
+ GUILD_EMOJIS: BigInt(1) << BigInt(3), // custom emojis
+ GUILD_INTEGRATIONS: BigInt(1) << BigInt(4), // applications
+ GUILD_WEBHOOKS: BigInt(1) << BigInt(5), // webhooks
+ GUILD_INVITES: BigInt(1) << BigInt(6), // mass invites (no user can receive user specific invites of another user)
+ GUILD_VOICE_STATES: BigInt(1) << BigInt(7), // voice updates
+ GUILD_PRESENCES: BigInt(1) << BigInt(8), // presence updates
+ GUILD_MESSAGES_METADATA: BigInt(1) << BigInt(9), // guild message metadata
+ GUILD_MESSAGE_REACTIONS: BigInt(1) << BigInt(10), // guild message reactions
+ GUILD_MESSAGE_TYPING: BigInt(1) << BigInt(11), // guild channel typing notifications
+ DIRECT_MESSAGES: BigInt(1) << BigInt(12), // DM or orphan channels
+ DIRECT_MESSAGE_REACTIONS: BigInt(1) << BigInt(13), // DM or orphan channel message reactions
+ DIRECT_MESSAGE_TYPING: BigInt(1) << BigInt(14), // DM typing notifications
+ GUILD_MESSAGES_CONTENT: BigInt(1) << BigInt(15), // guild message content
+ GUILD_POLICIES: BigInt(1) << BigInt(20), // guild policies
+ GUILD_POLICY_EXECUTION: BigInt(1) << BigInt(21), // guild policy execution
+ LIVE_MESSAGE_COMPOSITION: BigInt(1) << BigInt(32), // allow composing messages using the gateway
+ GUILD_ROUTES: BigInt(1) << BigInt(41), // message routes affecting the guild
+ DIRECT_MESSAGES_THREADS: BigInt(1) << BigInt(42), // direct message threads
+ JUMBO_EVENTS: BigInt(1) << BigInt(43), // jumbo events (size limits to be defined later)
+ LOBBIES: BigInt(1) << BigInt(44), // lobbies
+ INSTANCE_ROUTES: BigInt(1) << BigInt(60), // all message route changes
+ INSTANCE_GUILD_CHANGES: BigInt(1) << BigInt(61), // all guild create, guild object patch, split, merge and delete events
+ INSTANCE_POLICY_UPDATES: BigInt(1) << BigInt(62), // all instance policy updates
+ INSTANCE_USER_UPDATES: BigInt(1) << BigInt(63) // all instance user updates
+ };
+}
+
diff --git a/src/util/util/InvisibleCharacters.ts b/src/util/util/InvisibleCharacters.ts
new file mode 100644
index 00000000..a48cfab0
--- /dev/null
+++ b/src/util/util/InvisibleCharacters.ts
@@ -0,0 +1,56 @@
+// List from https://invisible-characters.com/
+export const InvisibleCharacters = [
+ '\u{9}', //Tab
+ //'\u{20}', //Space //categories can have spaces in them
+ '\u{ad}', //Soft hyphen
+ '\u{34f}', //Combining grapheme joiner
+ '\u{61c}', //Arabic letter mark
+ '\u{115f}', //Hangul choseong filler
+ '\u{1160}', //Hangul jungseong filler
+ '\u{17b4}', //Khmer vowel inherent AQ
+ '\u{17b5}', //Khmer vowel inherent AA
+ '\u{180e}', //Mongolian vowel separator
+ '\u{2000}', //En quad
+ '\u{2001}', //Em quad
+ '\u{2002}', //En space
+ '\u{2003}', //Em space
+ '\u{2004}', //Three-per-em space
+ '\u{2005}', //Four-per-em space
+ '\u{2006}', //Six-per-em space
+ '\u{2007}', //Figure space
+ '\u{2008}', //Punctuation space
+ '\u{2009}', //Thin space
+ '\u{200a}', //Hair space
+ '\u{200b}', //Zero width space
+ '\u{200c}', //Zero width non-joiner
+ '\u{200d}', //Zero width joiner
+ '\u{200e}', //Left-to-right mark
+ '\u{200f}', //Right-to-left mark
+ '\u{202f}', //Narrow no-break space
+ '\u{205f}', //Medium mathematical space
+ '\u{2060}', //Word joiner
+ '\u{2061}', //Function application
+ '\u{2062}', //Invisible times
+ '\u{2063}', //Invisible separator
+ '\u{2064}', //Invisible plus
+ '\u{206a}', //Inhibit symmetric swapping
+ '\u{206b}', //Activate symmetric swapping
+ '\u{206c}', //Inhibit arabic form shaping
+ '\u{206d}', //Activate arabic form shaping
+ '\u{206e}', //National digit shapes
+ '\u{206f}', //Nominal digit shapes
+ '\u{3000}', //Ideographic space
+ '\u{2800}', //Braille pattern blank
+ '\u{3164}', //Hangul filler
+ '\u{feff}', //Zero width no-break space
+ '\u{ffa0}', //Haldwidth hangul filler
+ '\u{1d159}', //Musical symbol null notehead
+ '\u{1d173}', //Musical symbol begin beam
+ '\u{1d174}', //Musical symbol end beam
+ '\u{1d175}', //Musical symbol begin tie
+ '\u{1d176}', //Musical symbol end tie
+ '\u{1d177}', //Musical symbol begin slur
+ '\u{1d178}', //Musical symbol end slur
+ '\u{1d179}', //Musical symbol begin phrase
+ '\u{1d17a}' //Musical symbol end phrase
+];
\ No newline at end of file
diff --git a/src/util/util/MessageFlags.ts b/src/util/util/MessageFlags.ts
new file mode 100644
index 00000000..b59295c4
--- /dev/null
+++ b/src/util/util/MessageFlags.ts
@@ -0,0 +1,20 @@
+// based on https://github.com/discordjs/discord.js/blob/master/src/util/MessageFlags.js
+// Apache License Version 2.0 Copyright 2015 - 2021 Amish Shah, 2022 Erkin Alp Güney
+
+import { BitField } from "./BitField";
+
+export class MessageFlags extends BitField {
+ static FLAGS = {
+ CROSSPOSTED: BigInt(1) << BigInt(0),
+ IS_CROSSPOST: BigInt(1) << BigInt(1),
+ SUPPRESS_EMBEDS: BigInt(1) << BigInt(2),
+ // SOURCE_MESSAGE_DELETED: BigInt(1) << BigInt(3), // fosscord will delete them from destination too, making this redundant
+ URGENT: BigInt(1) << BigInt(4),
+ // HAS_THREAD: BigInt(1) << BigInt(5) // does not apply to fosscord due to infrastructural differences
+ PRIVATE_ROUTE: BigInt(1) << BigInt(6), // it that has been routed to only some of the users that can see the channel
+ INTERACTION_WAIT: BigInt(1) << BigInt(7), // discord.com calls this LOADING
+ // FAILED_TO_MENTION_SOME_ROLES_IN_THREAD: BigInt(1) << BigInt(8)
+ SCRIPT_WAIT: BigInt(1) << BigInt(24), // waiting for the self command to complete
+ IMPORT_WAIT: BigInt(1) << BigInt(25), // latest message of a bulk import, waiting for the rest of the channel to be backfilled
+ };
+}
diff --git a/src/util/util/Permissions.ts b/src/util/util/Permissions.ts
new file mode 100644
index 00000000..e5459ab5
--- /dev/null
+++ b/src/util/util/Permissions.ts
@@ -0,0 +1,281 @@
+// https://github.com/discordjs/discord.js/blob/master/src/util/Permissions.js
+// Apache License Version 2.0 Copyright 2015 - 2021 Amish Shah
+import { Channel, ChannelPermissionOverwrite, Guild, Member, Role } from "../entities";
+import { BitField } from "./BitField";
+import "missing-native-js-functions";
+import { BitFieldResolvable, BitFlag } from "./BitField";
+
+var HTTPError: any;
+
+try {
+ HTTPError = require("lambert-server").HTTPError;
+} catch (e) {
+ HTTPError = Error;
+}
+
+export type PermissionResolvable = bigint | number | Permissions | PermissionResolvable[] | PermissionString;
+
+type PermissionString = keyof typeof Permissions.FLAGS;
+
+// BigInt doesn't have a bit limit (https://stackoverflow.com/questions/53335545/whats-the-biggest-bigint-value-in-js-as-per-spec)
+const CUSTOM_PERMISSION_OFFSET = BigInt(1) << BigInt(64); // 27 permission bits left for discord to add new ones
+
+export class Permissions extends BitField {
+ cache: PermissionCache = {};
+
+ constructor(bits: BitFieldResolvable = 0) {
+ super(bits);
+ if (this.bitfield & Permissions.FLAGS.ADMINISTRATOR) {
+ this.bitfield = ALL_PERMISSIONS;
+ }
+ }
+
+ static FLAGS = {
+ CREATE_INSTANT_INVITE: BitFlag(0),
+ KICK_MEMBERS: BitFlag(1),
+ BAN_MEMBERS: BitFlag(2),
+ ADMINISTRATOR: BitFlag(3),
+ MANAGE_CHANNELS: BitFlag(4),
+ MANAGE_GUILD: BitFlag(5),
+ ADD_REACTIONS: BitFlag(6),
+ VIEW_AUDIT_LOG: BitFlag(7),
+ PRIORITY_SPEAKER: BitFlag(8),
+ STREAM: BitFlag(9),
+ VIEW_CHANNEL: BitFlag(10),
+ SEND_MESSAGES: BitFlag(11),
+ SEND_TTS_MESSAGES: BitFlag(12),
+ MANAGE_MESSAGES: BitFlag(13),
+ EMBED_LINKS: BitFlag(14),
+ ATTACH_FILES: BitFlag(15),
+ READ_MESSAGE_HISTORY: BitFlag(16),
+ MENTION_EVERYONE: BitFlag(17),
+ USE_EXTERNAL_EMOJIS: BitFlag(18),
+ VIEW_GUILD_INSIGHTS: BitFlag(19),
+ CONNECT: BitFlag(20),
+ SPEAK: BitFlag(21),
+ MUTE_MEMBERS: BitFlag(22),
+ DEAFEN_MEMBERS: BitFlag(23),
+ MOVE_MEMBERS: BitFlag(24),
+ USE_VAD: BitFlag(25),
+ CHANGE_NICKNAME: BitFlag(26),
+ MANAGE_NICKNAMES: BitFlag(27),
+ MANAGE_ROLES: BitFlag(28),
+ MANAGE_WEBHOOKS: BitFlag(29),
+ MANAGE_EMOJIS_AND_STICKERS: BitFlag(30),
+ USE_APPLICATION_COMMANDS: BitFlag(31),
+ REQUEST_TO_SPEAK: BitFlag(32),
+ // TODO: what is permission 33?
+ MANAGE_THREADS: BitFlag(34),
+ USE_PUBLIC_THREADS: BitFlag(35),
+ USE_PRIVATE_THREADS: BitFlag(36),
+ USE_EXTERNAL_STICKERS: BitFlag(37),
+
+ /**
+ * CUSTOM PERMISSIONS ideas:
+ * - allow user to dm members
+ * - allow user to pin messages (without MANAGE_MESSAGES)
+ * - allow user to publish messages (without MANAGE_MESSAGES)
+ */
+ // CUSTOM_PERMISSION: BigInt(1) << BigInt(0) + CUSTOM_PERMISSION_OFFSET
+ };
+
+ any(permission: PermissionResolvable, checkAdmin = true) {
+ return (checkAdmin && super.any(Permissions.FLAGS.ADMINISTRATOR)) || super.any(permission);
+ }
+
+ /**
+ * Checks whether the bitfield has a permission, or multiple permissions.
+ */
+ has(permission: PermissionResolvable, checkAdmin = true) {
+ return (checkAdmin && super.has(Permissions.FLAGS.ADMINISTRATOR)) || super.has(permission);
+ }
+
+ /**
+ * Checks whether the bitfield has a permission, or multiple permissions, but throws an Error if user fails to match auth criteria.
+ */
+ hasThrow(permission: PermissionResolvable) {
+ if (this.has(permission) && this.has("VIEW_CHANNEL")) return true;
+ // @ts-ignore
+ throw new HTTPError(`You are missing the following permissions ${permission}`, 403);
+ }
+
+ overwriteChannel(overwrites: ChannelPermissionOverwrite[]) {
+ if (!overwrites) return this;
+ if (!this.cache) throw new Error("permission chache not available");
+ overwrites = overwrites.filter((x) => {
+ if (x.type === 0 && this.cache.roles?.some((r) => r.id === x.id)) return true;
+ if (x.type === 1 && x.id == this.cache.user_id) return true;
+ return false;
+ });
+ return new Permissions(Permissions.channelPermission(overwrites, this.bitfield));
+ }
+
+ static channelPermission(overwrites: ChannelPermissionOverwrite[], init?: bigint) {
+ // TODO: do not deny any permissions if admin
+ return overwrites.reduce((permission, overwrite) => {
+ // apply disallowed permission
+ // * permission: current calculated permission (e.g. 010)
+ // * deny contains all denied permissions (e.g. 011)
+ // * allow contains all explicitly allowed permisions (e.g. 100)
+ return (permission & ~BigInt(overwrite.deny)) | BigInt(overwrite.allow);
+ // ~ operator inverts deny (e.g. 011 -> 100)
+ // & operator only allows 1 for both ~deny and permission (e.g. 010 & 100 -> 000)
+ // | operators adds both together (e.g. 000 + 100 -> 100)
+ }, init || BigInt(0));
+ }
+
+ static rolePermission(roles: Role[]) {
+ // adds all permissions of all roles together (Bit OR)
+ return roles.reduce((permission, role) => permission | BigInt(role.permissions), BigInt(0));
+ }
+
+ static finalPermission({
+ user,
+ guild,
+ channel,
+ }: {
+ user: { id: string; roles: string[] };
+ guild: { roles: Role[] };
+ channel?: {
+ overwrites?: ChannelPermissionOverwrite[];
+ recipient_ids?: string[] | null;
+ owner_id?: string;
+ };
+ }) {
+ if (user.id === "0") return new Permissions("ADMINISTRATOR"); // system user id
+
+ let roles = guild.roles.filter((x) => user.roles.includes(x.id));
+ let permission = Permissions.rolePermission(roles);
+
+ if (channel?.overwrites) {
+ let overwrites = channel.overwrites.filter((x) => {
+ if (x.type === 0 && user.roles.includes(x.id)) return true;
+ if (x.type === 1 && x.id == user.id) return true;
+ return false;
+ });
+ permission = Permissions.channelPermission(overwrites, permission);
+ }
+
+ if (channel?.recipient_ids) {
+ if (channel?.owner_id === user.id) return new Permissions("ADMINISTRATOR");
+ if (channel.recipient_ids.includes(user.id)) {
+ // Default dm permissions
+ return new Permissions([
+ "VIEW_CHANNEL",
+ "SEND_MESSAGES",
+ "STREAM",
+ "ADD_REACTIONS",
+ "EMBED_LINKS",
+ "ATTACH_FILES",
+ "READ_MESSAGE_HISTORY",
+ "MENTION_EVERYONE",
+ "USE_EXTERNAL_EMOJIS",
+ "CONNECT",
+ "SPEAK",
+ "MANAGE_CHANNELS",
+ ]);
+ }
+
+ return new Permissions();
+ }
+
+ return new Permissions(permission);
+ }
+}
+
+const ALL_PERMISSIONS = Object.values(Permissions.FLAGS).reduce((total, val) => total | val, BigInt(0));
+
+export type PermissionCache = {
+ channel?: Channel | undefined;
+ member?: Member | undefined;
+ guild?: Guild | undefined;
+ roles?: Role[] | undefined;
+ user_id?: string;
+};
+
+export async function getPermission(
+ user_id?: string,
+ guild_id?: string,
+ channel_id?: string,
+ opts: {
+ guild_select?: (keyof Guild)[];
+ guild_relations?: string[];
+ channel_select?: (keyof Channel)[];
+ channel_relations?: string[];
+ member_select?: (keyof Member)[];
+ member_relations?: string[];
+ } = {}
+) {
+ if (!user_id) throw new HTTPError("User not found");
+ var channel: Channel | undefined;
+ var member: Member | undefined;
+ var guild: Guild | undefined;
+
+ if (channel_id) {
+ channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ relations: ["recipients", ...(opts.channel_relations || [])],
+ select: [
+ "id",
+ "recipients",
+ "permission_overwrites",
+ "owner_id",
+ "guild_id",
+ // @ts-ignore
+ ...(opts.channel_select || []),
+ ],
+ });
+ if (channel.guild_id) guild_id = channel.guild_id; // derive guild_id from the channel
+ }
+
+ if (guild_id) {
+ guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: [
+ "id",
+ "owner_id",
+ // @ts-ignore
+ ...(opts.guild_select || []),
+ ],
+ relations: opts.guild_relations,
+ });
+ if (guild.owner_id === user_id) return new Permissions(Permissions.FLAGS.ADMINISTRATOR);
+
+ member = await Member.findOneOrFail({
+ where: { guild_id, id: user_id },
+ relations: ["roles", ...(opts.member_relations || [])],
+ select: [
+ "id",
+ "roles",
+ // @ts-ignore
+ ...(opts.member_select || []),
+ ],
+ });
+ }
+
+ let recipient_ids: any = channel?.recipients?.map((x) => x.user_id);
+ if (!recipient_ids?.length) recipient_ids = null;
+
+ // TODO: remove guild.roles and convert recipient_ids to recipients
+ var permission = Permissions.finalPermission({
+ user: {
+ id: user_id,
+ roles: member?.roles.map((x) => x.id) || [],
+ },
+ guild: {
+ roles: member?.roles || [],
+ },
+ channel: {
+ overwrites: channel?.permission_overwrites,
+ owner_id: channel?.owner_id,
+ recipient_ids,
+ },
+ });
+
+ const obj = new Permissions(permission);
+
+ // pass cache to permission for possible future getPermission calls
+ obj.cache = { guild, member, channel, roles: member?.roles, user_id };
+
+ return obj;
+}
diff --git a/src/util/util/RabbitMQ.ts b/src/util/util/RabbitMQ.ts
new file mode 100644
index 00000000..0f5eb6aa
--- /dev/null
+++ b/src/util/util/RabbitMQ.ts
@@ -0,0 +1,19 @@
+import amqp, { Connection, Channel } from "amqplib";
+// import Config from "./Config";
+
+export const RabbitMQ: { connection: Connection | null; channel: Channel | null; init: () => Promise<void> } = {
+ connection: null,
+ channel: null,
+ init: async function () {
+ return;
+ // const host = Config.get().rabbitmq.host;
+ // if (!host) return;
+ // console.log(`[RabbitMQ] connect: ${host}`);
+ // this.connection = await amqp.connect(host, {
+ // timeout: 1000 * 60,
+ // });
+ // console.log(`[RabbitMQ] connected`);
+ // this.channel = await this.connection.createChannel();
+ // console.log(`[RabbitMQ] channel created`);
+ },
+};
diff --git a/src/util/util/Regex.ts b/src/util/util/Regex.ts
new file mode 100644
index 00000000..83fc9fe8
--- /dev/null
+++ b/src/util/util/Regex.ts
@@ -0,0 +1,7 @@
+export const DOUBLE_WHITE_SPACE = /\s\s+/g;
+export const SPECIAL_CHAR = /[@#`:\r\n\t\f\v\p{C}]/gu;
+export const CHANNEL_MENTION = /<#(\d+)>/g;
+export const USER_MENTION = /<@!?(\d+)>/g;
+export const ROLE_MENTION = /<@&(\d+)>/g;
+export const EVERYONE_MENTION = /@everyone/g;
+export const HERE_MENTION = /@here/g;
diff --git a/src/util/util/Rights.ts b/src/util/util/Rights.ts
new file mode 100644
index 00000000..b28c75b7
--- /dev/null
+++ b/src/util/util/Rights.ts
@@ -0,0 +1,102 @@
+import { BitField } from "./BitField";
+import "missing-native-js-functions";
+import { BitFieldResolvable, BitFlag } from "./BitField";
+import { User } from "../entities";
+
+var HTTPError: any;
+
+try {
+ HTTPError = require("lambert-server").HTTPError;
+} catch (e) {
+ HTTPError = Error;
+}
+
+export type RightResolvable = bigint | number | Rights | RightResolvable[] | RightString;
+
+type RightString = keyof typeof Rights.FLAGS;
+// TODO: just like roles for members, users should have privilidges which combine multiple rights into one and make it easy to assign
+
+export class Rights extends BitField {
+ constructor(bits: BitFieldResolvable = 0) {
+ super(bits);
+ if (this.bitfield & Rights.FLAGS.OPERATOR) {
+ this.bitfield = ALL_RIGHTS;
+ }
+ }
+
+ static FLAGS = {
+ OPERATOR: BitFlag(0), // has all rights
+ MANAGE_APPLICATIONS: BitFlag(1),
+ MANAGE_GUILDS: BitFlag(2),
+ MANAGE_MESSAGES: BitFlag(3), // Can't see other messages but delete/edit them in channels that they can see
+ MANAGE_RATE_LIMITS: BitFlag(4),
+ MANAGE_ROUTING: BitFlag(5), // can create custom message routes to any channel/guild
+ MANAGE_TICKETS: BitFlag(6), // can respond to and resolve support tickets
+ MANAGE_USERS: BitFlag(7),
+ ADD_MEMBERS: BitFlag(8), // can manually add any members in their guilds
+ BYPASS_RATE_LIMITS: BitFlag(9),
+ CREATE_APPLICATIONS: BitFlag(10),
+ CREATE_CHANNELS: BitFlag(11), // can create guild channels or threads in the guilds that they have permission
+ CREATE_DMS: BitFlag(12),
+ CREATE_DM_GROUPS: BitFlag(13), // can create group DMs or custom orphan channels
+ CREATE_GUILDS: BitFlag(14),
+ CREATE_INVITES: BitFlag(15), // can create mass invites in the guilds that they have CREATE_INSTANT_INVITE
+ CREATE_ROLES: BitFlag(16),
+ CREATE_TEMPLATES: BitFlag(17),
+ CREATE_WEBHOOKS: BitFlag(18),
+ JOIN_GUILDS: BitFlag(19),
+ PIN_MESSAGES: BitFlag(20),
+ SELF_ADD_REACTIONS: BitFlag(21),
+ SELF_DELETE_MESSAGES: BitFlag(22),
+ SELF_EDIT_MESSAGES: BitFlag(23),
+ SELF_EDIT_NAME: BitFlag(24),
+ SEND_MESSAGES: BitFlag(25),
+ USE_ACTIVITIES: BitFlag(26), // use (game) activities in voice channels (e.g. Watch together)
+ USE_VIDEO: BitFlag(27),
+ USE_VOICE: BitFlag(28),
+ INVITE_USERS: BitFlag(29), // can create user-specific invites in the guilds that they have INVITE_USERS
+ SELF_DELETE_DISABLE: BitFlag(30), // can disable/delete own account
+ DEBTABLE: BitFlag(31), // can use pay-to-use features
+ CREDITABLE: BitFlag(32), // can receive money from monetisation related features
+ KICK_BAN_MEMBERS: BitFlag(33),
+ // can kick or ban guild or group DM members in the guilds/groups that they have KICK_MEMBERS, or BAN_MEMBERS
+ SELF_LEAVE_GROUPS: BitFlag(34),
+ // can leave the guilds or group DMs that they joined on their own (one can always leave a guild or group DMs they have been force-added)
+ PRESENCE: BitFlag(35),
+ // inverts the presence confidentiality default (OPERATOR's presence is not routed by default, others' are) for a given user
+ SELF_ADD_DISCOVERABLE: BitFlag(36), // can mark discoverable guilds that they have permissions to mark as discoverable
+ MANAGE_GUILD_DIRECTORY: BitFlag(37), // can change anything in the primary guild directory
+ POGGERS: BitFlag(38), // can send confetti, screenshake, random user mention (@someone)
+ USE_ACHIEVEMENTS: BitFlag(39), // can use achievements and cheers
+ INITIATE_INTERACTIONS: BitFlag(40), // can initiate interactions
+ RESPOND_TO_INTERACTIONS: BitFlag(41), // can respond to interactions
+ SEND_BACKDATED_EVENTS: BitFlag(42), // can send backdated events
+ USE_MASS_INVITES: BitFlag(43), // added per @xnacly's request — can accept mass invites
+ ACCEPT_INVITES: BitFlag(44) // added per @xnacly's request — can accept user-specific invites and DM requests
+ };
+
+ any(permission: RightResolvable, checkOperator = true) {
+ return (checkOperator && super.any(Rights.FLAGS.OPERATOR)) || super.any(permission);
+ }
+
+ has(permission: RightResolvable, checkOperator = true) {
+ return (checkOperator && super.has(Rights.FLAGS.OPERATOR)) || super.has(permission);
+ }
+
+ hasThrow(permission: RightResolvable) {
+ if (this.has(permission)) return true;
+ // @ts-ignore
+ throw new HTTPError(`You are missing the following rights ${permission}`, 403);
+ }
+
+}
+
+const ALL_RIGHTS = Object.values(Rights.FLAGS).reduce((total, val) => total | val, BigInt(0));
+
+export async function getRights( user_id: string
+ /**, opts: {
+ in_behalf?: (keyof User)[];
+ } = {} **/) {
+ let user = await User.findOneOrFail({ where: { id: user_id } });
+ return new Rights(user.rights);
+}
diff --git a/src/util/util/Snowflake.ts b/src/util/util/Snowflake.ts
new file mode 100644
index 00000000..134d526e
--- /dev/null
+++ b/src/util/util/Snowflake.ts
@@ -0,0 +1,130 @@
+// @ts-nocheck
+import * as cluster from "cluster";
+
+// https://github.com/discordjs/discord.js/blob/master/src/util/Snowflake.js
+// Apache License Version 2.0 Copyright 2015 - 2021 Amish Shah
+("use strict");
+
+// Discord epoch (2015-01-01T00:00:00.000Z)
+
+/**
+ * A container for useful snowflake-related methods.
+ */
+export class Snowflake {
+ static readonly EPOCH = 1420070400000;
+ static INCREMENT = 0n; // max 4095
+ static processId = BigInt(process.pid % 31); // max 31
+ static workerId = BigInt((cluster.worker?.id || 0) % 31); // max 31
+
+ constructor() {
+ throw new Error(`The ${this.constructor.name} class may not be instantiated.`);
+ }
+
+ /**
+ * A Twitter-like snowflake, except the epoch is 2015-01-01T00:00:00.000Z
+ * ```
+ * If we have a snowflake '266241948824764416' we can represent it as binary:
+ *
+ * 64 22 17 12 0
+ * 000000111011000111100001101001000101000000 00001 00000 000000000000
+ * number of ms since Discord epoch worker pid increment
+ * ```
+ * @typedef {string} Snowflake
+ */
+
+ /**
+ * Transforms a snowflake from a decimal string to a bit string.
+ * @param {Snowflake} num Snowflake to be transformed
+ * @returns {string}
+ * @private
+ */
+ static idToBinary(num) {
+ let bin = "";
+ let high = parseInt(num.slice(0, -10)) || 0;
+ let low = parseInt(num.slice(-10));
+ while (low > 0 || high > 0) {
+ bin = String(low & 1) + bin;
+ low = Math.floor(low / 2);
+ if (high > 0) {
+ low += 5000000000 * (high % 2);
+ high = Math.floor(high / 2);
+ }
+ }
+ return bin;
+ }
+
+ /**
+ * Transforms a snowflake from a bit string to a decimal string.
+ * @param {string} num Bit string to be transformed
+ * @returns {Snowflake}
+ * @private
+ */
+ static binaryToID(num) {
+ let dec = "";
+
+ while (num.length > 50) {
+ const high = parseInt(num.slice(0, -32), 2);
+ const low = parseInt((high % 10).toString(2) + num.slice(-32), 2);
+
+ dec = (low % 10).toString() + dec;
+ num =
+ Math.floor(high / 10).toString(2) +
+ Math.floor(low / 10)
+ .toString(2)
+ .padStart(32, "0");
+ }
+
+ num = parseInt(num, 2);
+ while (num > 0) {
+ dec = (num % 10).toString() + dec;
+ num = Math.floor(num / 10);
+ }
+
+ return dec;
+ }
+
+ static generateWorkerProcess() { // worker process - returns a number
+ var time = BigInt(Date.now() - Snowflake.EPOCH) << BigInt(22);
+ var worker = Snowflake.workerId << 17n;
+ var process = Snowflake.processId << 12n;
+ var increment = Snowflake.INCREMENT++;
+ return BigInt(time | worker | process | increment);
+ }
+
+ static generate() {
+ return Snowflake.generateWorkerProcess().toString();
+ }
+ /**
+ * A deconstructed snowflake.
+ * @typedef {Object} DeconstructedSnowflake
+ * @property {number} timestamp Timestamp the snowflake was created
+ * @property {Date} date Date the snowflake was created
+ * @property {number} workerID Worker ID in the snowflake
+ * @property {number} processID Process ID in the snowflake
+ * @property {number} increment Increment in the snowflake
+ * @property {string} binary Binary representation of the snowflake
+ */
+
+ /**
+ * Deconstructs a Discord snowflake.
+ * @param {Snowflake} snowflake Snowflake to deconstruct
+ * @returns {DeconstructedSnowflake} Deconstructed snowflake
+ */
+ static deconstruct(snowflake) {
+ const BINARY = Snowflake.idToBinary(snowflake).toString(2).padStart(64, "0");
+ const res = {
+ timestamp: parseInt(BINARY.substring(0, 42), 2) + Snowflake.EPOCH,
+ workerID: parseInt(BINARY.substring(42, 47), 2),
+ processID: parseInt(BINARY.substring(47, 52), 2),
+ increment: parseInt(BINARY.substring(52, 64), 2),
+ binary: BINARY,
+ };
+ Object.defineProperty(res, "date", {
+ get: function get() {
+ return new Date(this.timestamp);
+ },
+ enumerable: true,
+ });
+ return res;
+ }
+}
diff --git a/src/util/util/String.ts b/src/util/util/String.ts
new file mode 100644
index 00000000..55f11e8d
--- /dev/null
+++ b/src/util/util/String.ts
@@ -0,0 +1,7 @@
+import { SPECIAL_CHAR } from "./Regex";
+
+export function trimSpecial(str?: string): string {
+ // @ts-ignore
+ if (!str) return;
+ return str.replace(SPECIAL_CHAR, "").trim();
+}
diff --git a/src/util/util/Token.ts b/src/util/util/Token.ts
new file mode 100644
index 00000000..5ba3e1ec
--- /dev/null
+++ b/src/util/util/Token.ts
@@ -0,0 +1,51 @@
+import jwt, { VerifyOptions } from "jsonwebtoken";
+import { Config } from "./Config";
+import { User } from "../entities";
+
+export const JWTOptions: VerifyOptions = { algorithms: ["HS256"] };
+
+export function checkToken(token: string, jwtSecret: string): Promise<any> {
+ return new Promise((res, rej) => {
+ token = token.replace("Bot ", "");
+ /**
+ in fosscord, even with instances that have bot distinction; we won't enforce "Bot" prefix,
+ as we don't really have separate pathways for bots
+ **/
+
+ jwt.verify(token, jwtSecret, JWTOptions, async (err, decoded: any) => {
+ if (err || !decoded) return rej("Invalid Token");
+
+ const user = await User.findOne({
+ where: { id: decoded.id },
+ select: ["data", "bot", "disabled", "deleted", "rights"]
+ });
+ if (!user) return rej("Invalid Token");
+ // we need to round it to seconds as it saved as seconds in jwt iat and valid_tokens_since is stored in milliseconds
+ if (decoded.iat * 1000 < new Date(user.data.valid_tokens_since).setSeconds(0, 0))
+ return rej("Invalid Token");
+ if (user.disabled) return rej("User disabled");
+ if (user.deleted) return rej("User not found");
+
+ return res({ decoded, user });
+ });
+ });
+}
+
+export async function generateToken(id: string) {
+ const iat = Math.floor(Date.now() / 1000);
+ const algorithm = "HS256";
+
+ return new Promise((res, rej) => {
+ jwt.sign(
+ { id: id, iat },
+ Config.get().security.jwtSecret,
+ {
+ algorithm,
+ },
+ (err, token) => {
+ if (err) return rej(err);
+ return res(token);
+ }
+ );
+ });
+}
diff --git a/src/util/util/TraverseDirectory.ts b/src/util/util/TraverseDirectory.ts
new file mode 100644
index 00000000..3d0d6279
--- /dev/null
+++ b/src/util/util/TraverseDirectory.ts
@@ -0,0 +1,13 @@
+import { Server, traverseDirectory } from "lambert-server";
+
+//if we're using ts-node, use ts files instead of js
+const extension = Symbol.for("ts-node.register.instance") in process ? "ts" : "js"
+
+const DEFAULT_FILTER = new RegExp("^([^\.].*)(?<!\.d)\.(" + extension + ")$");
+
+export function registerRoutes(server: Server, root: string) {
+ return traverseDirectory(
+ { dirname: root, recursive: true, filter: DEFAULT_FILTER },
+ server.registerRoute.bind(server, root)
+ );
+}
diff --git a/src/util/util/cdn.ts b/src/util/util/cdn.ts
new file mode 100644
index 00000000..812a4e1d
--- /dev/null
+++ b/src/util/util/cdn.ts
@@ -0,0 +1,56 @@
+import FormData from "form-data";
+import { HTTPError } from "lambert-server";
+import fetch from "node-fetch";
+import { Attachment } from "../entities";
+import { Config } from "./Config";
+
+export async function uploadFile(path: string, file?: Express.Multer.File): Promise<Attachment> {
+ if (!file?.buffer) throw new HTTPError("Missing file in body");
+
+ const form = new FormData();
+ form.append("file", file.buffer, {
+ contentType: file.mimetype,
+ filename: file.originalname,
+ });
+
+ const response = await fetch(`${Config.get().cdn.endpointPrivate || "http://localhost:3003"}${path}`, {
+ headers: {
+ signature: Config.get().security.requestSignature,
+ ...form.getHeaders(),
+ },
+ method: "POST",
+ body: form,
+ });
+ const result = await response.json() as Attachment;
+
+ if (response.status !== 200) throw result;
+ return result;
+}
+
+export async function handleFile(path: string, body?: string): Promise<string | undefined> {
+ if (!body || !body.startsWith("data:")) return undefined;
+ try {
+ const mimetype = body.split(":")[1].split(";")[0];
+ const buffer = Buffer.from(body.split(",")[1], "base64");
+
+ // @ts-ignore
+ const { id } = await uploadFile(path, { buffer, mimetype, originalname: "banner" });
+ return id;
+ } catch (error) {
+ console.error(error);
+ throw new HTTPError("Invalid " + path);
+ }
+}
+
+export async function deleteFile(path: string) {
+ const response = await fetch(`${Config.get().cdn.endpointPrivate || "http://localhost:3003"}${path}`, {
+ headers: {
+ signature: Config.get().security.requestSignature,
+ },
+ method: "DELETE",
+ });
+ const result = await response.json();
+
+ if (response.status !== 200) throw result;
+ return result;
+}
diff --git a/src/util/util/index.ts b/src/util/util/index.ts
new file mode 100644
index 00000000..b2bd6489
--- /dev/null
+++ b/src/util/util/index.ts
@@ -0,0 +1,23 @@
+export * from "./ApiError";
+export * from "./BitField";
+export * from "./Token";
+//export * from "./Categories";
+export * from "./cdn";
+export * from "./Config";
+export * from "./Constants";
+export * from "./Database";
+export * from "./Email";
+export * from "./Event";
+export * from "./FieldError";
+export * from "./Intents";
+export * from "./MessageFlags";
+export * from "./Permissions";
+export * from "./RabbitMQ";
+export * from "./Regex";
+export * from "./Rights";
+export * from "./Snowflake";
+export * from "./String";
+export * from "./Array";
+export * from "./TraverseDirectory";
+export * from "./InvisibleCharacters";
+export * from "./BannedWords";
\ No newline at end of file
|