diff --git a/src/database/entities/Channel.ts b/src/database/entities/Channel.ts
new file mode 100644
index 00000000..17a2a628
--- /dev/null
+++ b/src/database/entities/Channel.ts
@@ -0,0 +1,736 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+
+import { HTTPError } from "lambert-server/HTTPError";
+import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
+import { DmChannelDTO } from "../../util/dtos";
+import { ChannelCreateEvent, ChannelRecipientRemoveEvent, ThreadCreateEvent, ThreadMembersUpdateEvent } from "../../util/interfaces";
+import { InvisibleCharacters, Snowflake, emitEvent, getPermission, trimSpecial, Permissions, Config, DiscordApiErrors } from "../../util/util";
+import { BaseClass } from "./BaseClass";
+import { Guild } from "./Guild";
+import { Invite } from "./Invite";
+import { Message } from "./Message";
+import { Tag } from "./Tag";
+import { ReadState } from "./ReadState";
+import { Recipient } from "./Recipient";
+import { User } from "./User";
+import { VoiceState } from "./VoiceState";
+import { Webhook } from "./Webhook";
+import { Member } from "./Member";
+import { ChannelPermissionOverwrite, ChannelType, PublicChannel, PublicUserProjection, ThreadMetadata } from "@spacebar/schemas";
+import { OrmUtils } from "../../util/imports";
+import { ThreadMember } from "./ThreadMember";
+
+@Entity({
+ name: "channels",
+})
+export class Channel extends BaseClass {
+ @Column()
+ created_at: Date;
+
+ @Column({ nullable: true })
+ name?: string;
+
+ @Column({ type: "text", nullable: true })
+ icon?: string | null;
+
+ @Column({ type: "int" })
+ type: ChannelType;
+
+ @OneToMany(() => Recipient, (recipient: Recipient) => recipient.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ recipients?: Recipient[];
+
+ @OneToMany(() => ThreadMember, (member: ThreadMember) => member.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ thread_members?: ThreadMember[];
+
+ @Column({ nullable: true })
+ last_message_id?: string;
+
+ @Column({ nullable: true })
+ @RelationId((channel: Channel) => channel.guild)
+ guild_id?: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, (guild) => guild.channels, {
+ onDelete: "CASCADE",
+ nullable: true,
+ })
+ guild?: Guild;
+
+ @Column({ nullable: true })
+ @RelationId((channel: Channel) => channel.parent)
+ parent_id: string | null;
+
+ @JoinColumn({ name: "parent_id" })
+ @ManyToOne(() => Channel)
+ parent?: Channel;
+
+ // for group DMs and owned custom channel types
+ @Column({ nullable: true })
+ @RelationId((channel: Channel) => channel.owner)
+ owner_id?: string;
+
+ @JoinColumn({ name: "owner_id" })
+ @ManyToOne(() => User)
+ owner: User;
+
+ @Column({ nullable: true, type: "timestamp with time zone" })
+ last_pin_timestamp?: Date | null; // ISO8601
+
+ @Column({ nullable: true })
+ default_auto_archive_duration?: number;
+
+ @Column({ type: "jsonb", nullable: true })
+ permission_overwrites?: ChannelPermissionOverwrite[];
+
+ @Column({ nullable: true })
+ video_quality_mode?: number;
+
+ @Column({ nullable: true })
+ bitrate?: number;
+
+ @Column({ nullable: true })
+ user_limit?: number;
+
+ @Column()
+ nsfw: boolean = false;
+
+ @Column({ nullable: true })
+ rate_limit_per_user?: number;
+
+ @Column({ nullable: true })
+ topic?: string;
+
+ @OneToMany(() => Invite, (invite: Invite) => invite.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ invites?: Invite[];
+
+ @Column({ nullable: true })
+ retention_policy_id?: string;
+
+ @OneToMany(() => Message, (message: Message) => message.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ messages?: Message[];
+
+ @OneToMany(() => VoiceState, (voice_state: VoiceState) => voice_state.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ voice_states?: VoiceState[];
+
+ @OneToMany(() => ReadState, (read_state: ReadState) => read_state.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ read_states?: ReadState[];
+
+ @OneToMany(() => Webhook, (webhook: Webhook) => webhook.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ webhooks?: Webhook[];
+
+ @Column()
+ flags: number = 0;
+
+ @Column({ nullable: true })
+ default_thread_rate_limit_per_user?: number = 0;
+
+ @Column({ type: "jsonb", nullable: true })
+ thread_metadata?: ThreadMetadata;
+
+ @Column({ nullable: true })
+ member_count?: number;
+
+ @Column({ nullable: true })
+ message_count?: number;
+
+ @Column({ nullable: true })
+ total_message_sent?: number;
+
+ @JoinColumn({ name: "available_tags_ids" })
+ @OneToMany(() => Tag, (tag: Tag) => tag.channel, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ available_tags?: Tag[];
+
+ @Column("text", { array: true, nullable: true })
+ applied_tags?: string[];
+
+ @Column("text", { nullable: true })
+ status?: string | null;
+
+ /** Must be calculated Channel.calculatePosition */
+ position: number;
+
+ // TODO: DM channel
+ static async createChannel(
+ channel: Partial<Channel>,
+ user_id: string = "0",
+ opts?: {
+ keepId?: boolean;
+ skipExistsCheck?: boolean;
+ skipPermissionCheck?: boolean;
+ skipEventEmit?: boolean;
+ skipNameChecks?: boolean;
+ },
+ ): Promise<Channel> {
+ if (!opts?.skipPermissionCheck) {
+ // Always check if user has permission first
+ const permissions = await getPermission(user_id, channel.guild_id);
+ permissions.hasThrow("MANAGE_CHANNELS");
+ }
+
+ const guild = await Guild.findOneOrFail({
+ where: { id: channel.guild_id },
+ select: {
+ features: !opts?.skipNameChecks,
+ channel_ordering: true,
+ id: true,
+ },
+ });
+
+ if (!opts?.skipNameChecks) {
+ if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) {
+ for (const character of InvisibleCharacters) if (channel.name.includes(character)) throw new HTTPError("Channel name cannot include invalid characters", 403);
+
+ // Categories skip these checks on discord.com
+ if (
+ (channel.type !== ChannelType.GUILD_CATEGORY && channel.type !== ChannelType.GUILD_STAGE_VOICE && channel.type !== ChannelType.GUILD_VOICE) ||
+ guild.features.includes("IRC_LIKE_CATEGORY_NAMES")
+ ) {
+ if (channel.name.includes(" ")) throw new HTTPError("Channel name cannot include invalid characters", 403);
+
+ if (channel.name.match(/--+/g)) throw new HTTPError("Channel name cannot include multiple adjacent dashes.", 403);
+
+ if (channel.name.charAt(0) === "-" || channel.name.charAt(channel.name.length - 1) === "-")
+ throw new HTTPError("Channel name cannot start/end with dash.", 403);
+ } else channel.name = channel.name.trim(); //category names are trimmed client side on discord.com
+ }
+
+ if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) {
+ if (!channel.name) throw new HTTPError("Channel name cannot be empty.", 403);
+ }
+ }
+
+ switch (channel.type) {
+ // TODO: should threads even be routed through this function instead of createThreadChannel?
+ case ChannelType.GUILD_PUBLIC_THREAD:
+ case ChannelType.GUILD_PRIVATE_THREAD:
+ case ChannelType.GUILD_NEWS_THREAD:
+ case ChannelType.GUILD_TEXT:
+ case ChannelType.GUILD_FORUM:
+ case ChannelType.GUILD_MEDIA:
+ case ChannelType.GUILD_NEWS:
+ case ChannelType.GUILD_VOICE:
+ if (channel.parent_id && !opts?.skipExistsCheck) {
+ const exists = await Channel.findOneOrFail({
+ where: { id: channel.parent_id },
+ });
+ if (!exists) throw new HTTPError("Parent id channel doesn't exist", 400);
+ if (exists.guild_id !== channel.guild_id) throw new HTTPError("The category channel needs to be in the guild");
+ }
+ break;
+ case ChannelType.GUILD_CATEGORY:
+ case ChannelType.UNHANDLED:
+ break;
+ case ChannelType.DM:
+ case ChannelType.GROUP_DM:
+ throw new HTTPError("You can't create a dm channel in a guild");
+ case ChannelType.GUILD_STORE:
+ default:
+ throw new HTTPError("Not yet supported");
+ }
+
+ if (!channel.permission_overwrites) channel.permission_overwrites = [];
+ // TODO: eagerly auto generate position of all guild channels
+
+ const position = (channel.type === ChannelType.UNHANDLED ? 0 : channel.position) || 0;
+
+ channel = {
+ ...channel,
+ ...(!opts?.keepId && { id: Snowflake.generate() }),
+ created_at: new Date(),
+ position,
+ // from #876 (threads): shouldnt these be undefined?
+ // message_count: 0,
+ // member_count: 0,
+ // total_message_sent: 0,
+ };
+
+ // TODO: figure out why the generic is required here
+ const ret = Channel.create<Channel>(channel);
+
+ await Promise.all([
+ ret.save(),
+ !opts?.skipEventEmit
+ ? emitEvent({
+ event: "CHANNEL_CREATE",
+ data: ret.toJSON(),
+ guild_id: channel.guild_id,
+ } satisfies ChannelCreateEvent)
+ : Promise.resolve(),
+ Guild.insertChannelInOrder(guild.id, ret.id, position, guild),
+ ]);
+
+ return ret;
+ }
+ threadOnly() {
+ return this.type === ChannelType.GUILD_FORUM || this.type === ChannelType.GUILD_MEDIA;
+ }
+
+ static async createThreadChannel(
+ channel: Partial<Channel>,
+ metadata: Partial<ThreadMetadata>,
+ user_id: string = "0",
+ opts?: {
+ keepId?: boolean;
+ skipExistsCheck?: boolean;
+ skipParentExistsCheck?: boolean;
+ skipPermissionCheck?: boolean;
+ skipEventEmit?: boolean;
+ skipNameChecks?: boolean;
+ },
+ ): Promise<Channel> {
+ channel = {
+ // set the default type to private
+ type: ChannelType.GUILD_PRIVATE_THREAD,
+ ...channel,
+ ...(!opts?.keepId && { id: Snowflake.generate() }),
+ created_at: new Date(),
+ position: 0, // TODO:
+ message_count: 0,
+ member_count: 1,
+ total_message_sent: 0,
+ };
+
+ const exists = await Channel.findOne({
+ where: {
+ id: channel.id,
+ },
+ });
+
+ const guild = await Guild.findOneOrFail({ where: { id: channel.guild_id } });
+
+ if (!opts?.skipExistsCheck && !guild.features.includes("ALLOW_EXISTING_THREAD_FOR_MESSAGE") && exists) throw DiscordApiErrors.THREAD_ALREADY_CREATED_FOR_THIS_MESSAGE;
+
+ if (!channel.parent_id) throw new HTTPError("Parent id not set", 400);
+ const parent = await Channel.findOneOrFail({ where: { id: channel.parent_id } });
+
+ if (!opts?.skipPermissionCheck) {
+ // Always check if user has permission first
+ const permissions = await getPermission(user_id, parent.guild_id);
+ permissions.hasThrow(channel.type === ChannelType.GUILD_PRIVATE_THREAD ? "CREATE_PRIVATE_THREADS" : "CREATE_PUBLIC_THREADS");
+ }
+
+ channel = {
+ ...channel,
+ permission_overwrites: parent.permission_overwrites,
+ nsfw: parent.nsfw,
+ owner_id: user_id,
+ guild_id: parent.guild_id,
+ thread_metadata: {
+ create_timestamp: new Date().toISOString(),
+ archive_timestamp: new Date().toISOString(),
+ archived: false,
+ auto_archive_duration: 0,
+ invitable: channel.type === ChannelType.GUILD_NEWS_THREAD || channel.type === ChannelType.GUILD_PUBLIC_THREAD ? Config.get().guild.publicThreadsInvitable : false,
+ locked: false,
+ ...metadata,
+ },
+ };
+
+ if (!opts?.skipParentExistsCheck) {
+ if (!parent) throw new HTTPError("Parent channel doesn't exist", 400);
+ if (parent.guild_id !== channel.guild_id) throw new HTTPError("The category channel needs to be in the guild");
+ }
+
+ if (!opts?.skipNameChecks) {
+ const guild = await Guild.findOneOrFail({ where: { id: channel.guild_id } });
+ if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) {
+ for (const character of InvisibleCharacters) if (channel.name.includes(character)) throw new HTTPError("Channel name cannot include invalid characters", 403);
+
+ channel.name = channel.name.trim(); //category names are trimmed client side on discord.com
+ }
+
+ if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) {
+ if (!channel.name) throw new HTTPError("Channel name cannot be empty.", 403);
+ }
+ }
+
+ // TODO: eagerly auto generate position of all guild channels
+
+ const thread = await OrmUtils.mergeDeep(new Channel(), channel).save();
+
+ const member = {
+ id: thread.id,
+ user_id,
+ join_timestamp: new Date(),
+ muted: false,
+ mute_config: null,
+ flags: 0,
+ };
+ if (channel.member_count) channel.member_count++;
+
+ const threadMember = await OrmUtils.mergeDeep(new ThreadMember(), member).save();
+
+ if (!opts?.skipEventEmit) {
+ await Promise.all([
+ emitEvent({
+ event: "THREAD_CREATE",
+ data: {
+ ...thread,
+ newly_created: true,
+ },
+ guild_id: channel.guild_id,
+ } satisfies ThreadCreateEvent),
+ emitEvent({
+ event: "THREAD_MEMBERS_UPDATE",
+ data: {
+ guild_id: channel.guild_id!, // TODO: is this the right fix?
+ id: thread.id,
+ member_count: channel.member_count ?? 0, //TODO: is this the right fix?
+ added_members: [threadMember],
+ removed_member_ids: [],
+ },
+ guild_id: channel.guild_id,
+ } satisfies ThreadMembersUpdateEvent),
+ ]);
+ }
+
+ return thread;
+ }
+
+ static async createDMChannel(recipients: string[], creator_user_id: string, name?: string) {
+ recipients = [...new Set(recipients)].filter((x) => x !== creator_user_id);
+ // TODO: check config for max number of recipients
+ /** if you want to disallow note to self channels, uncomment the conditional below
+
+ const otherRecipientsUsers = await User.find({ where: recipients.map((x) => ({ id: x })) });
+ if (otherRecipientsUsers.length !== recipients.length) {
+ throw new HTTPError("Recipient/s not found");
+ }
+ **/
+
+ const type = recipients.length > 1 ? ChannelType.GROUP_DM : ChannelType.DM;
+
+ let channel = null;
+ let needsTx = true;
+
+ const channelRecipients = [...recipients, creator_user_id];
+
+ const userRecipients = await Recipient.find({
+ where: { user_id: creator_user_id },
+ relations: { channel: { recipients: true } },
+ });
+
+ for (const ur of userRecipients) {
+ if (!ur.channel.recipients) continue;
+ const re = ur.channel.recipients.map((r) => r.user_id);
+ if (re.length === channelRecipients.length) {
+ if (channelRecipients.every((_) => re.includes(_))) {
+ if (channel == null) {
+ channel = ur.channel;
+ if (!ur.closed) needsTx = false;
+ await ur.assign({ closed: false }).save();
+ }
+ }
+ }
+ }
+
+ if (channel == null) {
+ name = trimSpecial(name);
+
+ channel = await Channel.create({
+ name,
+ type,
+ owner_id: type === ChannelType.GROUP_DM ? creator_user_id : undefined,
+ created_at: new Date(),
+ last_message_id: undefined,
+ recipients: channelRecipients.map((x) =>
+ Recipient.create({
+ user_id: x,
+ closed: !(type === ChannelType.GROUP_DM || x === creator_user_id),
+ }),
+ ),
+ nsfw: false,
+ }).save();
+ }
+
+ const channel_dto = await DmChannelDTO.from(channel);
+
+ if (!needsTx) {
+ /*ignored*/
+ } else if (type === ChannelType.GROUP_DM && channel.recipients) {
+ for (const recipient of channel.recipients) {
+ await emitEvent({
+ event: "CHANNEL_CREATE",
+ data: channel_dto.excludedRecipients([recipient.user_id]),
+ user_id: recipient.user_id,
+ });
+ }
+ } else {
+ await emitEvent({
+ event: "CHANNEL_CREATE",
+ data: channel_dto,
+ user_id: creator_user_id,
+ });
+ }
+
+ if (recipients.length === 1) return channel_dto;
+ else return channel_dto.excludedRecipients([creator_user_id]);
+ }
+
+ static async removeRecipientFromChannel(channel: Channel, user_id: string) {
+ await Recipient.delete({ channel_id: channel.id, user_id: user_id });
+ channel.recipients = channel.recipients?.filter((r) => r.user_id !== user_id);
+
+ if (channel.recipients?.length === 0) {
+ await Channel.deleteChannel(channel);
+ await emitEvent({
+ event: "CHANNEL_DELETE",
+ data: await DmChannelDTO.from(channel, [user_id]),
+ user_id: user_id,
+ });
+ return;
+ }
+
+ await emitEvent({
+ event: "CHANNEL_DELETE",
+ data: await DmChannelDTO.from(channel, [user_id]),
+ user_id: user_id,
+ });
+
+ //If the owner leave the server user is the new owner
+ if (channel.owner_id === user_id) {
+ channel.owner_id = "1"; // The channel is now owned by the server user
+ await emitEvent({
+ event: "CHANNEL_UPDATE",
+ data: await DmChannelDTO.from(channel, [user_id]),
+ channel_id: channel.id,
+ });
+ }
+
+ await channel.save();
+
+ await emitEvent({
+ event: "CHANNEL_RECIPIENT_REMOVE",
+ data: {
+ channel_id: channel.id,
+ user: await User.findOneOrFail({
+ where: { id: user_id },
+ select: PublicUserProjection,
+ }),
+ },
+ channel_id: channel.id,
+ } satisfies ChannelRecipientRemoveEvent);
+ }
+
+ static async deleteChannel(channel: Channel) {
+ // TODO Delete attachments from the CDN for messages in the channel
+ await Channel.delete({ id: channel.id });
+
+ if (channel.guild_id) {
+ const guild = await Guild.findOneOrFail({
+ where: { id: channel.guild_id },
+ select: { channel_ordering: true },
+ });
+
+ const updatedOrdering = guild.channel_ordering.filter((id) => id != channel.id);
+ await Guild.update({ id: channel.guild_id }, { channel_ordering: updatedOrdering });
+ }
+ }
+
+ static async calculatePosition(channel_id: string, guild_id: string, guild?: Guild) {
+ if (!guild)
+ guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: { channel_ordering: true },
+ });
+
+ return guild.channel_ordering.findIndex((id) => channel_id == id);
+ }
+
+ static async getOrderedChannels(guild_id: string, guild?: Guild) {
+ if (!guild)
+ guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: { channel_ordering: true },
+ });
+
+ const channels = await Promise.all(guild.channel_ordering.map((id) => Channel.findOne({ where: { id } })));
+
+ return channels
+ .filter((channel) => channel !== null)
+ .reduce((r, v) => {
+ v = v as Channel;
+
+ v.position = (guild as Guild).channel_ordering.indexOf(v.id);
+ r[v.position] = v;
+ return r;
+ }, [] as Array<Channel>);
+ }
+
+ isDm() {
+ return this.type === ChannelType.DM || this.type === ChannelType.GROUP_DM;
+ }
+
+ isThread() {
+ return this.type === ChannelType.GUILD_NEWS_THREAD || this.type === ChannelType.GUILD_PUBLIC_THREAD || this.type === ChannelType.GUILD_PRIVATE_THREAD;
+ }
+ isForum() {
+ return this.type === ChannelType.GUILD_FORUM || this.type === ChannelType.GUILD_MEDIA;
+ }
+
+ isPrivateThread() {
+ return this.type === ChannelType.GUILD_PRIVATE_THREAD;
+ }
+
+ isPublicThread() {
+ return this.type === ChannelType.GUILD_NEWS_THREAD || this.type === ChannelType.GUILD_PUBLIC_THREAD;
+ }
+
+ // Does the channel support sending messages ( eg categories do not )
+ isWritable() {
+ const disallowedChannelTypes = [ChannelType.GUILD_CATEGORY, ChannelType.GUILD_STAGE_VOICE];
+ return disallowedChannelTypes.indexOf(this.type) == -1;
+ }
+
+ async getUserPermissions(opts: { user_id?: string; user?: User; member?: Member; guild?: Guild }): Promise<Permissions> {
+ if (this.isDm()) return this.owner_id == (opts.user_id ?? opts.user?.id) ? Permissions.ALL : Permissions.DEFAULT_DM_PERMISSIONS;
+ let guild = opts.guild;
+ if (!guild) {
+ if (this.guild) guild = this.guild;
+ else if (this.guild_id) guild = await Guild.findOneOrFail({ where: { id: this.guild_id } });
+ else {
+ console.error("Channel.getUserPermissions: called without guild for non-DM channel.");
+ return Permissions.NONE;
+ }
+ }
+
+ // check if we can resolve here to short-circuit possibly calling the database unnecessarily
+ // TODO: do we want to have an instance-wide opt out of this behavior? It would just be an extra if statement here
+ const ownerId = guild?.owner?.id ?? guild?.owner_id;
+ if (!!opts.user_id && ownerId === opts.user_id) return Permissions.ALL;
+ if (!!opts.user?.id && ownerId === opts.user?.id) return Permissions.ALL;
+ if (!!opts.member?.id && ownerId === opts.member?.id) return Permissions.ALL;
+
+ let member = opts.member;
+ if (!member) {
+ if (opts.user) member = await Member.findOneOrFail({ where: { guild_id: guild.id, id: opts.user.id }, relations: { roles: true } });
+ else if (opts.user_id) member = await Member.findOneOrFail({ where: { guild_id: guild.id, id: opts.user_id }, relations: { roles: true } });
+ else {
+ console.error("Channel.getUserPermissions: called without user or member for non-DM channel.");
+ return Permissions.NONE;
+ }
+ }
+
+ const roles = (
+ member.roles ||
+ (
+ await Member.findOneOrFail({
+ where: { guild_id: guild.id, index: member.index },
+ relations: { roles: true },
+ select: {
+ roles: {
+ id: true,
+ permissions: true,
+ position: true,
+ },
+ },
+ loadEagerRelations: false,
+ })
+ ).roles
+ ).sort((a, b) => a.position - b.position); // ascending by position
+
+ return Permissions.finalPermission({
+ user: {
+ ...member,
+ roles: roles.map((r) => r.id),
+ flags: member.user?.flags ?? (await User.findOneOrFail({ where: { id: member.id }, select: { flags: true } })).flags,
+ },
+ guild: { id: guild.id, owner_id: guild.owner_id!, roles }, // We don't care about including *all* guild roles, as not all of them are relevant...
+ channel: this,
+ });
+ }
+
+ // TODO: should we throw for missing args?
+ async canViewChannel(opts: { user_id?: string; user?: User; member?: Member; guild?: Guild }): Promise<boolean> {
+ if (this.isDm()) return await this.canViewDmChannel(opts.user_id, opts.user);
+
+ const userPerms = await this.getUserPermissions(opts);
+ return userPerms.has("VIEW_CHANNEL");
+ }
+
+ private async canViewDmChannel(user_id?: string, user?: User): Promise<boolean> {
+ const userId = user_id ?? user?.id;
+ if (!userId) {
+ console.error("Channel.canViewChannel: called without user for DM channel.");
+ return false;
+ }
+ if (!user) return false;
+ if (this.recipients) return this.recipients.some((r) => r.user_id === user.id && !r.closed);
+ else {
+ // we dont have recipients on hand
+ const recipient = await Recipient.findOne({ where: { channel_id: this.id, user_id: user.id } });
+ return recipient == null ? false : !recipient.closed;
+ }
+ }
+
+ toJSON(): PublicChannel {
+ return {
+ ...this,
+ last_pin_timestamp: this.last_pin_timestamp?.toISOString(),
+ guild_id: this.guild_id ?? undefined,
+ recipients: undefined, //this.recipients?.map(x=>x.user.toPublicUser()), // TODO: fix me
+ owner: undefined, // TODO: fix me - this is thread owner
+
+ // these fields are not returned depending on the type of channel
+ bitrate: this.bitrate || undefined,
+ user_limit: this.user_limit || undefined,
+ rate_limit_per_user: this.rate_limit_per_user || undefined,
+ owner_id: this.owner_id || undefined,
+ ...(this.isThread() && this.thread_members ? { member_ids_preview: this.thread_members.map((_) => _.member.id) } : {}),
+ default_auto_archive_duration: this.default_auto_archive_duration ?? undefined,
+ retention_policy_id: undefined,
+ thread_metadata: this.thread_metadata
+ ? {
+ ...this.thread_metadata,
+ archive_timestamp: new Date(this.thread_metadata.archive_timestamp).toISOString().replace("Z", "+00:00"),
+ create_timestamp: new Date(this.thread_metadata.create_timestamp).toISOString().replace("Z", "+00:00"),
+ }
+ : undefined,
+ member_count: this.member_count ?? undefined,
+ message_count: this.message_count ?? undefined,
+ total_message_sent: this.total_message_sent ?? undefined,
+ applied_tags: this.applied_tags ?? undefined,
+ permission_overwrites: this.isThread() ? undefined : this.permission_overwrites,
+ };
+ }
+}
|