summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/util/entities/Channel.ts788
-rw-r--r--src/util/util/InvisibleCharacters.ts109
2 files changed, 460 insertions, 437 deletions
diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts
index 23fc6544..f97aa3ab 100644
--- a/src/util/entities/Channel.ts
+++ b/src/util/entities/Channel.ts
@@ -1,383 +1,405 @@
-import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
-import { DmChannelDTO } from "../dtos";
-import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces";
-import { containsAll, emitEvent, getPermission, InvisibleCharacters, Snowflake, trimSpecial } from "../util";
-import { HTTPError } from "../util/imports/HTTPError";
-import { OrmUtils } from "../util/imports/OrmUtils";
-import { BaseClass } from "./BaseClass";
-import { Guild } from "./Guild";
-import { Invite } from "./Invite";
-import { Message } from "./Message";
-import { ReadState } from "./ReadState";
-import { Recipient } from "./Recipient";
-import { PublicUserProjection, User } from "./User";
-import { VoiceState } from "./VoiceState";
-import { Webhook } from "./Webhook";
-
-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({ nullable: true })
-	nsfw?: boolean;
-
-	@Column({ nullable: true })
-	rate_limit_per_user?: number;
-
-	@Column({ nullable: true })
-	topic?: string;
-
-	@OneToMany(() => Invite, (invite: Invite) => invite.channel, {
-		cascade: true,
-		orphanedRowAction: "delete"
-	})
-	invites?: Invite[];
-
-	@Column({ nullable: true })
-	retention_policy_id?: string;
-
-	@OneToMany(() => Message, (message: Message) => message.channel, {
-		cascade: true,
-		orphanedRowAction: "delete"
-	})
-	messages?: Message[];
-
-	@OneToMany(() => VoiceState, (voice_state: VoiceState) => voice_state.channel, {
-		cascade: true,
-		orphanedRowAction: "delete"
-	})
-	voice_states?: VoiceState[];
-
-	@OneToMany(() => ReadState, (read_state: ReadState) => read_state.channel, {
-		cascade: true,
-		orphanedRowAction: "delete"
-	})
-	read_states?: ReadState[];
-
-	@OneToMany(() => Webhook, (webhook: Webhook) => webhook.channel, {
-		cascade: true,
-		orphanedRowAction: "delete"
-	})
-	webhooks?: Webhook[];
-
-	@Column({ nullable: true })
-	flags?: number = 0;
-
-	@Column({ nullable: true })
-	default_thread_rate_limit_per_user?: number = 0;
-
-	// 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 (let character of InvisibleCharacters)
-					if (channel.name.includes(character)) 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);
-			}
-
-			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([
-			OrmUtils.mergeDeep(new Channel(), 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);
-		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;
-						ur = OrmUtils.mergeDeep(ur, { closed: false });
-						await ur.save();
-					}
-				}
-			}
-		}
-
-		if (channel == null) {
-			name = trimSpecial(name);
-
-			channel = await (
-				OrmUtils.mergeDeep(new Channel(), {
-					name,
-					type,
-					owner_id: type === ChannelType.DM ? undefined : null, // 1:1 DMs are ownerless in fosscord-server
-					created_at: new Date(),
-					last_message_id: null,
-					recipients: channelRecipients.map((x) =>
-						OrmUtils.mergeDeep(new Recipient(), {
-							user_id: x,
-							closed: !(type === ChannelType.GROUP_DM || x === creator_user_id)
-						})
-					)
-				}) as Channel
-			).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
-}
+import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";

+import { DmChannelDTO } from "../dtos";

+import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces";

+import { containsAll, emitEvent, getPermission, InvisibleCharacters, Snowflake, trimSpecial } from "../util";

+import { HTTPError } from "../util/imports/HTTPError";

+import { OrmUtils } from "../util/imports/OrmUtils";

+import { BaseClass } from "./BaseClass";

+import { Guild } from "./Guild";

+import { Invite } from "./Invite";

+import { Message } from "./Message";

+import { ReadState } from "./ReadState";

+import { Recipient } from "./Recipient";

+import { PublicUserProjection, User } from "./User";

+import { VoiceState } from "./VoiceState";

+import { Webhook } from "./Webhook";

+

+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({ nullable: true })

+	nsfw?: boolean;

+

+	@Column({ nullable: true })

+	rate_limit_per_user?: number;

+

+	@Column({ nullable: true })

+	topic?: string;

+

+	@OneToMany(() => Invite, (invite: Invite) => invite.channel, {

+		cascade: true,

+		orphanedRowAction: "delete"

+	})

+	invites?: Invite[];

+

+	@Column({ nullable: true })

+	retention_policy_id?: string;

+

+	@OneToMany(() => Message, (message: Message) => message.channel, {

+		cascade: true,

+		orphanedRowAction: "delete"

+	})

+	messages?: Message[];

+

+	@OneToMany(() => VoiceState, (voice_state: VoiceState) => voice_state.channel, {

+		cascade: true,

+		orphanedRowAction: "delete"

+	})

+	voice_states?: VoiceState[];

+

+	@OneToMany(() => ReadState, (read_state: ReadState) => read_state.channel, {

+		cascade: true,

+		orphanedRowAction: "delete"

+	})

+	read_states?: ReadState[];

+

+	@OneToMany(() => Webhook, (webhook: Webhook) => webhook.channel, {

+		cascade: true,

+		orphanedRowAction: "delete"

+	})

+	webhooks?: Webhook[];

+

+	@Column({ nullable: true })

+	flags?: number = 0;

+

+	@Column({ nullable: true })

+	default_thread_rate_limit_per_user?: number = 0;

+

+	// 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 (let character of InvisibleCharacters)

+				if (channel.name.includes(character))

+					throw new HTTPError("Channel name cannot include invalid characters", 403);

+

+				// Categories and voice skip these checks on discord.com

+				const skipChecksTypes = [

+					ChannelType.GUILD_CATEGORY,

+					ChannelType.GUILD_VOICE,

+				]

+				if (channel.type

+					&& !skipChecksTypes.includes(channel.type)

+					|| guild.features.includes("IRC_LIKE_CHANNEL_NAMES")) {

+					if (channel.name.includes(" "))

+						throw new HTTPError("Channel name cannot include invalid characters", 403);

+

+					if (channel.name.match(/\-\-+/g))

+						throw new HTTPError("Channel name cannot include multiple adjacent dashes.", 403);

+

+					if (channel.name.charAt(0) === "-" ||

+						channel.name.charAt(channel.name.length - 1) === "-")

+						throw new HTTPError("Channel name cannot start/end with dash.", 403);

+				}

+				else

+					channel.name = channel.name.trim();	//category names are trimmed client side on discord.com

+			}

+

+			if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) {

+				if (!channel.name) throw new HTTPError("Channel name cannot be empty.", 403);

+			}

+		}

+

+		switch (channel.type) {

+			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([

+			OrmUtils.mergeDeep(new Channel(), 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);

+		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;

+						ur = OrmUtils.mergeDeep(ur, { closed: false });

+						await ur.save();

+					}

+				}

+			}

+		}

+

+		if (channel == null) {

+			name = trimSpecial(name);

+

+			channel = await (

+				OrmUtils.mergeDeep(new Channel(), {

+					name,

+					type,

+					owner_id: type === ChannelType.DM ? undefined : null, // 1:1 DMs are ownerless in fosscord-server

+					created_at: new Date(),

+					last_message_id: null,

+					recipients: channelRecipients.map((x) =>

+						OrmUtils.mergeDeep(new Recipient(), {

+							user_id: x,

+							closed: !(type === ChannelType.GROUP_DM || x === creator_user_id)

+						})

+					)

+				}) as Channel

+			).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

+}
\ No newline at end of file
diff --git a/src/util/util/InvisibleCharacters.ts b/src/util/util/InvisibleCharacters.ts
index 4c809e48..0aab3ec3 100644
--- a/src/util/util/InvisibleCharacters.ts
+++ b/src/util/util/InvisibleCharacters.ts
@@ -1,56 +1,57 @@
 // List from https://invisible-characters.com/
 export const InvisibleCharacters = [
-	"\u{9}", //Tab
-	"\u{20}", //Space
-	"\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
-];
+	'\u{9}',			//Tab
+	'\u{c}',			//Form feed
+	//'\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 -- WJ is required in some languages that don't use spaces to split words
+	'\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
+];