/*
* 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;
}
}