/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer 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. * * Fluxer 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 Fluxer. If not, see . */ import type {GuildSplashCardAlignmentValue} from '~/Constants'; import { GuildFeatures, GuildSplashCardAlignment, LARGE_GUILD_THRESHOLD, MAX_GUILD_EMOJIS_ANIMATED, MAX_GUILD_EMOJIS_ANIMATED_MORE_EMOJI, MAX_GUILD_EMOJIS_STATIC, MAX_GUILD_EMOJIS_STATIC_MORE_EMOJI, MAX_GUILD_STICKERS, MAX_GUILD_STICKERS_MORE_STICKERS, MessageNotifications, } from '~/Constants'; import type {Channel} from '~/records/ChannelRecord'; import type {GuildEmoji} from '~/records/GuildEmojiRecord'; import type {GuildMember} from '~/records/GuildMemberRecord'; import type {GuildRole} from '~/records/GuildRoleRecord'; import {GuildRoleRecord} from '~/records/GuildRoleRecord'; import type {GuildSticker} from '~/records/GuildStickerRecord'; import type {Presence} from '~/stores/PresenceStore'; import type {VoiceState} from '~/stores/voice/MediaEngineFacade'; import * as SnowflakeUtils from '~/utils/SnowflakeUtils'; export type Guild = Readonly<{ id: string; name: string; icon: string | null; banner?: string | null; banner_width?: number | null; banner_height?: number | null; splash?: string | null; splash_width?: number | null; splash_height?: number | null; splash_card_alignment?: GuildSplashCardAlignmentValue; embed_splash?: string | null; embed_splash_width?: number | null; embed_splash_height?: number | null; vanity_url_code: string | null; owner_id: string; system_channel_id: string | null; system_channel_flags?: number; rules_channel_id?: string | null; afk_channel_id?: string | null; afk_timeout?: number; features: ReadonlyArray; verification_level?: number; mfa_level?: number; nsfw_level?: number; explicit_content_filter?: number; default_message_notifications?: number; disabled_operations?: number; joined_at?: string; unavailable?: boolean; member_count?: number; }>; export type GuildReadyData = Readonly<{ id: string; properties: Omit; channels: ReadonlyArray; emojis: ReadonlyArray; stickers?: ReadonlyArray; members: ReadonlyArray; member_count: number; presences?: ReadonlyArray; voice_states?: ReadonlyArray; roles: ReadonlyArray; joined_at: string; unavailable?: boolean; }>; type GuildInput = Guild | GuildRecord; export class GuildRecord { readonly id: string; readonly name: string; readonly icon: string | null; readonly banner: string | null; readonly bannerWidth: number | null; readonly bannerHeight: number | null; readonly splash: string | null; readonly splashWidth: number | null; readonly splashHeight: number | null; readonly splashCardAlignment: GuildSplashCardAlignmentValue; readonly embedSplash: string | null; readonly embedSplashWidth: number | null; readonly embedSplashHeight: number | null; readonly features: ReadonlySet; readonly vanityURLCode: string | null; readonly ownerId: string; readonly systemChannelId: string | null; readonly systemChannelFlags: number; readonly rulesChannelId: string | null; readonly afkChannelId: string | null; readonly afkTimeout: number; readonly roles: Readonly>; readonly verificationLevel: number; readonly mfaLevel: number; readonly nsfwLevel: number; readonly explicitContentFilter: number; readonly defaultMessageNotifications: number; private readonly _disabledOperations: number; readonly joinedAt: string | null; readonly unavailable: boolean; readonly memberCount: number; constructor(guild: GuildInput) { this.id = guild.id; this.name = guild.name; this.icon = guild.icon; this.banner = this.normalizeBanner(guild); this.bannerWidth = this.normalizeBannerWidth(guild); this.bannerHeight = this.normalizeBannerHeight(guild); this.splash = this.normalizeSplash(guild); this.splashWidth = this.normalizeSplashWidth(guild); this.splashHeight = this.normalizeSplashHeight(guild); this.splashCardAlignment = this.normalizeSplashCardAlignment(guild); this.embedSplash = this.normalizeEmbedSplash(guild); this.embedSplashWidth = this.normalizeEmbedSplashWidth(guild); this.embedSplashHeight = this.normalizeEmbedSplashHeight(guild); this.features = new Set(guild.features); this.vanityURLCode = this.normalizeVanityUrlCode(guild); this.ownerId = this.normalizeOwnerId(guild); this.systemChannelId = this.normalizeSystemChannelId(guild); this.systemChannelFlags = this.normalizeSystemChannelFlags(guild); this.rulesChannelId = this.normalizeRulesChannelId(guild); this.afkChannelId = this.normalizeAfkChannelId(guild); this.afkTimeout = this.normalizeAfkTimeout(guild); this.roles = this.normalizeRoles(guild); this.verificationLevel = this.normalizeVerificationLevel(guild); this.mfaLevel = this.normalizeMfaLevel(guild); this.nsfwLevel = this.normalizeNsfwLevel(guild); this.explicitContentFilter = this.normalizeExplicitContentFilter(guild); this.defaultMessageNotifications = this.normalizeDefaultMessageNotifications(guild); this._disabledOperations = this.normalizeDisabledOperations(guild); this.joinedAt = this.normalizeJoinedAt(guild); this.unavailable = guild.unavailable ?? false; this.memberCount = this.normalizeMemberCount(guild); } private normalizeField(guild: GuildInput, snakeCase: keyof Guild, camelCase: keyof GuildRecord): T { const value = this.isGuildInput(guild) ? guild[snakeCase] : guild[camelCase]; return (value === undefined ? null : value) as T; } private normalizeFieldWithDefault( guild: GuildInput, snakeCase: keyof Guild, camelCase: keyof GuildRecord, defaultValue: T, ): T { return this.isGuildInput(guild) ? ((guild[snakeCase] ?? defaultValue) as T) : (guild[camelCase] as T); } private normalizeBanner(guild: GuildInput): string | null { return this.normalizeField(guild, 'banner', 'banner'); } private normalizeBannerWidth(guild: GuildInput): number | null { return this.normalizeField(guild, 'banner_width', 'bannerWidth'); } private normalizeBannerHeight(guild: GuildInput): number | null { return this.normalizeField(guild, 'banner_height', 'bannerHeight'); } private normalizeSplash(guild: GuildInput): string | null { return this.normalizeField(guild, 'splash', 'splash'); } private normalizeSplashWidth(guild: GuildInput): number | null { return this.normalizeField(guild, 'splash_width', 'splashWidth'); } private normalizeSplashHeight(guild: GuildInput): number | null { return this.normalizeField(guild, 'splash_height', 'splashHeight'); } private normalizeSplashCardAlignment(guild: GuildInput): GuildSplashCardAlignmentValue { if (this.isGuildInput(guild)) { return guild.splash_card_alignment ?? GuildSplashCardAlignment.CENTER; } return guild.splashCardAlignment ?? GuildSplashCardAlignment.CENTER; } private normalizeEmbedSplash(guild: GuildInput): string | null { return this.normalizeField(guild, 'embed_splash', 'embedSplash'); } private normalizeEmbedSplashWidth(guild: GuildInput): number | null { return this.normalizeField(guild, 'embed_splash_width', 'embedSplashWidth'); } private normalizeEmbedSplashHeight(guild: GuildInput): number | null { return this.normalizeField(guild, 'embed_splash_height', 'embedSplashHeight'); } private normalizeVanityUrlCode(guild: GuildInput): string | null { return this.normalizeField(guild, 'vanity_url_code', 'vanityURLCode'); } private normalizeOwnerId(guild: GuildInput): string { return this.normalizeField(guild, 'owner_id', 'ownerId'); } private normalizeSystemChannelId(guild: GuildInput): string | null { return this.normalizeField(guild, 'system_channel_id', 'systemChannelId'); } private normalizeSystemChannelFlags(guild: GuildInput): number { return this.normalizeFieldWithDefault(guild, 'system_channel_flags', 'systemChannelFlags', 0); } private normalizeRulesChannelId(guild: GuildInput): string | null { return this.normalizeField(guild, 'rules_channel_id', 'rulesChannelId'); } private normalizeAfkChannelId(guild: GuildInput): string | null { return this.normalizeField(guild, 'afk_channel_id', 'afkChannelId'); } private normalizeAfkTimeout(guild: GuildInput): number { return this.normalizeFieldWithDefault(guild, 'afk_timeout', 'afkTimeout', 0); } private normalizeRoles(guild: GuildInput): Readonly> { return Object.freeze('roles' in guild ? {...guild.roles} : {}); } private normalizeVerificationLevel(guild: GuildInput): number { return this.normalizeFieldWithDefault(guild, 'verification_level', 'verificationLevel', 0); } private normalizeMfaLevel(guild: GuildInput): number { return this.normalizeFieldWithDefault(guild, 'mfa_level', 'mfaLevel', 0); } private normalizeNsfwLevel(guild: GuildInput): number { return this.normalizeFieldWithDefault(guild, 'nsfw_level', 'nsfwLevel', 0); } private normalizeExplicitContentFilter(guild: GuildInput): number { return this.normalizeFieldWithDefault(guild, 'explicit_content_filter', 'explicitContentFilter', 0); } private normalizeDefaultMessageNotifications(guild: GuildInput): number { return this.normalizeFieldWithDefault(guild, 'default_message_notifications', 'defaultMessageNotifications', 0); } private normalizeDisabledOperations(guild: GuildInput): number { return this.normalizeFieldWithDefault(guild, 'disabled_operations', 'disabledOperations', 0); } private normalizeJoinedAt(guild: GuildInput): string | null { return this.normalizeField(guild, 'joined_at', 'joinedAt'); } private normalizeMemberCount(guild: GuildInput): number { if (this.isGuildInput(guild)) { const value = (guild as Guild).member_count; return typeof value === 'number' ? value : 0; } return (guild as GuildRecord).memberCount ?? 0; } private isGuildInput(guild: GuildInput): guild is Guild { return 'vanity_url_code' in guild; } get disabledOperations(): number { return this._disabledOperations; } static fromGuildReadyData(guildData: GuildReadyData): GuildRecord { const roles = Object.freeze( guildData.roles.reduce>( (acc, role) => ({ // biome-ignore lint/performance/noAccumulatingSpread: acceptable for guild roles - manageable dataset size and immutability required ...acc, [role.id]: new GuildRoleRecord(guildData.properties.id, role), }), {}, ), ); return new GuildRecord({ ...guildData.properties, roles, joined_at: guildData.joined_at, unavailable: guildData.unavailable, }); } toJSON(): Guild & { roles: Readonly>; } { return { id: this.id, name: this.name, icon: this.icon, banner: this.banner, banner_width: this.bannerWidth, banner_height: this.bannerHeight, splash: this.splash, splash_width: this.splashWidth, splash_height: this.splashHeight, splash_card_alignment: this.splashCardAlignment, embed_splash: this.embedSplash, embed_splash_width: this.embedSplashWidth, embed_splash_height: this.embedSplashHeight, features: [...this.features], vanity_url_code: this.vanityURLCode, owner_id: this.ownerId, system_channel_id: this.systemChannelId, system_channel_flags: this.systemChannelFlags, rules_channel_id: this.rulesChannelId, afk_channel_id: this.afkChannelId, afk_timeout: this.afkTimeout, verification_level: this.verificationLevel, mfa_level: this.mfaLevel, nsfw_level: this.nsfwLevel, explicit_content_filter: this.explicitContentFilter, default_message_notifications: this.defaultMessageNotifications, disabled_operations: this._disabledOperations, joined_at: this.joinedAt ?? undefined, unavailable: this.unavailable, member_count: this.memberCount, roles: this.roles, }; } withUpdates(guild: Partial): GuildRecord { return new GuildRecord({ ...this, name: guild.name ?? this.name, icon: guild.icon ?? this.icon, banner: guild.banner ?? this.banner, bannerWidth: guild.banner_width ?? this.bannerWidth, bannerHeight: guild.banner_height ?? this.bannerHeight, splash: guild.splash ?? this.splash, splashWidth: guild.splash_width ?? this.splashWidth, splashHeight: guild.splash_height ?? this.splashHeight, splashCardAlignment: guild.splash_card_alignment ?? this.splashCardAlignment, embedSplash: guild.embed_splash ?? this.embedSplash, embedSplashWidth: guild.embed_splash_width ?? this.embedSplashWidth, embedSplashHeight: guild.embed_splash_height ?? this.embedSplashHeight, features: guild.features ? new Set(guild.features) : this.features, vanityURLCode: guild.vanity_url_code ?? this.vanityURLCode, ownerId: guild.owner_id ?? this.ownerId, systemChannelId: guild.system_channel_id ?? this.systemChannelId, systemChannelFlags: guild.system_channel_flags ?? this.systemChannelFlags, rulesChannelId: guild.rules_channel_id ?? this.rulesChannelId, afkChannelId: guild.afk_channel_id ?? this.afkChannelId, afkTimeout: guild.afk_timeout ?? this.afkTimeout, verificationLevel: guild.verification_level ?? this.verificationLevel, mfaLevel: guild.mfa_level ?? this.mfaLevel, nsfwLevel: guild.nsfw_level ?? this.nsfwLevel, explicitContentFilter: guild.explicit_content_filter ?? this.explicitContentFilter, defaultMessageNotifications: guild.default_message_notifications ?? this.defaultMessageNotifications, disabledOperations: guild.disabled_operations ?? this.disabledOperations, unavailable: guild.unavailable ?? this.unavailable, memberCount: guild.member_count ?? this.memberCount, }); } withRoles(roles: Record): GuildRecord { return new GuildRecord({ ...this, roles: Object.freeze({...roles}), }); } addRole(role: GuildRoleRecord): GuildRecord { return this.withRoles({ ...this.roles, [role.id]: role, }); } removeRole(roleId: string): GuildRecord { const {[roleId]: _, ...remainingRoles} = this.roles; return this.withRoles(remainingRoles); } updateRole(role: GuildRoleRecord): GuildRecord { if (!this.roles[role.id]) { return this; } return this.addRole(role); } getRole(roleId: string): GuildRoleRecord | undefined { return this.roles[roleId]; } get createdAt(): Date { return new Date(SnowflakeUtils.extractTimestamp(this.id)); } isOwner(userId?: string | null): boolean { return userId != null && this.ownerId === userId; } get maxStaticEmojis(): number { if (this.features.has(GuildFeatures.MORE_EMOJI)) { return MAX_GUILD_EMOJIS_STATIC_MORE_EMOJI; } if (this.features.has(GuildFeatures.UNLIMITED_EMOJI)) { return Number.POSITIVE_INFINITY; } return MAX_GUILD_EMOJIS_STATIC; } get maxAnimatedEmojis(): number { if (this.features.has(GuildFeatures.MORE_EMOJI)) { return MAX_GUILD_EMOJIS_ANIMATED_MORE_EMOJI; } if (this.features.has(GuildFeatures.UNLIMITED_EMOJI)) { return Number.POSITIVE_INFINITY; } return MAX_GUILD_EMOJIS_ANIMATED; } get maxStickers(): number { if (this.features.has(GuildFeatures.MORE_STICKERS)) { return MAX_GUILD_STICKERS_MORE_STICKERS; } if (this.features.has(GuildFeatures.UNLIMITED_STICKERS)) { return Number.POSITIVE_INFINITY; } return MAX_GUILD_STICKERS; } get isLargeGuild(): boolean { return this.features.has(GuildFeatures.LARGE_GUILD_OVERRIDE) || this.memberCount > LARGE_GUILD_THRESHOLD; } get effectiveMessageNotifications(): number { if (this.memberCount === undefined || this.memberCount === null || this.memberCount < 0) { return this.defaultMessageNotifications; } if (this.isLargeGuild) { return MessageNotifications.ONLY_MENTIONS; } return this.defaultMessageNotifications; } get isNotificationOverrideActive(): boolean { return this.isLargeGuild && this.defaultMessageNotifications === MessageNotifications.ALL_MESSAGES; } }