Newer
Older
percord / src / util / entities / Guild.ts
/*
	Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
	Copyright (C) 2023 Spacebar and Spacebar Contributors

	This program is free software: you can redistribute it and/or modify
	it under the terms of the GNU Affero General Public License as published
	by the Free Software Foundation, either version 3 of the License, or
	(at your option) any later version.

	This program is distributed in the hope that it will be useful,
	but WITHOUT ANY WARRANTY; without even the implied warranty of
	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
	GNU Affero General Public License for more details.

	You should have received a copy of the GNU Affero General Public License
	along with this program.  If not, see <https://www.gnu.org/licenses/>.
*/

import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
import { Config, GuildWelcomeScreen, Snowflake, handleFile } 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";
import { arrayRemove } from "@spacebar/util";
// TODO: application_command_count, application_command_counts: {1: 0, 2: 0, 3: 0}
// TODO: guild_scheduled_events
// TODO: stage_instances
// TODO: threads
// TODO:
// "keywords": [
// 		"Genshin Impact",
// 		"Paimon",
// 		"Honkai Impact",
// 		"ARPG",
// 		"Open-World",
// 		"Waifu",
// 		"Anime",
// 		"Genshin",
// 		"miHoYo",
// 		"Gacha"
// 	],

export const PublicGuildRelations = [
    "channels",
    "emojis",
    "roles",
    "stickers",
    "voice_states",
    // "members",		// TODO: These are public, but all members should not be fetched.
    // "members.user",
];

@Entity({
    name: "guilds",
})
export class Guild extends BaseClass {
    @Column({ type: String, nullable: true })
    @RelationId((guild: Guild) => guild.afk_channel)
    afk_channel_id?: string | null;

    @JoinColumn({ name: "afk_channel_id" })
    @ManyToOne(() => Channel)
    afk_channel?: Channel;

    @Column({ nullable: true })
    afk_timeout?: number;

    // * commented out -> use owner instead
    // application id of the guild creator if it is bot-created
    // @Column({ nullable: true })
    // application?: string;

    @JoinColumn({ name: "ban_ids" })
    @OneToMany(() => Ban, (ban: Ban) => ban.guild, {
        cascade: true,
        orphanedRowAction: "delete",
    })
    bans: Ban[];

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

    @Column({ nullable: true })
    default_message_notifications?: number;

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

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

    @Column({ nullable: true })
    explicit_content_filter?: number;

    @Column({ type: "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()
    large?: boolean = false;

    @Column({ nullable: true })
    max_members?: number;

    @Column({ nullable: true })
    max_presences?: number;

    @Column({ nullable: true })
    max_video_channel_users?: number;

    @Column({ nullable: true })
    member_count?: number;

    @Column({ nullable: true })
    presence_count?: number; // users online

    @OneToMany(() => Member, (member: Member) => member.guild, {
        cascade: true,
        orphanedRowAction: "delete",
        onDelete: "CASCADE",
    })
    members: Member[];

    @JoinColumn({ name: "role_ids" })
    @OneToMany(() => Role, (role: Role) => role.guild, {
        cascade: true,
        orphanedRowAction: "delete",
        onDelete: "CASCADE",
    })
    roles: Role[];

    @JoinColumn({ name: "channel_ids" })
    @OneToMany(() => Channel, (channel: Channel) => channel.guild, {
        cascade: true,
        orphanedRowAction: "delete",
    })
    channels: Channel[];

    @Column({ nullable: true })
    @RelationId((guild: Guild) => guild.template)
    template_id?: string;

    @JoinColumn({ name: "template_id", referencedColumnName: "id" })
    @ManyToOne(() => Template)
    template: Template;

    @JoinColumn({ name: "emoji_ids" })
    @OneToMany(() => Emoji, (emoji: Emoji) => emoji.guild, {
        cascade: true,
        orphanedRowAction: "delete",
        onDelete: "CASCADE",
    })
    emojis: Emoji[];

    @JoinColumn({ name: "sticker_ids" })
    @OneToMany(() => Sticker, (sticker: Sticker) => sticker.guild, {
        cascade: true,
        orphanedRowAction: "delete",
        onDelete: "CASCADE",
    })
    stickers: Sticker[];

    @JoinColumn({ name: "invite_ids" })
    @OneToMany(() => Invite, (invite: Invite) => invite.guild, {
        cascade: true,
        orphanedRowAction: "delete",
        onDelete: "CASCADE",
    })
    invites: Invite[];

    @JoinColumn({ name: "voice_state_ids" })
    @OneToMany(() => VoiceState, (voicestate: VoiceState) => voicestate.guild, {
        cascade: true,
        orphanedRowAction: "delete",
        onDelete: "CASCADE",
    })
    voice_states: VoiceState[];

    @JoinColumn({ name: "webhook_ids" })
    @OneToMany(() => Webhook, (webhook: Webhook) => webhook.guild, {
        cascade: true,
        orphanedRowAction: "delete",
        onDelete: "CASCADE",
    })
    webhooks: Webhook[];

    @Column({ nullable: true })
    mfa_level?: number;

    @Column()
    name: string;

    @Column({ nullable: true })
    @RelationId((guild: Guild) => guild.owner)
    owner_id?: string; // optional to allow for ownerless guilds

    @JoinColumn({ name: "owner_id", referencedColumnName: "id" })
    @ManyToOne(() => User)
    owner?: User; // optional to allow for ownerless guilds

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

    @Column({ nullable: true })
    premium_subscription_count?: number;

    @Column()
    premium_tier?: number; // crowd premium level

    @Column({ type: String, nullable: true })
    @RelationId((guild: Guild) => guild.public_updates_channel)
    public_updates_channel_id: string | null;

    @JoinColumn({ name: "public_updates_channel_id" })
    @ManyToOne(() => Channel)
    public_updates_channel?: Channel;

    @Column({ type: String, nullable: true })
    @RelationId((guild: Guild) => guild.rules_channel)
    rules_channel_id?: string | null;

    @JoinColumn({ name: "rules_channel_id" })
    @ManyToOne(() => Channel)
    rules_channel?: string;

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

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

    @Column({ type: String, nullable: true })
    @RelationId((guild: Guild) => guild.system_channel)
    system_channel_id?: string | null;

    @JoinColumn({ name: "system_channel_id" })
    @ManyToOne(() => Channel)
    system_channel?: Channel;

    @Column({ nullable: true })
    system_channel_flags?: number;

    @Column()
    unavailable: boolean = false;

    @Column({ nullable: true })
    verification_level?: number;

    /**
     * DEPRECATED: Look at the new Guild onboarding screens.
     */
    @Column({ type: "simple-json" })
    welcome_screen: GuildWelcomeScreen;

    @Column({ nullable: true })
    @RelationId((guild: Guild) => guild.widget_channel)
    widget_channel_id?: string;

    @JoinColumn({ name: "widget_channel_id" })
    @ManyToOne(() => Channel)
    widget_channel?: Channel;

    @Column()
    widget_enabled: boolean = true;

    @Column({ nullable: true })
    nsfw_level?: number;

    @Column()
    nsfw: boolean = false;

    // TODO: nested guilds
    @Column({ nullable: true })
    parent?: string;

    // only for developer portal
    permissions?: number;

    //new guild settings, 11/08/2022:
    @Column({ nullable: true })
    premium_progress_bar_enabled: boolean = false;

    @Column({ select: false, type: "simple-array" })
    channel_ordering: string[];

    static async createGuild(body: {
        name?: string;
        icon?: string | null;
        owner_id?: string;
        roles?: Partial<Role>[];
        channels?: Partial<Channel>[];
        template_guild_id: string | null;
    }) {
        const guild_id = Snowflake.generate();

        const guild = await Guild.create({
            id: guild_id,
            name: body.name || "Spacebar",
            icon: await handleFile(`/icons/${guild_id}`, body.icon as string),
            owner_id: body.owner_id, // TODO: need to figure out a way for ownerless guilds and multiply-owned guilds
            presence_count: 0,
            member_count: 0, // will automatically be increased by addMember()
            mfa_level: 0,
            preferred_locale: "en-US",
            premium_subscription_count: 0,
            premium_tier: 0,
            system_channel_flags: 4, // defaults effect: suppress the setup tips to save performance
            nsfw_level: 0,
            verification_level: 0,
            welcome_screen: {
                enabled: false,
                description: "",
                welcome_channels: [],
            },
            channel_ordering: [],
            afk_timeout: Config.get().defaults.guild.afkTimeout,
            default_message_notifications: Config.get().defaults.guild.defaultMessageNotifications,
            explicit_content_filter: Config.get().defaults.guild.explicitContentFilter,
            features: Config.get().guild.defaultFeatures,
            max_members: Config.get().limits.guild.maxMembers,
            max_presences: Config.get().defaults.guild.maxPresences,
            max_video_channel_users: Config.get().defaults.guild.maxVideoChannelUsers,
            region: Config.get().regions.default,
        }).save();

        // we have to create the role _after_ the guild because else we would get a "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,
            colors: { primary_color: 0 },
            hoist: false,
            managed: false,
            mentionable: false,
            name: "@everyone",
            permissions: "2251804225",
            position: 0,
            icon: undefined,
            unicode_emoji: undefined,
            flags: 0, // TODO?
        }).save();

        // create custom roles if provided
        if (body.roles && body.roles.length) {
            await Promise.all(
                body.roles?.map((role) => {
                    new Promise((resolve) => {
                        Role.create({
                            ...role,
                            guild_id,
                            id:
                                // role.id === body.template_guild_id indicates that this is the @everyone role
                                role.id === body.template_guild_id ? guild_id : Snowflake.generate(),
                        })
                            .save()
                            .then(resolve);
                    });
                }),
            );
        }

        if (!body.channels || !body.channels.length) {
            body.channels = [{ id: "01", type: 0, name: "general", nsfw: false }];
        }

        const ids = new Map();

        body.channels.forEach((x) => {
            if (x.id) {
                ids.set(x.id, Snowflake.generate());
            }
        });

        for (const channel of body.channels.sort((a) => (a.parent_id ? 1 : -1))) {
            const id = ids.get(channel.id) || Snowflake.generate();

            const parent_id = ids.get(channel.parent_id);

            const saved = await Channel.createChannel({ ...channel, guild_id, id, parent_id }, body.owner_id, {
                keepId: true,
                skipExistsCheck: true,
                skipPermissionCheck: true,
                skipEventEmit: true,
            });

            await Guild.insertChannelInOrder(guild.id, saved.id, parent_id ?? channel.position ?? 0, guild);
        }

        return guild;
    }

    /** Insert a channel into the guild ordering by parent channel id or position */
    static async insertChannelInOrder(guild_id: string, channel_id: string, position: number, guild?: Guild): Promise<number>;
    static async insertChannelInOrder(guild_id: string, channel_id: string, parent_id: string, guild?: Guild): Promise<number>;
    static async insertChannelInOrder(guild_id: string, channel_id: string, insertPoint: string | number, guild?: Guild): Promise<number>;
    static async insertChannelInOrder(guild_id: string, channel_id: string, insertPoint: string | number, guild?: Guild): Promise<number> {
        if (!guild)
            guild = await Guild.findOneOrFail({
                where: { id: guild_id },
                select: { channel_ordering: true },
            });

        let position;
        if (typeof insertPoint == "string") position = guild.channel_ordering.indexOf(insertPoint) + 1;
        else position = insertPoint;

        arrayRemove(guild.channel_ordering, channel_id);

        guild.channel_ordering.splice(position, 0, channel_id);
        await Guild.update({ id: guild_id }, { channel_ordering: guild.channel_ordering });
        return position;
    }

    toJSON(): Guild {
        return {
            ...this,
            unavailable: this.unavailable == false ? undefined : true,
            channel_ordering: undefined,
        };
    }
}