initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
export type AuthSession = Readonly<{
id: string;
approx_last_used_at: string | null;
client_os: string;
client_platform: string;
client_location: string;
}>;
export class AuthSessionRecord {
readonly id: string;
readonly approxLastUsedAt: Date | null;
readonly clientOs: string;
readonly clientPlatform: string;
readonly clientLocation: string;
constructor(data: AuthSession) {
this.id = data.id;
this.approxLastUsedAt = data.approx_last_used_at ? new Date(data.approx_last_used_at) : null;
this.clientOs = data.client_os;
this.clientPlatform = data.client_platform;
this.clientLocation = data.client_location;
}
toJSON(): AuthSession {
return {
id: this.id,
approx_last_used_at: this.approxLastUsedAt?.toISOString() ?? null,
client_os: this.clientOs,
client_platform: this.clientPlatform,
client_location: this.clientLocation,
};
}
equals(other: AuthSessionRecord): boolean {
return JSON.stringify(this) === JSON.stringify(other);
}
}

View File

@@ -0,0 +1,59 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {type UserPartial, UserRecord} from '~/records/UserRecord';
import UserStore from '~/stores/UserStore';
export type BetaCode = Readonly<{
code: string;
created_at: string;
redeemed_at: string | null;
redeemer: UserPartial | null;
}>;
export class BetaCodeRecord {
readonly code: string;
readonly createdAt: Date;
readonly redeemedAt: Date | null;
readonly redeemer: UserRecord | null;
constructor(data: BetaCode) {
this.code = data.code;
this.createdAt = new Date(data.created_at);
this.redeemedAt = data.redeemed_at ? new Date(data.redeemed_at) : null;
this.redeemer = data.redeemer ? new UserRecord(data.redeemer) : null;
if (this.redeemer != null) {
UserStore.cacheUsers([this.redeemer.toJSON()]);
}
}
toJSON(): BetaCode {
return {
code: this.code,
created_at: this.createdAt.toISOString(),
redeemed_at: this.redeemedAt?.toISOString() ?? null,
redeemer: this.redeemer ? this.redeemer.toJSON() : null,
};
}
equals(other: BetaCodeRecord): boolean {
return JSON.stringify(this) === JSON.stringify(other);
}
}

View File

@@ -0,0 +1,376 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {ChannelTypes} from '~/Constants';
import type {UserPartial} from '~/records/UserRecord';
import UserPinnedDMStore from '~/stores/UserPinnedDMStore';
import UserStore from '~/stores/UserStore';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
type ChannelOverwrite = Readonly<{
id: string;
type: number;
allow: string;
deny: string;
}>;
export class ChannelOverwriteRecord {
readonly id: string;
readonly type: number;
readonly allow: bigint;
readonly deny: bigint;
constructor(overwrite: ChannelOverwrite) {
this.id = overwrite.id;
this.type = overwrite.type;
this.allow = BigInt(overwrite.allow);
this.deny = BigInt(overwrite.deny);
}
withUpdates(overwrite: Partial<ChannelOverwrite>): ChannelOverwriteRecord {
return new ChannelOverwriteRecord({
id: this.id,
type: overwrite.type ?? this.type,
allow: overwrite.allow ?? this.allow.toString(),
deny: overwrite.deny ?? this.deny.toString(),
});
}
equals(other: ChannelOverwriteRecord): boolean {
return this.id === other.id && this.type === other.type && this.allow === other.allow && this.deny === other.deny;
}
toJSON(): ChannelOverwrite {
return {
id: this.id,
type: this.type,
allow: this.allow.toString(),
deny: this.deny.toString(),
};
}
}
export type DefaultReactionEmoji = Readonly<{
emoji_id: string | null;
emoji_name: string | null;
}>;
export type Channel = Readonly<{
id: string;
guild_id?: string;
name?: string;
topic?: string | null;
url?: string | null;
icon?: string | null;
owner_id?: string | null;
type: number;
position?: number;
parent_id?: string | null;
bitrate?: number | null;
user_limit?: number | null;
rtc_region?: string | null;
last_message_id?: string | null;
last_pin_timestamp?: string | null;
permission_overwrites?: ReadonlyArray<ChannelOverwrite>;
recipients?: ReadonlyArray<UserPartial>;
nsfw?: boolean;
rate_limit_per_user?: number;
nicks?: Readonly<Record<string, string>>;
flags?: number;
member_count?: number;
message_count?: number;
total_message_sent?: number;
default_reaction_emoji?: DefaultReactionEmoji | null;
}>;
export class ChannelRecord {
readonly id: string;
readonly guildId?: string;
readonly name?: string;
readonly topic: string | null;
readonly url: string | null;
readonly icon: string | null;
readonly ownerId: string | null;
readonly type: number;
readonly position?: number;
readonly parentId: string | null;
readonly bitrate: number | null;
readonly userLimit: number | null;
readonly rtcRegion: string | null;
readonly lastMessageId: string | null;
readonly lastPinTimestamp: Date | null;
readonly permissionOverwrites: Readonly<Record<string, ChannelOverwriteRecord>>;
readonly recipientIds: ReadonlyArray<string>;
readonly nsfw: boolean;
readonly rateLimitPerUser: number;
readonly nicks: Readonly<Record<string, string>>;
readonly flags: number;
readonly memberCount?: number;
readonly messageCount?: number;
readonly totalMessageSent?: number;
readonly defaultReactionEmoji?: DefaultReactionEmoji | null;
constructor(channel: Channel) {
this.id = channel.id;
this.guildId = channel.guild_id;
this.name = channel.name;
this.topic = channel.topic ?? null;
this.url = channel.url ?? null;
this.icon = channel.icon ?? null;
this.ownerId = channel.owner_id ?? null;
this.type = channel.type;
this.position = channel.position;
this.parentId = channel.parent_id ?? null;
this.bitrate = channel.bitrate ?? null;
this.userLimit = channel.user_limit ?? null;
this.rtcRegion = channel.rtc_region ?? null;
this.lastMessageId = channel.last_message_id ?? null;
this.lastPinTimestamp = channel.last_pin_timestamp ? new Date(channel.last_pin_timestamp) : null;
this.nsfw = channel.nsfw ?? false;
this.rateLimitPerUser = channel.rate_limit_per_user ?? 0;
this.flags = channel.flags ?? 0;
this.nicks = channel.nicks ?? {};
this.memberCount = channel.member_count;
this.messageCount = channel.message_count;
this.totalMessageSent = channel.total_message_sent;
this.defaultReactionEmoji = channel.default_reaction_emoji;
if ((this.type === ChannelTypes.DM || this.type === ChannelTypes.GROUP_DM) && channel.recipients) {
UserStore.cacheUsers(Array.from(channel.recipients));
}
if (this.type === ChannelTypes.DM_PERSONAL_NOTES) {
const currentUser = UserStore.getCurrentUser();
this.recipientIds = currentUser ? [currentUser.id] : [];
} else if ((this.type === ChannelTypes.DM || this.type === ChannelTypes.GROUP_DM) && channel.recipients) {
this.recipientIds = channel.recipients.map((user) => user.id);
} else {
this.recipientIds = [];
}
this.permissionOverwrites =
!this.isPrivate() && channel.permission_overwrites
? channel.permission_overwrites.reduce(
(acc, overwrite) => {
acc[overwrite.id] = new ChannelOverwriteRecord(overwrite);
return acc;
},
{} as Record<string, ChannelOverwriteRecord>,
)
: {};
}
hasFlag(flag: number): boolean {
return (this.flags & flag) === flag;
}
get isPinned(): boolean {
return UserPinnedDMStore.pinnedDMs.includes(this.id);
}
isPrivate(): boolean {
return (
this.type === ChannelTypes.DM ||
this.type === ChannelTypes.GROUP_DM ||
this.type === ChannelTypes.DM_PERSONAL_NOTES
);
}
isDM(): boolean {
return this.type === ChannelTypes.DM;
}
isGroupDM(): boolean {
return this.type === ChannelTypes.GROUP_DM;
}
isPersonalNotes(): boolean {
return this.type === ChannelTypes.DM_PERSONAL_NOTES;
}
isGuildText(): boolean {
return this.type === ChannelTypes.GUILD_TEXT;
}
isGuildVoice(): boolean {
return this.type === ChannelTypes.GUILD_VOICE;
}
isGuildCategory(): boolean {
return this.type === ChannelTypes.GUILD_CATEGORY;
}
isVoice(): boolean {
return this.type === ChannelTypes.GUILD_VOICE;
}
isText(): boolean {
return this.type === ChannelTypes.GUILD_TEXT;
}
isNSFW(): boolean {
return this.nsfw;
}
getRecipientId(): string | undefined {
if (this.type !== ChannelTypes.DM) return undefined;
return this.recipientIds[0];
}
get createdAt(): Date {
return new Date(SnowflakeUtils.extractTimestamp(this.id));
}
withUpdates(updates: Partial<Channel>): ChannelRecord {
let newRecipients: Array<UserPartial> = [];
if (
updates.type === ChannelTypes.DM_PERSONAL_NOTES ||
(this.type === ChannelTypes.DM_PERSONAL_NOTES && updates.type === undefined)
) {
const currentUser = UserStore.getCurrentUser();
if (currentUser) {
newRecipients = [currentUser.toJSON()];
}
} else if ((this.type === ChannelTypes.DM || this.type === ChannelTypes.GROUP_DM) && updates.recipients) {
newRecipients = Array.from(updates.recipients);
UserStore.cacheUsers(newRecipients);
} else if (this.type === ChannelTypes.DM || this.type === ChannelTypes.GROUP_DM) {
newRecipients = this.recipientIds.map((id) => UserStore.getUser(id)!.toJSON());
}
return new ChannelRecord({
id: this.id,
guild_id: updates.guild_id ?? this.guildId,
name: updates.name ?? this.name,
topic: updates.topic !== undefined ? updates.topic : this.topic,
url: updates.url !== undefined ? updates.url : this.url,
icon: updates.icon !== undefined ? updates.icon : this.icon,
owner_id: updates.owner_id !== undefined ? updates.owner_id : this.ownerId,
type: updates.type ?? this.type,
position: updates.position ?? this.position,
parent_id: updates.parent_id !== undefined ? updates.parent_id : this.parentId,
bitrate: updates.bitrate !== undefined ? updates.bitrate : this.bitrate,
user_limit: updates.user_limit !== undefined ? updates.user_limit : this.userLimit,
rtc_region: updates.rtc_region !== undefined ? updates.rtc_region : this.rtcRegion,
last_message_id: updates.last_message_id !== undefined ? updates.last_message_id : this.lastMessageId,
last_pin_timestamp: updates.last_pin_timestamp ?? this.lastPinTimestamp?.toISOString() ?? undefined,
permission_overwrites: !this.isPrivate()
? (updates.permission_overwrites ?? Object.values(this.permissionOverwrites).map((o) => o.toJSON()))
: undefined,
recipients: newRecipients.length > 0 ? newRecipients : undefined,
nsfw: updates.nsfw ?? this.nsfw,
rate_limit_per_user: updates.rate_limit_per_user ?? this.rateLimitPerUser,
nicks: updates.nicks ?? this.nicks,
flags: updates.flags ?? this.flags,
member_count: updates.member_count ?? this.memberCount,
message_count: updates.message_count ?? this.messageCount,
total_message_sent: updates.total_message_sent ?? this.totalMessageSent,
default_reaction_emoji: updates.default_reaction_emoji ?? this.defaultReactionEmoji,
});
}
withOverwrite(overwrite: ChannelOverwriteRecord): ChannelRecord {
if (this.isPrivate()) {
return this;
}
return new ChannelRecord({
...this.toJSON(),
permission_overwrites: Object.values({
...this.permissionOverwrites,
[overwrite.id]: overwrite,
}).map((o) => o.toJSON()),
});
}
equals(other: ChannelRecord): boolean {
if (this === other) return true;
if (this.id !== other.id) return false;
if (this.guildId !== other.guildId) return false;
if (this.name !== other.name) return false;
if (this.topic !== other.topic) return false;
if (this.url !== other.url) return false;
if (this.icon !== other.icon) return false;
if (this.ownerId !== other.ownerId) return false;
if (this.type !== other.type) return false;
if (this.position !== other.position) return false;
if (this.parentId !== other.parentId) return false;
if (this.bitrate !== other.bitrate) return false;
if (this.userLimit !== other.userLimit) return false;
if (this.rtcRegion !== other.rtcRegion) return false;
if (this.lastMessageId !== other.lastMessageId) return false;
if (this.lastPinTimestamp?.getTime() !== other.lastPinTimestamp?.getTime()) return false;
if (this.nsfw !== other.nsfw) return false;
if (this.rateLimitPerUser !== other.rateLimitPerUser) return false;
if (this.flags !== other.flags) return false;
if (this.recipientIds.length !== other.recipientIds.length) return false;
for (let i = 0; i < this.recipientIds.length; i++) {
if (this.recipientIds[i] !== other.recipientIds[i]) return false;
}
const thisOverwrites = Object.keys(this.permissionOverwrites);
const otherOverwrites = Object.keys(other.permissionOverwrites);
if (thisOverwrites.length !== otherOverwrites.length) return false;
for (const key of thisOverwrites) {
if (!this.permissionOverwrites[key].equals(other.permissionOverwrites[key])) {
return false;
}
}
return true;
}
toJSON(): Channel {
return {
id: this.id,
guild_id: this.guildId,
name: this.name,
topic: this.topic,
url: this.url,
icon: this.icon,
owner_id: this.ownerId,
type: this.type,
position: this.position,
parent_id: this.parentId,
bitrate: this.bitrate,
user_limit: this.userLimit,
rtc_region: this.rtcRegion,
last_message_id: this.lastMessageId,
last_pin_timestamp: this.lastPinTimestamp?.toISOString() ?? undefined,
permission_overwrites: Object.values(this.permissionOverwrites).map((o) => o.toJSON()),
recipients:
this.type === ChannelTypes.DM || this.type === ChannelTypes.GROUP_DM
? this.recipientIds.map((id) => UserStore.getUser(id)!.toJSON())
: undefined,
nsfw: this.nsfw,
rate_limit_per_user: this.rateLimitPerUser,
nicks: this.nicks,
flags: this.flags,
member_count: this.memberCount,
message_count: this.messageCount,
total_message_sent: this.totalMessageSent,
default_reaction_emoji: this.defaultReactionEmoji,
};
}
}

View File

@@ -0,0 +1,95 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
export interface DeveloperApplicationBot {
id: string;
username: string;
discriminator: string;
avatar: string | null;
bio?: string | null;
token?: string;
banner?: string | null;
}
export interface DeveloperApplication {
id: string;
name: string;
redirect_uris: Array<string>;
bot_public: boolean;
bot_require_code_grant: boolean;
client_secret?: string;
bot?: DeveloperApplicationBot;
}
export class DeveloperApplicationRecord implements DeveloperApplication {
readonly id: string;
readonly name: string;
readonly redirect_uris: Array<string>;
readonly bot_public: boolean;
readonly bot_require_code_grant: boolean;
readonly client_secret?: string;
readonly bot?: DeveloperApplicationBot;
constructor(application: DeveloperApplication) {
this.id = application.id;
this.name = application.name;
this.redirect_uris = application.redirect_uris ? [...application.redirect_uris] : [];
this.bot_public = application.bot_public;
this.bot_require_code_grant = application.bot_require_code_grant;
if ('client_secret' in application) {
this.client_secret = application.client_secret;
}
if (application.bot) {
this.bot = {
id: application.bot.id,
username: application.bot.username,
discriminator: application.bot.discriminator,
avatar: application.bot.avatar,
bio: application.bot.bio ?? null,
token: application.bot.token,
banner: application.bot.banner ?? null,
};
}
}
static from(application: DeveloperApplication): DeveloperApplicationRecord {
return new DeveloperApplicationRecord(application);
}
withUpdates(updates: Partial<DeveloperApplication>): DeveloperApplicationRecord {
return new DeveloperApplicationRecord({
...this.toObject(),
...updates,
redirect_uris: updates.redirect_uris ?? this.redirect_uris,
bot: updates.bot ?? this.bot,
});
}
toObject(): DeveloperApplication {
return {
id: this.id,
name: this.name,
redirect_uris: [...this.redirect_uris],
bot_public: this.bot_public,
bot_require_code_grant: this.bot_require_code_grant,
client_secret: this.client_secret,
bot: this.bot ? {...this.bot} : undefined,
};
}
}

View File

@@ -0,0 +1,147 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
export type FavoriteMeme = Readonly<{
id: string;
user_id: string;
name: string;
alt_text: string | null;
tags: Array<string>;
attachment_id: string;
filename: string;
content_type: string;
content_hash: string | null;
size: number;
width: number | null;
height: number | null;
duration: number | null;
is_gifv: boolean;
url: string;
tenor_id: string | null;
}>;
export class FavoriteMemeRecord {
readonly id: string;
readonly userId: string;
readonly name: string;
readonly altText: string | null;
readonly tags: Array<string>;
readonly attachmentId: string;
readonly filename: string;
readonly contentType: string;
readonly contentHash: string | null;
readonly size: number;
readonly width: number | null;
readonly height: number | null;
readonly duration: number | null;
readonly isGifv: boolean;
readonly url: string;
readonly tenorId: string | null;
constructor(meme: FavoriteMeme) {
this.id = meme.id;
this.userId = meme.user_id;
this.name = meme.name;
this.altText = meme.alt_text;
this.tags = meme.tags;
this.attachmentId = meme.attachment_id;
this.filename = meme.filename;
this.contentType = meme.content_type;
this.contentHash = meme.content_hash;
this.size = meme.size;
this.width = meme.width;
this.height = meme.height;
this.duration = meme.duration;
this.isGifv = meme.is_gifv;
this.url = meme.url;
this.tenorId = meme.tenor_id;
}
get createdAtTimestamp(): number {
return SnowflakeUtils.extractTimestamp(this.id);
}
get createdAt(): Date {
return new Date(this.createdAtTimestamp);
}
isImage(): boolean {
return this.contentType.startsWith('image/');
}
isVideo(): boolean {
return this.contentType.startsWith('video/');
}
isAudio(): boolean {
return this.contentType.startsWith('audio/');
}
getMediaType(): 'image' | 'gifv' | 'video' | 'audio' | 'unknown' {
if (this.isGifv) return 'gifv';
if (this.isImage()) return 'image';
if (this.isVideo()) return 'video';
if (this.isAudio()) return 'audio';
return 'unknown';
}
equals(other: FavoriteMemeRecord): boolean {
return (
this.id === other.id &&
this.userId === other.userId &&
this.name === other.name &&
this.altText === other.altText &&
JSON.stringify(this.tags) === JSON.stringify(other.tags) &&
this.attachmentId === other.attachmentId &&
this.filename === other.filename &&
this.contentType === other.contentType &&
this.contentHash === other.contentHash &&
this.size === other.size &&
this.width === other.width &&
this.height === other.height &&
this.duration === other.duration &&
this.isGifv === other.isGifv &&
this.url === other.url &&
this.tenorId === other.tenorId
);
}
toJSON(): FavoriteMeme {
return {
id: this.id,
user_id: this.userId,
name: this.name,
alt_text: this.altText,
tags: this.tags,
attachment_id: this.attachmentId,
filename: this.filename,
content_type: this.contentType,
content_hash: this.contentHash,
size: this.size,
width: this.width,
height: this.height,
duration: this.duration,
is_gifv: this.isGifv,
url: this.url,
tenor_id: this.tenorId,
};
}
}

View File

@@ -0,0 +1,89 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {UserPartial} from '~/records/UserRecord';
import * as AvatarUtils from '~/utils/AvatarUtils';
export type GuildEmoji = Readonly<{
id: string;
name: string;
animated: boolean;
user?: UserPartial;
}>;
export interface GuildEmojiWithUser extends GuildEmoji {
user?: UserPartial;
}
export class GuildEmojiRecord {
readonly id: string;
readonly guildId: string;
readonly name: string;
readonly uniqueName: string;
readonly allNamesString: string;
readonly url: string;
readonly animated: boolean;
readonly user?: UserPartial;
constructor(guildId: string, data: GuildEmoji) {
this.id = data.id;
this.guildId = guildId;
this.name = data.name;
this.uniqueName = data.name;
this.allNamesString = `:${data.name}:`;
this.url = AvatarUtils.getEmojiURL({
id: data.id,
animated: data.animated,
});
this.animated = data.animated;
this.user = data.user;
}
withUpdates(updates: Partial<GuildEmoji>): GuildEmojiRecord {
return new GuildEmojiRecord(this.guildId, {
id: updates.id ?? this.id,
name: updates.name ?? this.name,
animated: updates.animated ?? this.animated,
user: updates.user ?? this.user,
});
}
equals(other: GuildEmojiRecord): boolean {
return (
this.id === other.id &&
this.guildId === other.guildId &&
this.name === other.name &&
this.animated === other.animated &&
this.user?.id === other.user?.id
);
}
toJSON(): GuildEmoji {
return {
id: this.id,
name: this.name,
animated: this.animated,
user: this.user,
};
}
static create(guildId: string, data: GuildEmoji): GuildEmojiRecord {
return new GuildEmojiRecord(guildId, data);
}
}

View File

@@ -0,0 +1,191 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {GuildMemberProfileFlags} from '~/Constants';
import type {GuildRoleRecord} from '~/records/GuildRoleRecord';
import type {UserPartial} from '~/records/UserRecord';
import {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
import * as ColorUtils from '~/utils/ColorUtils';
export type GuildMember = Readonly<{
user: UserPartial;
nick?: string | null;
avatar?: string | null;
banner?: string | null;
accent_color?: string | null;
roles: ReadonlyArray<string>;
joined_at: string;
join_source_type?: number | null;
source_invite_code?: string | null;
inviter_id?: string | null;
mute?: boolean;
deaf?: boolean;
communication_disabled_until?: string | null;
profile_flags?: number | null;
}>;
export class GuildMemberRecord {
readonly guildId: string;
readonly user: UserRecord;
readonly nick: string | null;
readonly avatar: string | null;
readonly banner: string | null;
readonly accentColor: string | null;
readonly roles: ReadonlySet<string>;
readonly joinedAt: Date;
readonly joinSourceType: number | null;
readonly sourceInviteCode: string | null;
readonly inviterId: string | null;
readonly mute: boolean;
readonly deaf: boolean;
readonly communicationDisabledUntil: Date | null;
readonly profileFlags: number;
constructor(guildId: string, guildMember: GuildMember) {
this.guildId = guildId;
const cachedUser = UserStore.getUser(guildMember.user.id);
if (cachedUser) {
this.user = cachedUser;
} else {
this.user = new UserRecord(guildMember.user);
UserStore.cacheUsers([this.user.toJSON()]);
}
this.nick = guildMember.nick ?? null;
this.avatar = guildMember.avatar ?? null;
this.banner = guildMember.banner ?? null;
this.accentColor = guildMember.accent_color ?? null;
this.roles = new Set(guildMember.roles);
this.joinedAt = new Date(guildMember.joined_at);
this.joinSourceType = guildMember.join_source_type ?? null;
this.sourceInviteCode = guildMember.source_invite_code ?? null;
this.inviterId = guildMember.inviter_id ?? null;
this.mute = guildMember.mute ?? false;
this.deaf = guildMember.deaf ?? false;
this.communicationDisabledUntil = guildMember.communication_disabled_until
? new Date(guildMember.communication_disabled_until)
: null;
this.profileFlags = guildMember.profile_flags ?? 0;
}
isAvatarUnset(): boolean {
return (this.profileFlags & GuildMemberProfileFlags.AVATAR_UNSET) !== 0;
}
isBannerUnset(): boolean {
return (this.profileFlags & GuildMemberProfileFlags.BANNER_UNSET) !== 0;
}
withUpdates(updates: Partial<GuildMember>): GuildMemberRecord {
return new GuildMemberRecord(this.guildId, {
user: updates.user ?? this.user.toJSON(),
nick: updates.nick ?? this.nick,
avatar: updates.avatar ?? this.avatar,
banner: updates.banner ?? this.banner,
accent_color: updates.accent_color ?? this.accentColor,
roles: updates.roles ?? Array.from(this.roles),
joined_at: updates.joined_at ?? this.joinedAt.toISOString(),
join_source_type: updates.join_source_type ?? this.joinSourceType,
source_invite_code: updates.source_invite_code ?? this.sourceInviteCode,
inviter_id: updates.inviter_id ?? this.inviterId,
mute: updates.mute ?? this.mute,
deaf: updates.deaf ?? this.deaf,
communication_disabled_until:
updates.communication_disabled_until ?? this.communicationDisabledUntil?.toISOString() ?? null,
profile_flags: updates.profile_flags ?? this.profileFlags,
});
}
withRoles(roles: Iterable<string>): GuildMemberRecord {
return new GuildMemberRecord(this.guildId, {
...this.toJSON(),
roles: Array.from(roles),
});
}
getSortedRoles(): ReadonlyArray<GuildRoleRecord> {
const guild = GuildStore.getGuild(this.guildId);
if (!guild) {
return [];
}
return Array.from(this.roles)
.map((roleId) => guild.roles[roleId])
.filter((role): role is GuildRoleRecord => role !== undefined)
.sort((a, b) => {
if (b.position !== a.position) {
return b.position - a.position;
}
return BigInt(a.id) < BigInt(b.id) ? -1 : 1;
});
}
getColorString(): string | undefined {
const sortedRoles = this.getSortedRoles();
for (const role of sortedRoles) {
if (role.color) {
return ColorUtils.int2rgb(role.color);
}
}
const guild = GuildStore.getGuild(this.guildId);
if (guild) {
const everyoneRole = guild.roles[this.guildId];
if (everyoneRole?.color) {
return ColorUtils.int2rgb(everyoneRole.color);
}
}
return;
}
isCurrentUser(): boolean {
return this.user.id === AuthenticationStore.currentUserId;
}
isTimedOut(): boolean {
if (!this.communicationDisabledUntil) {
return false;
}
return this.communicationDisabledUntil.getTime() > Date.now();
}
toJSON(): GuildMember {
return {
user: this.user.toJSON(),
nick: this.nick,
avatar: this.avatar,
banner: this.banner,
accent_color: this.accentColor,
roles: Array.from(this.roles),
joined_at: this.joinedAt.toISOString(),
join_source_type: this.joinSourceType,
source_invite_code: this.sourceInviteCode,
inviter_id: this.inviterId,
mute: this.mute,
deaf: this.deaf,
communication_disabled_until: this.communicationDisabledUntil?.toISOString() ?? null,
profile_flags: this.profileFlags,
};
}
}

View File

@@ -0,0 +1,470 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<string>;
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<Guild, 'roles'>;
channels: ReadonlyArray<Channel>;
emojis: ReadonlyArray<GuildEmoji>;
stickers?: ReadonlyArray<GuildSticker>;
members: ReadonlyArray<GuildMember>;
member_count: number;
presences?: ReadonlyArray<Presence>;
voice_states?: ReadonlyArray<VoiceState>;
roles: ReadonlyArray<GuildRole>;
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<string>;
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<Record<string, GuildRoleRecord>>;
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<T>(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<T>(
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<Record<string, GuildRoleRecord>> {
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<Record<string, GuildRoleRecord>>(
(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<Record<string, GuildRoleRecord>>;
} {
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<Guild>): 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<string, GuildRoleRecord>): 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;
}
}

View File

@@ -0,0 +1,101 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
export interface GuildRole {
readonly id: string;
readonly name: string;
readonly color: number;
readonly position: number;
readonly hoist_position?: number | null;
readonly permissions: string;
readonly hoist: boolean;
readonly mentionable: boolean;
}
export class GuildRoleRecord {
readonly id: string;
readonly guildId: string;
readonly name: string;
readonly color: number;
readonly position: number;
readonly hoistPosition: number | null;
readonly permissions: bigint;
readonly hoist: boolean;
readonly mentionable: boolean;
constructor(guildId: string, guildRole: GuildRole) {
this.id = guildRole.id;
this.guildId = guildId;
this.name = guildRole.name;
this.color = guildRole.color;
this.position = guildRole.position;
this.hoistPosition = guildRole.hoist_position ?? null;
this.permissions = BigInt(guildRole.permissions);
this.hoist = guildRole.hoist;
this.mentionable = guildRole.mentionable;
}
get effectiveHoistPosition(): number {
return this.hoistPosition ?? this.position;
}
withUpdates(updates: Partial<GuildRole>): GuildRoleRecord {
return new GuildRoleRecord(this.guildId, {
id: this.id,
name: updates.name ?? this.name,
color: updates.color ?? this.color,
position: updates.position ?? this.position,
hoist_position: updates.hoist_position !== undefined ? updates.hoist_position : this.hoistPosition,
permissions: updates.permissions ?? this.permissions.toString(),
hoist: updates.hoist ?? this.hoist,
mentionable: updates.mentionable ?? this.mentionable,
});
}
get isEveryone(): boolean {
return this.id === this.guildId;
}
equals(other: GuildRoleRecord): boolean {
return (
this.id === other.id &&
this.guildId === other.guildId &&
this.name === other.name &&
this.color === other.color &&
this.position === other.position &&
this.hoistPosition === other.hoistPosition &&
this.permissions === other.permissions &&
this.hoist === other.hoist &&
this.mentionable === other.mentionable
);
}
toJSON(): GuildRole {
return {
id: this.id,
name: this.name,
color: this.color,
position: this.position,
hoist_position: this.hoistPosition,
permissions: this.permissions.toString(),
hoist: this.hoist,
mentionable: this.mentionable,
};
}
}

View File

@@ -0,0 +1,107 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {StickerFormatTypes} from '~/Constants';
import type {UserPartial} from '~/records/UserRecord';
import * as AvatarUtils from '~/utils/AvatarUtils';
export type GuildSticker = Readonly<{
id: string;
name: string;
description: string;
tags: Array<string>;
format_type: number;
user?: UserPartial;
}>;
export interface GuildStickerWithUser extends GuildSticker {
user?: UserPartial;
}
export function isStickerAnimated(sticker: GuildSticker) {
return sticker.format_type === StickerFormatTypes.GIF;
}
export class GuildStickerRecord {
readonly id: string;
readonly guildId: string;
readonly name: string;
readonly description: string;
readonly tags: ReadonlyArray<string>;
readonly url: string;
readonly formatType: number;
readonly user?: UserPartial;
constructor(guildId: string, data: GuildSticker) {
this.id = data.id;
this.guildId = guildId;
this.name = data.name;
this.description = data.description;
this.tags = Object.freeze([...data.tags]);
this.url = AvatarUtils.getStickerURL({
id: data.id,
animated: isStickerAnimated(data),
size: 320,
});
this.formatType = data.format_type;
this.user = data.user;
}
isAnimated() {
return isStickerAnimated(this.toJSON());
}
withUpdates(updates: Partial<GuildSticker>): GuildStickerRecord {
return new GuildStickerRecord(this.guildId, {
id: updates.id ?? this.id,
name: updates.name ?? this.name,
description: updates.description ?? this.description,
tags: updates.tags ?? [...this.tags],
format_type: updates.format_type ?? this.formatType,
user: updates.user ?? this.user,
});
}
equals(other: GuildStickerRecord): boolean {
return (
this.id === other.id &&
this.guildId === other.guildId &&
this.name === other.name &&
this.description === other.description &&
JSON.stringify(this.tags) === JSON.stringify(other.tags) &&
this.formatType === other.formatType &&
this.user?.id === other.user?.id
);
}
toJSON(): GuildSticker {
return {
id: this.id,
name: this.name,
description: this.description,
tags: [...this.tags],
format_type: this.formatType,
user: this.user,
};
}
static create(guildId: string, data: GuildSticker): GuildStickerRecord {
return new GuildStickerRecord(guildId, data);
}
}

View File

@@ -0,0 +1,704 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {MessageFlags, MessageStates, MessageTypes} from '~/Constants';
import type {GuildMember} from '~/records/GuildMemberRecord';
import type {UserPartial} from '~/records/UserRecord';
import {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ChannelStore from '~/stores/ChannelStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import RelationshipStore from '~/stores/RelationshipStore';
import UserStore from '~/stores/UserStore';
import type {Invite as InviteType} from '~/types/InviteTypes';
import * as GiftCodeUtils from '~/utils/giftCodeUtils';
import * as InviteUtils from '~/utils/InviteUtils';
import {emojiEquals, type ReactionEmoji} from '~/utils/ReactionUtils';
import * as ThemeUtils from '~/utils/ThemeUtils';
export type Invite = InviteType;
export interface EmbedAuthor {
name: string;
url?: string;
icon_url?: string;
proxy_icon_url?: string;
}
export interface EmbedFooter {
text: string;
icon_url?: string;
proxy_icon_url?: string;
}
export interface EmbedMedia {
url: string;
proxy_url?: string;
content_type?: string;
content_hash?: string | null;
width?: number;
height?: number;
placeholder?: string;
flags: number;
description?: string;
duration?: number;
nsfw?: boolean;
}
export interface EmbedField {
name: string;
value: string;
inline: boolean;
}
export interface MessageEmbed {
id: string;
type: string;
url?: string;
title?: string;
color?: number;
timestamp?: string;
description?: string;
author?: EmbedAuthor;
image?: EmbedMedia;
thumbnail?: EmbedMedia;
footer?: EmbedFooter;
fields?: ReadonlyArray<EmbedField>;
provider?: EmbedAuthor;
video?: EmbedMedia;
audio?: EmbedMedia;
flags?: number;
}
export interface MessageReference {
message_id: string;
channel_id: string;
guild_id?: string;
type?: number;
}
export interface MessageReaction {
emoji: ReactionEmoji;
count: number;
me?: true;
me_burst?: boolean;
count_details?: {
burst: number;
normal: number;
};
}
export interface MessageAttachment {
id: string;
filename: string;
title?: string;
description?: string;
caption?: string;
content_type?: string;
size: number;
url: string | null;
proxy_url: string | null;
width?: number;
height?: number;
placeholder?: string;
placeholder_version?: number;
flags: number;
duration_secs?: number;
duration?: number;
waveform?: string;
content_hash?: string | null;
nsfw?: boolean;
expires_at?: string | null;
expired?: boolean;
}
export interface MessageCall {
participants: Array<string>;
ended_timestamp?: string | null;
}
export interface MessageSnapshot {
type: number;
content: string;
embeds?: ReadonlyArray<MessageEmbed>;
attachments?: ReadonlyArray<MessageAttachment>;
timestamp: string;
}
export interface MessageStickerItem {
id: string;
name: string;
format_type: number;
}
export interface ChannelMention {
id: string;
guild_id: string;
type: number;
name: string;
parent_id?: string | null;
}
export interface AllowedMentions {
parse?: ReadonlyArray<'roles' | 'users' | 'everyone'>;
roles?: ReadonlyArray<string>;
users?: ReadonlyArray<string>;
replied_user?: boolean;
}
export interface MessageMention extends UserPartial {
member?: Omit<GuildMember, 'user'>;
}
export type Message = Readonly<{
id: string;
channel_id: string;
guild_id?: string;
author: UserPartial;
member?: Omit<GuildMember, 'user'>;
webhook_id?: string;
application_id?: string;
type: number;
flags: number;
pinned: boolean;
tts?: boolean;
mention_everyone: boolean;
content: string;
timestamp: string;
edited_timestamp?: string;
mentions?: ReadonlyArray<MessageMention>;
mention_roles?: ReadonlyArray<string>;
mention_channels?: ReadonlyArray<ChannelMention>;
embeds?: ReadonlyArray<MessageEmbed>;
attachments?: ReadonlyArray<MessageAttachment>;
stickers?: ReadonlyArray<MessageStickerItem>;
reactions?: ReadonlyArray<MessageReaction>;
message_reference?: MessageReference;
referenced_message?: Message | null;
message_snapshots?: ReadonlyArray<MessageSnapshot>;
call?: MessageCall | null;
state?: string;
nonce?: string;
blocked?: boolean;
loggingName?: string;
_allowedMentions?: AllowedMentions;
_favoriteMemeId?: string;
}>;
interface TransformedMessageCall {
participants: Array<string>;
endedTimestamp: Date | null;
}
function transformMessageCall(call?: MessageCall | null): TransformedMessageCall | null {
if (call != null) {
return {
participants: call.participants,
endedTimestamp: call.ended_timestamp != null ? new Date(call.ended_timestamp) : null,
};
}
return null;
}
let embedIdCounter = 0;
const generateEmbedId = (): string => {
return `embed_${embedIdCounter++}`;
};
const areEmbedsEqual = (embed1: MessageEmbed, embed2: MessageEmbed): boolean => {
const {id: _id1, ...embed1WithoutId} = embed1;
const {id: _id2, ...embed2WithoutId} = embed2;
return JSON.stringify(embed1WithoutId) === JSON.stringify(embed2WithoutId);
};
const embedCache: Array<{embed: MessageEmbed; id: string}> = [];
const getOrCreateEmbedId = (embed: Omit<MessageEmbed, 'id'>): string => {
const existingEmbed = embedCache.find((cached) => areEmbedsEqual(cached.embed, embed as MessageEmbed));
if (existingEmbed) {
return existingEmbed.id;
}
const newId = generateEmbedId();
embedCache.push({
embed: {...embed, id: newId} as MessageEmbed,
id: newId,
});
return newId;
};
interface MessageRecordOptions {
skipUserCache?: boolean;
}
export class MessageRecord {
readonly id: string;
readonly channelId: string;
readonly guildId?: string;
readonly author: UserRecord;
readonly webhookId?: string;
readonly applicationId?: string;
readonly type: number;
readonly flags: number;
readonly pinned: boolean;
readonly mentionEveryone: boolean;
readonly content: string;
readonly timestamp: Date;
readonly editedTimestamp: Date | null;
readonly mentions: ReadonlyArray<UserRecord>;
readonly mentionRoles: ReadonlyArray<string>;
readonly mentionChannels: ReadonlyArray<ChannelMention>;
readonly embeds: ReadonlyArray<MessageEmbed>;
readonly attachments: ReadonlyArray<MessageAttachment>;
readonly stickerItems: ReadonlyArray<MessageStickerItem>;
readonly reactions: ReadonlyArray<MessageReaction>;
readonly messageReference?: MessageReference;
readonly referencedMessage?: MessageRecord | null;
readonly messageSnapshots?: ReadonlyArray<MessageSnapshot>;
readonly call: TransformedMessageCall | null;
readonly state: string;
readonly nonce?: string;
readonly blocked: boolean;
readonly loggingName?: string;
readonly invites: ReadonlyArray<string>;
readonly gifts: ReadonlyArray<string>;
readonly themes: ReadonlyArray<string>;
readonly _allowedMentions?: AllowedMentions;
readonly _favoriteMemeId?: string;
readonly stickers?: ReadonlyArray<MessageStickerItem>;
constructor(message: Message, options?: MessageRecordOptions) {
if (!options?.skipUserCache) {
UserStore.cacheUsers([message.author, ...(message.mentions ?? [])]);
}
const isBlocked = RelationshipStore.isBlocked(message.author.id);
if (message.webhook_id) {
this.author = new UserRecord(message.author);
} else {
this.author = UserStore.getUser(message.author.id) || new UserRecord(message.author);
}
this.id = message.id;
this.channelId = message.channel_id;
this.guildId = message.guild_id;
this.webhookId = message.webhook_id;
this.applicationId = message.application_id;
this.type = message.type;
this.flags = message.flags;
this.pinned = message.pinned;
this.mentionEveryone = message.mention_everyone;
this.content = message.content;
this.timestamp = new Date(message.timestamp);
this.editedTimestamp = message.edited_timestamp ? new Date(message.edited_timestamp) : null;
this.state = message.state ?? MessageStates.SENT;
this.nonce = message.nonce;
this.blocked = message.blocked ?? isBlocked;
this.loggingName = message.loggingName;
this.mentions = Object.freeze((message.mentions ?? []).map((user) => new UserRecord(user)));
this.mentionRoles = Object.freeze(message.mention_roles ?? []);
this.mentionChannels = Object.freeze(message.mention_channels ?? []);
this.embeds = Object.freeze(
(message.embeds ?? []).map((embed) => ({
...embed,
id: getOrCreateEmbedId(embed),
})),
);
this.attachments = Object.freeze(message.attachments ?? []);
this.stickerItems = Object.freeze(message.stickers ?? []);
this.reactions = Object.freeze(message.reactions ?? []);
this.messageReference = message.message_reference;
this.referencedMessage = message.referenced_message
? new MessageRecord(message.referenced_message, {skipUserCache: true})
: undefined;
this.messageSnapshots = message.message_snapshots ? Object.freeze(message.message_snapshots) : undefined;
this.call = transformMessageCall(message.call);
this.invites = Object.freeze(InviteUtils.findInvites(message.content));
this.gifts = Object.freeze(GiftCodeUtils.findGifts(message.content));
this.themes = Object.freeze(ThemeUtils.findThemes(message.content));
this._allowedMentions = message._allowedMentions;
this._favoriteMemeId = message._favoriteMemeId;
this.stickers = message.stickers ? Object.freeze(message.stickers) : undefined;
}
hasFlag(flag: number): boolean {
return (this.flags & flag) === flag;
}
get suppressEmbeds(): boolean {
return this.hasFlag(MessageFlags.SUPPRESS_EMBEDS);
}
get suppressNotifications(): boolean {
return this.hasFlag(MessageFlags.SUPPRESS_NOTIFICATIONS);
}
get isSilent(): boolean {
return this.hasFlag(MessageFlags.SUPPRESS_NOTIFICATIONS);
}
isUserMessage(): boolean {
return (
this.type === MessageTypes.DEFAULT || this.type === MessageTypes.REPLY || this.type === MessageTypes.CLIENT_SYSTEM
);
}
isSystemMessage(): boolean {
return !this.isUserMessage();
}
isAuthor(userId?: string | null): boolean {
return userId != null && this.author.id === userId;
}
isCurrentUserAuthor(): boolean {
return this.isAuthor(AuthenticationStore.currentUserId);
}
isMentioned(): boolean {
return messageMentionsCurrentUser(this.toJSON());
}
get isSending(): boolean {
return this.state === MessageStates.SENDING;
}
get isSent(): boolean {
return this.state === MessageStates.SENT;
}
get hasFailed(): boolean {
return this.state === MessageStates.FAILED;
}
get isEditing(): boolean {
return this.state === MessageStates.EDITING;
}
withUpdates(updates: Partial<Message>): MessageRecord {
return new MessageRecord(
{
id: this.id,
channel_id: this.channelId,
guild_id: updates.guild_id ?? this.guildId,
author: updates.author ?? this.author.toJSON(),
webhook_id: updates.webhook_id ?? this.webhookId,
application_id: updates.application_id ?? this.applicationId,
type: updates.type ?? this.type,
flags: updates.flags ?? this.flags,
pinned: updates.pinned ?? this.pinned,
mention_everyone: 'mention_everyone' in updates ? (updates.mention_everyone ?? false) : this.mentionEveryone,
content: updates.content ?? this.content,
timestamp: this.timestamp.toISOString(),
edited_timestamp: updates.edited_timestamp ?? this.editedTimestamp?.toISOString(),
mentions: 'mentions' in updates ? updates.mentions : this.mentions.map((m) => m.toJSON()),
mention_roles: 'mention_roles' in updates ? updates.mention_roles : this.mentionRoles,
mention_channels: updates.mention_channels ?? this.mentionChannels,
embeds: updates.embeds ?? this.embeds,
attachments: updates.attachments ?? this.attachments,
stickers: updates.stickers ?? this.stickerItems,
reactions: updates.reactions ?? this.reactions,
message_reference: updates.message_reference ?? this.messageReference,
referenced_message: updates.referenced_message ?? this.referencedMessage?.toJSON(),
message_snapshots: updates.message_snapshots ?? this.messageSnapshots,
call: updates.call ?? this.call,
state: updates.state ?? this.state,
nonce: updates.nonce ?? this.nonce,
blocked: updates.blocked ?? this.blocked,
loggingName: updates.loggingName ?? this.loggingName,
},
{skipUserCache: true},
);
}
withReaction(emoji: ReactionEmoji, add = true, me = false): MessageRecord {
const existingReaction = this.getReaction(emoji);
if (!existingReaction && !add) {
return this;
}
let newReactions: Array<MessageReaction>;
if (existingReaction) {
if (add) {
newReactions = this.reactions.map((reaction) =>
emojiEquals(reaction.emoji, emoji)
? {
...reaction,
count: me && reaction.me ? reaction.count : reaction.count + 1,
me: me || reaction.me ? true : undefined,
}
: reaction,
);
} else {
const updatedCount = existingReaction.count - (me && !existingReaction.me ? 0 : 1);
if (updatedCount <= 0) {
newReactions = this.reactions.filter((reaction) => !emojiEquals(reaction.emoji, emoji));
} else {
newReactions = this.reactions.map((reaction) =>
emojiEquals(reaction.emoji, emoji)
? {
...reaction,
count: updatedCount,
me: me ? undefined : reaction.me,
}
: reaction,
);
}
}
} else {
newReactions = [
...this.reactions,
{
emoji,
count: 1,
me: me ? true : undefined,
},
];
}
return this.withUpdates({reactions: newReactions});
}
withoutReactionEmoji(emoji: ReactionEmoji): MessageRecord {
return this.withUpdates({
reactions: this.reactions.filter((reaction) => !emojiEquals(reaction.emoji, emoji)),
});
}
getReaction(emoji: ReactionEmoji): MessageReaction | undefined {
return this.reactions.find((r) => emojiEquals(r.emoji, emoji));
}
equals(other: MessageRecord): boolean {
if (this === other) return true;
if (this.id !== other.id) return false;
if (this.channelId !== other.channelId) return false;
if (this.guildId !== other.guildId) return false;
if (this.type !== other.type) return false;
if (this.flags !== other.flags) return false;
if (this.pinned !== other.pinned) return false;
if (this.mentionEveryone !== other.mentionEveryone) return false;
if (this.content !== other.content) return false;
if (this.state !== other.state) return false;
if (this.nonce !== other.nonce) return false;
if (this.blocked !== other.blocked) return false;
if (this.webhookId !== other.webhookId) return false;
if (this.applicationId !== other.applicationId) return false;
if (this.loggingName !== other.loggingName) return false;
if (this.timestamp.getTime() !== other.timestamp.getTime()) return false;
if (this.editedTimestamp?.getTime() !== other.editedTimestamp?.getTime()) return false;
if (!this.author.equals(other.author)) return false;
if (this.mentions.length !== other.mentions.length) return false;
if (this.mentionRoles.length !== other.mentionRoles.length) return false;
if (this.mentionChannels.length !== other.mentionChannels.length) return false;
if (this.embeds.length !== other.embeds.length) return false;
if (this.attachments.length !== other.attachments.length) return false;
if (this.stickerItems.length !== other.stickerItems.length) return false;
if (this.reactions.length !== other.reactions.length) return false;
if (this.invites.length !== other.invites.length) return false;
if (this.gifts.length !== other.gifts.length) return false;
if (this.themes.length !== other.themes.length) return false;
for (let i = 0; i < this.mentions.length; i++) {
if (!this.mentions[i].equals(other.mentions[i])) return false;
}
for (let i = 0; i < this.mentionRoles.length; i++) {
if (this.mentionRoles[i] !== other.mentionRoles[i]) return false;
}
if (this.mentionChannels.length > 0) {
for (let i = 0; i < this.mentionChannels.length; i++) {
if (JSON.stringify(this.mentionChannels[i]) !== JSON.stringify(other.mentionChannels[i])) {
return false;
}
}
}
for (let i = 0; i < this.embeds.length; i++) {
if (this.embeds[i].id !== other.embeds[i].id) return false;
}
for (let i = 0; i < this.attachments.length; i++) {
const a1 = this.attachments[i];
const a2 = other.attachments[i];
if (
a1.id !== a2.id ||
a1.filename !== a2.filename ||
a1.size !== a2.size ||
a1.url !== a2.url ||
a1.proxy_url !== a2.proxy_url ||
a1.width !== a2.width ||
a1.height !== a2.height ||
a1.content_type !== a2.content_type ||
a1.flags !== a2.flags
) {
return false;
}
}
for (let i = 0; i < this.stickerItems.length; i++) {
const s1 = this.stickerItems[i];
const s2 = other.stickerItems[i];
if (s1.id !== s2.id || s1.name !== s2.name || s1.format_type !== s2.format_type) {
return false;
}
}
for (let i = 0; i < this.reactions.length; i++) {
const r1 = this.reactions[i];
const r2 = other.reactions[i];
if (!emojiEquals(r1.emoji, r2.emoji) || r1.count !== r2.count || r1.me !== r2.me || r1.me_burst !== r2.me_burst) {
return false;
}
}
for (let i = 0; i < this.invites.length; i++) {
if (this.invites[i] !== other.invites[i]) return false;
}
for (let i = 0; i < this.gifts.length; i++) {
if (this.gifts[i] !== other.gifts[i]) return false;
}
for (let i = 0; i < this.themes.length; i++) {
if (this.themes[i] !== other.themes[i]) return false;
}
if (this.messageReference !== other.messageReference) {
if (!this.messageReference || !other.messageReference) return false;
if (
this.messageReference.message_id !== other.messageReference.message_id ||
this.messageReference.channel_id !== other.messageReference.channel_id ||
this.messageReference.guild_id !== other.messageReference.guild_id ||
this.messageReference.type !== other.messageReference.type
) {
return false;
}
}
if (this.referencedMessage !== other.referencedMessage) {
if (!this.referencedMessage || !other.referencedMessage) return false;
if (!this.referencedMessage.equals(other.referencedMessage)) return false;
}
if (this.messageSnapshots !== other.messageSnapshots) {
if (!this.messageSnapshots || !other.messageSnapshots) return false;
if (this.messageSnapshots.length !== other.messageSnapshots.length) return false;
for (let i = 0; i < this.messageSnapshots.length; i++) {
if (JSON.stringify(this.messageSnapshots[i]) !== JSON.stringify(other.messageSnapshots[i])) {
return false;
}
}
}
if (this.call !== other.call) {
if (!this.call || !other.call) return false;
if (
this.call.participants.length !== other.call.participants.length ||
this.call.endedTimestamp?.getTime() !== other.call.endedTimestamp?.getTime()
) {
return false;
}
for (let i = 0; i < this.call.participants.length; i++) {
if (this.call.participants[i] !== other.call.participants[i]) return false;
}
}
return true;
}
static hasRenderChanges(prev: MessageRecord | undefined, next: MessageRecord | undefined): boolean {
if (!prev && !next) return false;
if (!prev || !next) return true;
return !prev.equals(next);
}
toJSON(): Message {
return {
id: this.id,
channel_id: this.channelId,
guild_id: this.guildId,
author: this.author.toJSON(),
webhook_id: this.webhookId,
application_id: this.applicationId,
type: this.type,
flags: this.flags,
pinned: this.pinned,
mention_everyone: this.mentionEveryone,
content: this.content,
timestamp: this.timestamp.toISOString(),
edited_timestamp: this.editedTimestamp?.toISOString(),
mentions: this.mentions.map((user) => user.toJSON()),
mention_roles: this.mentionRoles,
mention_channels: this.mentionChannels,
embeds: this.embeds,
attachments: this.attachments,
stickers: this.stickerItems,
reactions: this.reactions,
message_reference: this.messageReference,
referenced_message: this.referencedMessage?.toJSON(),
message_snapshots: this.messageSnapshots,
call: this.call,
state: this.state,
nonce: this.nonce,
blocked: this.blocked,
loggingName: this.loggingName,
};
}
}
export const messageMentionsCurrentUser = (message: Message): boolean => {
const channel = ChannelStore.getChannel(message.channel_id);
if (!channel) return false;
if (message.mention_everyone) return true;
if (message.mentions?.some((user) => user.id === AuthenticationStore.currentUserId)) {
return true;
}
if (!channel.guildId) return false;
const guild = GuildStore.getGuild(channel.guildId);
if (!guild) return false;
const guildMember = GuildMemberStore.getMember(guild.id, AuthenticationStore.currentUserId);
if (!guildMember) return false;
return message.mention_roles?.some((roleId) => guildMember.roles.has(roleId)) ?? false;
};

View File

@@ -0,0 +1,148 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {GuildMember, GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import type {UserPartial, UserProfile} from '~/records/UserRecord';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
export type Profile = Readonly<{
user: UserPartial;
user_profile: UserProfile;
guild_member_profile?: UserProfile | null;
timezone_offset: number | null;
guild_member?: GuildMember;
premium_type?: number;
premium_since?: string;
premium_lifetime_sequence?: number;
mutual_friends?: Array<UserPartial>;
}>;
export class ProfileRecord {
readonly userId: string;
readonly guildId: string | null;
readonly userProfile: Readonly<UserProfile>;
readonly guildMemberProfile: Readonly<UserProfile> | null;
readonly timezoneOffset: number | null;
readonly premiumType: number | null;
readonly premiumSince: Date | null;
readonly premiumLifetimeSequence: number | null;
readonly mutualFriends: ReadonlyArray<UserPartial> | null;
constructor(profile: Profile, guildId?: string) {
this.userId = profile.user.id;
this.guildId = guildId ?? null;
this.userProfile = Object.freeze({...profile.user_profile});
this.guildMemberProfile = profile.guild_member_profile ? Object.freeze({...profile.guild_member_profile}) : null;
this.timezoneOffset = profile.timezone_offset;
this.premiumType = profile.premium_type ?? null;
this.premiumSince = profile.premium_since ? new Date(profile.premium_since) : null;
this.premiumLifetimeSequence = profile.premium_lifetime_sequence ?? null;
this.mutualFriends = profile.mutual_friends ? Object.freeze([...profile.mutual_friends]) : null;
}
withUpdates(updates: Partial<Profile>): ProfileRecord {
return new ProfileRecord(
{
user: {...this.toJSON().user, ...(updates.user ?? {})},
user_profile: updates.user_profile ?? this.userProfile,
guild_member_profile:
updates.guild_member_profile === undefined ? this.guildMemberProfile : (updates.guild_member_profile ?? null),
timezone_offset: updates.timezone_offset ?? this.timezoneOffset,
guild_member: updates.guild_member,
premium_type: updates.premium_type !== undefined ? updates.premium_type : (this.premiumType ?? undefined),
premium_since:
updates.premium_since !== undefined ? updates.premium_since : (this.premiumSince?.toISOString() ?? undefined),
premium_lifetime_sequence:
updates.premium_lifetime_sequence !== undefined
? updates.premium_lifetime_sequence
: (this.premiumLifetimeSequence ?? undefined),
mutual_friends: updates.mutual_friends ?? (this.mutualFriends ? [...this.mutualFriends] : undefined),
},
this.guildId ?? undefined,
);
}
withGuildId(guildId: string | null): ProfileRecord {
return new ProfileRecord(this.toJSON(), guildId ?? undefined);
}
get guild(): GuildRecord | null {
if (!this.guildId) return null;
return GuildStore.getGuild(this.guildId) ?? null;
}
get guildMember(): GuildMemberRecord | null {
if (!this.guildId) return null;
return GuildMemberStore.getMember(this.guildId, this.userId) ?? null;
}
getGuildMemberProfile(): Readonly<UserProfile> | null {
return this.guildMemberProfile;
}
getEffectiveProfile(): Readonly<UserProfile> {
if (!this.guildMemberProfile) {
return this.userProfile;
}
const guildMember = this.guildMember;
const isBannerUnset = guildMember?.isBannerUnset() ?? false;
const bannerColor = isBannerUnset
? null
: (this.guildMemberProfile?.banner_color ?? this.userProfile.banner_color ?? null);
return {
bio: this.guildMemberProfile.bio ?? this.userProfile.bio,
banner: isBannerUnset ? null : (this.guildMemberProfile.banner ?? this.userProfile.banner),
banner_color: bannerColor,
pronouns: this.guildMemberProfile.pronouns ?? this.userProfile.pronouns,
accent_color: this.guildMemberProfile.accent_color ?? this.userProfile.accent_color,
};
}
equals(other: ProfileRecord): boolean {
return (
this.userId === other.userId &&
this.guildId === other.guildId &&
JSON.stringify(this.userProfile) === JSON.stringify(other.userProfile) &&
JSON.stringify(this.guildMemberProfile) === JSON.stringify(other.guildMemberProfile) &&
this.timezoneOffset === other.timezoneOffset &&
this.premiumType === other.premiumType &&
this.premiumSince === other.premiumSince &&
this.premiumLifetimeSequence === other.premiumLifetimeSequence &&
JSON.stringify(this.mutualFriends) === JSON.stringify(other.mutualFriends)
);
}
toJSON(): Profile {
return {
user: UserStore.getUser(this.userId)!,
user_profile: {...this.userProfile},
guild_member_profile: this.guildMemberProfile ? {...this.guildMemberProfile} : undefined,
timezone_offset: this.timezoneOffset,
premium_type: this.premiumType ?? undefined,
premium_since: this.premiumSince?.toISOString() ?? undefined,
premium_lifetime_sequence: this.premiumLifetimeSequence ?? undefined,
mutual_friends: this.mutualFriends ? [...this.mutualFriends] : undefined,
};
}
}

View File

@@ -0,0 +1,71 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {UserPartial, UserRecord} from '~/records/UserRecord';
import UserStore from '~/stores/UserStore';
export type Relationship = Readonly<{
id: string;
type: number;
user?: UserPartial;
since: string;
nickname?: string | null;
}>;
export class RelationshipRecord {
readonly id: string;
readonly type: number;
readonly userId: string;
readonly since: Date;
readonly nickname: string | null;
constructor(relationship: Relationship) {
if (relationship.user) {
UserStore.cacheUsers([relationship.user]);
this.userId = relationship.user.id;
} else {
this.userId = relationship.id;
}
this.id = relationship.id;
this.type = relationship.type;
this.since = new Date(relationship.since);
this.nickname = relationship.nickname ?? null;
}
get user(): UserRecord {
return UserStore.getUser(this.userId)!;
}
withUpdates(relationship: Relationship): RelationshipRecord {
const mergedUser = relationship.user
? {
...this.user?.toJSON(),
...relationship.user,
}
: this.user?.toJSON();
return new RelationshipRecord({
id: relationship.id ?? this.id,
type: relationship.type ?? this.type,
since: relationship.since ?? this.since.toISOString(),
nickname: relationship.nickname ?? this.nickname,
user: mergedUser,
});
}
}

View File

@@ -0,0 +1,64 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {type Message, MessageRecord} from '~/records/MessageRecord';
export interface SavedMessageEntryResponse {
id: string;
channel_id: string;
message_id: string;
status: SavedMessageStatus;
message: Message | null;
}
export type SavedMessageStatus = 'available' | 'missing_permissions';
export interface SavedMessageMissingEntry {
id: string;
channelId: string;
messageId: string;
}
export class SavedMessageEntryRecord {
readonly id: string;
readonly channelId: string;
readonly messageId: string;
readonly status: SavedMessageStatus;
readonly message: MessageRecord | null;
constructor(data: SavedMessageEntryResponse) {
this.id = data.id;
this.channelId = data.channel_id;
this.messageId = data.message_id;
this.status = data.status;
this.message = data.message ? new MessageRecord(data.message) : null;
}
static fromResponse(response: SavedMessageEntryResponse): SavedMessageEntryRecord {
return new SavedMessageEntryRecord(response);
}
toMissingEntry(): SavedMessageMissingEntry {
return {
id: this.id,
channelId: this.channelId,
messageId: this.messageId,
};
}
}

View File

@@ -0,0 +1,98 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
export interface ScheduledMessageReference {
message_id: string;
channel_id?: string;
guild_id?: string;
type?: number;
}
export interface ScheduledAllowedMentions {
parse?: Array<'users' | 'roles' | 'everyone'>;
users?: Array<string>;
roles?: Array<string>;
replied_user?: boolean;
}
export interface ScheduledAttachment {
id: string;
filename: string;
title?: string;
description?: string;
flags?: number;
}
export interface ScheduledMessagePayload {
content?: string | null;
embeds?: Array<unknown>;
attachments?: Array<ScheduledAttachment>;
message_reference?: ScheduledMessageReference;
allowed_mentions?: ScheduledAllowedMentions;
flags?: number;
nonce?: string;
favorite_meme_id?: string;
sticker_ids?: Array<string>;
tts?: boolean;
}
export type ScheduledMessageStatus = 'pending' | 'invalid';
export interface ScheduledMessageResponse {
id: string;
channel_id: string;
scheduled_at: string;
scheduled_local_at: string;
timezone: string;
status: ScheduledMessageStatus;
status_reason: string | null;
payload: ScheduledMessagePayload;
created_at: string;
invalidated_at: string | null;
}
export class ScheduledMessageRecord {
readonly id: string;
readonly channelId: string;
readonly scheduledAt: Date;
readonly scheduledLocalAt: string;
readonly timezone: string;
readonly payload: ScheduledMessagePayload;
readonly status: ScheduledMessageStatus;
readonly statusReason: string | null;
readonly createdAt: Date;
readonly invalidatedAt: Date | null;
constructor(response: ScheduledMessageResponse) {
this.id = response.id;
this.channelId = response.channel_id;
this.scheduledAt = new Date(response.scheduled_at);
this.scheduledLocalAt = response.scheduled_local_at;
this.timezone = response.timezone;
this.payload = response.payload;
this.status = response.status ?? 'pending';
this.statusReason = response.status_reason;
this.createdAt = new Date(response.created_at);
this.invalidatedAt = response.invalidated_at ? new Date(response.invalidated_at) : null;
}
static fromResponse(response: ScheduledMessageResponse): ScheduledMessageRecord {
return new ScheduledMessageRecord(response);
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
export type MuteConfig = Readonly<{
end_time?: string | null;
selected_time_window?: number;
}> | null;
export type ChannelOverride = Readonly<{
collapsed: boolean;
message_notifications: number;
muted: boolean;
mute_config?: MuteConfig;
}>;
export type UserGuildSettings = Readonly<{
guild_id: string | null;
message_notifications: number;
muted: boolean;
mute_config?: MuteConfig;
mobile_push: boolean;
suppress_everyone: boolean;
suppress_roles: boolean;
hide_muted_channels: boolean;
channel_overrides?: Record<string, ChannelOverride> | null;
version: number;
}>;
export type UserGuildSettingsPartial = Partial<Omit<UserGuildSettings, 'guild_id' | 'version'>>;

View File

@@ -0,0 +1,642 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {
MAX_BIO_LENGTH_NON_PREMIUM,
MAX_BIO_LENGTH_PREMIUM,
MAX_BOOKMARKS_NON_PREMIUM,
MAX_BOOKMARKS_PREMIUM,
MAX_FAVORITE_MEMES_NON_PREMIUM,
MAX_FAVORITE_MEMES_PREMIUM,
MAX_GUILDS_NON_PREMIUM,
MAX_GUILDS_PREMIUM,
MAX_MESSAGE_LENGTH_NON_PREMIUM,
MAX_MESSAGE_LENGTH_PREMIUM,
UserFlags,
} from '~/Constants';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
import {getAttachmentMaxSize} from '~/utils/AttachmentUtils';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
export type BackupCode = Readonly<{
code: string;
consumed: boolean;
}>;
export type UserProfile = Readonly<{
bio: string | null;
banner: string | null;
banner_color?: number | null;
pronouns: string | null;
accent_color: string | null;
}>;
export type UserPartial = Readonly<{
id: string;
username: string;
discriminator: string;
global_name?: string | null;
avatar: string | null;
avatar_color?: number | null;
bot?: boolean;
system?: boolean;
flags: number;
}>;
export type RequiredAction =
| 'REQUIRE_VERIFIED_EMAIL'
| 'REQUIRE_REVERIFIED_EMAIL'
| 'REQUIRE_VERIFIED_PHONE'
| 'REQUIRE_REVERIFIED_PHONE'
| 'REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE'
| 'REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE'
| 'REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE'
| 'REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE';
export type UserPrivate = Readonly<
UserPartial &
UserProfile & {
email: string | null;
mfa_enabled: boolean;
phone: string | null;
authenticator_types: Array<number>;
verified: boolean;
premium_type: number | null;
premium_since: string | null;
premium_until: string | null;
premium_will_cancel: boolean;
premium_billing_cycle: string | null;
premium_lifetime_sequence: number | null;
premium_badge_hidden: boolean;
premium_badge_masked: boolean;
premium_badge_timestamp_hidden: boolean;
premium_badge_sequence_hidden: boolean;
premium_purchase_disabled: boolean;
premium_enabled_override: boolean;
password_last_changed_at: string | null;
required_actions: Array<RequiredAction> | null;
nsfw_allowed: boolean;
pending_manual_verification: boolean;
pending_bulk_message_deletion: {
scheduled_at: string;
channel_count: number;
message_count: number;
} | null;
has_dismissed_premium_onboarding: boolean;
has_ever_purchased: boolean;
has_unread_gift_inventory: boolean;
unread_gift_inventory_count: number;
used_mobile_client: boolean;
}
>;
export type User = Readonly<UserPartial & Partial<UserPrivate>>;
export type PendingBulkMessageDeletion = Readonly<{
scheduledAt: Date;
channelCount: number;
messageCount: number;
}>;
export class UserRecord {
readonly id: string;
readonly username: string;
readonly discriminator: string;
readonly globalName: string | null;
readonly avatar: string | null;
readonly avatarColor?: number | null;
readonly bot: boolean;
readonly system: boolean;
readonly flags: number;
private readonly _email?: string | null;
readonly bio?: string | null;
readonly banner?: string | null;
readonly bannerColor?: number | null;
readonly pronouns?: string | null;
readonly accentColor?: string | null;
readonly mfaEnabled?: boolean;
readonly phone?: string | null;
readonly authenticatorTypes?: Array<number>;
private readonly _verified?: boolean;
private readonly _premiumType?: number | null;
private readonly _premiumSince?: Date | null;
private readonly _premiumUntil?: Date | null;
private readonly _premiumWillCancel?: boolean;
private readonly _premiumBillingCycle?: string | null;
readonly premiumLifetimeSequence?: number | null;
readonly premiumBadgeHidden?: boolean;
readonly premiumBadgeMasked?: boolean;
readonly premiumBadgeTimestampHidden?: boolean;
readonly premiumBadgeSequenceHidden?: boolean;
readonly premiumPurchaseDisabled?: boolean;
readonly premiumEnabledOverride?: boolean;
readonly passwordLastChangedAt?: Date | null;
readonly requiredActions?: Array<RequiredAction> | null;
readonly pendingManualVerification?: boolean;
readonly pendingBulkMessageDeletion: PendingBulkMessageDeletion | null;
private readonly _nsfwAllowed?: boolean;
readonly hasDismissedPremiumOnboarding?: boolean;
private readonly _hasEverPurchased?: boolean;
private readonly _hasUnreadGiftInventory?: boolean;
private readonly _unreadGiftInventoryCount?: number;
private readonly _usedMobileClient?: boolean;
constructor(user: User) {
this.id = user.id;
this.username = user.username;
this.discriminator = user.discriminator;
this.globalName = user.global_name ?? null;
this.avatar = user.avatar;
if ('avatar_color' in user) this.avatarColor = user.avatar_color;
this.bot = user.bot ?? false;
this.system = user.system ?? false;
this.flags = user.flags;
if ('email' in user) this._email = user.email;
if ('bio' in user) this.bio = user.bio;
if ('banner' in user) this.banner = user.banner;
if ('banner_color' in user) this.bannerColor = user.banner_color;
if ('pronouns' in user) this.pronouns = user.pronouns;
if ('accent_color' in user) this.accentColor = user.accent_color;
if ('mfa_enabled' in user) this.mfaEnabled = user.mfa_enabled;
if ('phone' in user) this.phone = user.phone;
if ('authenticator_types' in user) this.authenticatorTypes = user.authenticator_types;
if ('verified' in user) this._verified = user.verified;
if ('premium_type' in user) this._premiumType = user.premium_type;
if ('premium_since' in user) this._premiumSince = user.premium_since ? new Date(user.premium_since) : null;
if ('premium_until' in user) this._premiumUntil = user.premium_until ? new Date(user.premium_until) : null;
if ('premium_will_cancel' in user) this._premiumWillCancel = user.premium_will_cancel;
if ('premium_billing_cycle' in user) this._premiumBillingCycle = user.premium_billing_cycle;
if ('premium_lifetime_sequence' in user) this.premiumLifetimeSequence = user.premium_lifetime_sequence;
if ('premium_badge_hidden' in user) this.premiumBadgeHidden = user.premium_badge_hidden;
if ('premium_badge_masked' in user) this.premiumBadgeMasked = user.premium_badge_masked;
if ('premium_badge_timestamp_hidden' in user)
this.premiumBadgeTimestampHidden = user.premium_badge_timestamp_hidden;
if ('premium_badge_sequence_hidden' in user) this.premiumBadgeSequenceHidden = user.premium_badge_sequence_hidden;
if ('premium_purchase_disabled' in user) this.premiumPurchaseDisabled = user.premium_purchase_disabled;
if ('premium_enabled_override' in user) this.premiumEnabledOverride = user.premium_enabled_override;
if ('password_last_changed_at' in user)
this.passwordLastChangedAt = user.password_last_changed_at ? new Date(user.password_last_changed_at) : null;
if ('required_actions' in user) {
this.requiredActions = user.required_actions && user.required_actions.length > 0 ? user.required_actions : null;
}
if ('pending_manual_verification' in user) this.pendingManualVerification = user.pending_manual_verification;
if ('pending_bulk_message_deletion' in user && user.pending_bulk_message_deletion) {
this.pendingBulkMessageDeletion = {
scheduledAt: new Date(user.pending_bulk_message_deletion.scheduled_at),
channelCount: user.pending_bulk_message_deletion.channel_count,
messageCount: user.pending_bulk_message_deletion.message_count,
};
} else {
this.pendingBulkMessageDeletion = null;
}
if ('nsfw_allowed' in user) this._nsfwAllowed = user.nsfw_allowed;
if ('has_dismissed_premium_onboarding' in user)
this.hasDismissedPremiumOnboarding = user.has_dismissed_premium_onboarding;
if ('has_ever_purchased' in user) this._hasEverPurchased = user.has_ever_purchased;
if ('has_unread_gift_inventory' in user) this._hasUnreadGiftInventory = user.has_unread_gift_inventory;
if ('unread_gift_inventory_count' in user) this._unreadGiftInventoryCount = user.unread_gift_inventory_count;
if ('used_mobile_client' in user) this._usedMobileClient = user.used_mobile_client;
}
get email(): string | null | undefined {
if (DeveloperOptionsStore.unclaimedAccountOverride === true) {
return null;
}
return this._email;
}
get verified(): boolean | undefined {
const verifiedOverride = DeveloperOptionsStore.emailVerifiedOverride;
if (verifiedOverride != null) {
return verifiedOverride;
}
return this._verified;
}
get premiumType(): number | null {
const override = DeveloperOptionsStore.premiumTypeOverride;
return override != null ? override : (this._premiumType ?? null);
}
get premiumSince(): Date | null | undefined {
const override = DeveloperOptionsStore.premiumSinceOverride;
return override != null ? override : this._premiumSince;
}
get premiumUntil(): Date | null | undefined {
const override = DeveloperOptionsStore.premiumUntilOverride;
return override != null ? override : this._premiumUntil;
}
get premiumBillingCycle(): string | null | undefined {
const override = DeveloperOptionsStore.premiumBillingCycleOverride;
return override != null ? override : this._premiumBillingCycle;
}
get premiumWillCancel(): boolean | undefined {
const override = DeveloperOptionsStore.premiumWillCancelOverride;
return override != null ? override : this._premiumWillCancel;
}
getPendingBulkMessageDeletion(): PendingBulkMessageDeletion | null {
return this.pendingBulkMessageDeletion;
}
hasPendingBulkMessageDeletion(): boolean {
return this.pendingBulkMessageDeletion != null;
}
get hasEverPurchased(): boolean | undefined {
const override = DeveloperOptionsStore.hasEverPurchasedOverride;
return override != null ? override : this._hasEverPurchased;
}
get hasUnreadGiftInventory(): boolean | undefined {
const override = DeveloperOptionsStore.hasUnreadGiftInventoryOverride;
return override != null ? override : this._hasUnreadGiftInventory;
}
get unreadGiftInventoryCount(): number | undefined {
const override = DeveloperOptionsStore.unreadGiftInventoryCountOverride;
return override != null ? override : this._unreadGiftInventoryCount;
}
get nsfwAllowed(): boolean | undefined {
return this._nsfwAllowed;
}
get usedMobileClient(): boolean | undefined {
return this._usedMobileClient;
}
withUpdates(updates: Partial<User>): UserRecord {
const baseFields: UserPartial = {
id: updates.id ?? this.id,
username: updates.username ?? this.username,
discriminator: updates.discriminator ?? this.discriminator,
avatar: 'avatar' in updates ? (updates.avatar as string | null) : this.avatar,
bot: updates.bot ?? this.bot,
system: updates.system ?? this.system,
flags: updates.flags ?? this.flags,
};
const pendingBulkMessageDeletionValue =
'pending_bulk_message_deletion' in updates
? updates.pending_bulk_message_deletion
: this.pendingBulkMessageDeletion
? {
scheduled_at: this.pendingBulkMessageDeletion.scheduledAt.toISOString(),
channel_count: this.pendingBulkMessageDeletion.channelCount,
message_count: this.pendingBulkMessageDeletion.messageCount,
}
: null;
const privateFields: Partial<UserPrivate> = {
...(this._email !== undefined || updates.email !== undefined ? {email: updates.email ?? this._email} : {}),
...(this.bio !== undefined || 'bio' in updates
? {bio: 'bio' in updates && updates.bio !== undefined ? (updates.bio as string | null) : this.bio}
: {}),
...(this.avatarColor !== undefined || 'avatar_color' in updates
? {
avatar_color:
'avatar_color' in updates && updates.avatar_color !== undefined
? (updates.avatar_color as number | null)
: this.avatarColor,
}
: {}),
...(this.banner !== undefined || 'banner' in updates
? {
banner:
'banner' in updates && updates.banner !== undefined ? (updates.banner as string | null) : this.banner,
}
: {}),
...(this.bannerColor !== undefined || 'banner_color' in updates
? {
banner_color:
'banner_color' in updates && updates.banner_color !== undefined
? (updates.banner_color as number | null)
: this.bannerColor,
}
: {}),
...(this.globalName !== undefined || 'global_name' in updates
? {
global_name:
'global_name' in updates && updates.global_name !== undefined
? (updates.global_name as string | null)
: this.globalName,
}
: {}),
...(this.pronouns !== undefined || 'pronouns' in updates
? {
pronouns:
'pronouns' in updates && updates.pronouns !== undefined
? (updates.pronouns as string | null)
: this.pronouns,
}
: {}),
...(this.accentColor !== undefined || 'accent_color' in updates
? {
accent_color:
'accent_color' in updates && updates.accent_color !== undefined
? (updates.accent_color as string | null)
: this.accentColor,
}
: {}),
...(this.mfaEnabled !== undefined || updates.mfa_enabled !== undefined
? {mfa_enabled: updates.mfa_enabled ?? this.mfaEnabled}
: {}),
...(this.phone !== undefined || 'phone' in updates
? {phone: 'phone' in updates && updates.phone !== undefined ? (updates.phone as string | null) : this.phone}
: {}),
...(this.authenticatorTypes !== undefined || updates.authenticator_types !== undefined
? {authenticator_types: updates.authenticator_types ?? this.authenticatorTypes}
: {}),
...(this._verified !== undefined || updates.verified !== undefined
? {verified: updates.verified ?? this._verified}
: {}),
...(this._premiumType !== undefined || updates.premium_type !== undefined
? {premium_type: updates.premium_type ?? this._premiumType}
: {}),
...(this._premiumSince !== undefined || updates.premium_since !== undefined
? {premium_since: updates.premium_since ?? (this._premiumSince ? this._premiumSince.toISOString() : null)}
: {}),
...(this._premiumUntil !== undefined || updates.premium_until !== undefined
? {premium_until: updates.premium_until ?? (this._premiumUntil ? this._premiumUntil.toISOString() : null)}
: {}),
...(this._premiumWillCancel !== undefined || updates.premium_will_cancel !== undefined
? {premium_will_cancel: updates.premium_will_cancel ?? this._premiumWillCancel}
: {}),
...(this._premiumBillingCycle !== undefined || updates.premium_billing_cycle !== undefined
? {premium_billing_cycle: updates.premium_billing_cycle ?? this._premiumBillingCycle}
: {}),
...(this.premiumLifetimeSequence !== undefined || updates.premium_lifetime_sequence !== undefined
? {premium_lifetime_sequence: updates.premium_lifetime_sequence ?? this.premiumLifetimeSequence}
: {}),
...(this.premiumBadgeHidden !== undefined || updates.premium_badge_hidden !== undefined
? {premium_badge_hidden: updates.premium_badge_hidden ?? this.premiumBadgeHidden}
: {}),
...(this.premiumBadgeMasked !== undefined || updates.premium_badge_masked !== undefined
? {premium_badge_masked: updates.premium_badge_masked ?? this.premiumBadgeMasked}
: {}),
...(this.premiumBadgeTimestampHidden !== undefined || updates.premium_badge_timestamp_hidden !== undefined
? {premium_badge_timestamp_hidden: updates.premium_badge_timestamp_hidden ?? this.premiumBadgeTimestampHidden}
: {}),
...(this.premiumBadgeSequenceHidden !== undefined || updates.premium_badge_sequence_hidden !== undefined
? {premium_badge_sequence_hidden: updates.premium_badge_sequence_hidden ?? this.premiumBadgeSequenceHidden}
: {}),
...(this.premiumPurchaseDisabled !== undefined || updates.premium_purchase_disabled !== undefined
? {premium_purchase_disabled: updates.premium_purchase_disabled ?? this.premiumPurchaseDisabled}
: {}),
...(this.premiumEnabledOverride !== undefined || updates.premium_enabled_override !== undefined
? {premium_enabled_override: updates.premium_enabled_override ?? this.premiumEnabledOverride}
: {}),
...(this.passwordLastChangedAt !== undefined || updates.password_last_changed_at !== undefined
? {
password_last_changed_at:
updates.password_last_changed_at ?? this.passwordLastChangedAt?.toISOString() ?? null,
}
: {}),
...(this.pendingManualVerification !== undefined || updates.pending_manual_verification !== undefined
? {
pending_manual_verification: updates.pending_manual_verification ?? this.pendingManualVerification,
}
: {}),
pending_bulk_message_deletion: pendingBulkMessageDeletionValue,
...(this.requiredActions !== undefined || 'required_actions' in updates
? {
required_actions:
'required_actions' in updates
? updates.required_actions && (updates.required_actions as Array<RequiredAction>).length > 0
? (updates.required_actions as Array<RequiredAction>)
: null
: this.requiredActions,
}
: {}),
...(this._nsfwAllowed !== undefined || updates.nsfw_allowed !== undefined
? {nsfw_allowed: updates.nsfw_allowed ?? this._nsfwAllowed}
: {}),
...(this.hasDismissedPremiumOnboarding !== undefined || updates.has_dismissed_premium_onboarding !== undefined
? {
has_dismissed_premium_onboarding:
updates.has_dismissed_premium_onboarding ?? this.hasDismissedPremiumOnboarding,
}
: {}),
...(this._hasEverPurchased !== undefined || updates.has_ever_purchased !== undefined
? {has_ever_purchased: updates.has_ever_purchased ?? this._hasEverPurchased}
: {}),
...(this._hasUnreadGiftInventory !== undefined || updates.has_unread_gift_inventory !== undefined
? {has_unread_gift_inventory: updates.has_unread_gift_inventory ?? this._hasUnreadGiftInventory}
: {}),
...(this._unreadGiftInventoryCount !== undefined || updates.unread_gift_inventory_count !== undefined
? {unread_gift_inventory_count: updates.unread_gift_inventory_count ?? this._unreadGiftInventoryCount}
: {}),
...(this._usedMobileClient !== undefined || updates.used_mobile_client !== undefined
? {used_mobile_client: updates.used_mobile_client ?? this._usedMobileClient}
: {}),
};
return new UserRecord({
...baseFields,
...privateFields,
});
}
get displayName(): string {
return this.globalName || this.username;
}
get tag(): string {
return `${this.username}#${this.discriminator}`;
}
get createdAt(): Date {
return new Date(SnowflakeUtils.extractTimestamp(this.id));
}
isPremium(): boolean {
if (RuntimeConfigStore.isSelfHosted()) {
return true;
}
return this.premiumType != null && this.premiumType > 0;
}
get maxGuilds(): number {
return this.isPremium() ? MAX_GUILDS_PREMIUM : MAX_GUILDS_NON_PREMIUM;
}
get maxMessageLength(): number {
return this.isPremium() ? MAX_MESSAGE_LENGTH_PREMIUM : MAX_MESSAGE_LENGTH_NON_PREMIUM;
}
get maxAttachmentSize(): number {
return getAttachmentMaxSize(this.isPremium());
}
get maxBioLength(): number {
return this.isPremium() ? MAX_BIO_LENGTH_PREMIUM : MAX_BIO_LENGTH_NON_PREMIUM;
}
get maxBookmarks(): number {
return this.isPremium() ? MAX_BOOKMARKS_PREMIUM : MAX_BOOKMARKS_NON_PREMIUM;
}
get maxFavoriteMemes(): number {
return this.isPremium() ? MAX_FAVORITE_MEMES_PREMIUM : MAX_FAVORITE_MEMES_NON_PREMIUM;
}
isStaff(): boolean {
return (this.flags & UserFlags.STAFF) !== 0;
}
isClaimed(): boolean {
return !!this.email;
}
equals(other: UserRecord): boolean {
return (
this.id === other.id &&
this.username === other.username &&
this.discriminator === other.discriminator &&
this.avatar === other.avatar &&
this.avatarColor === other.avatarColor &&
this.bot === other.bot &&
this.system === other.system &&
this.flags === other.flags &&
this._email === other._email &&
this.bio === other.bio &&
this.banner === other.banner &&
this.bannerColor === other.bannerColor &&
this.pronouns === other.pronouns &&
this.mfaEnabled === other.mfaEnabled &&
this.phone === other.phone &&
JSON.stringify(this.authenticatorTypes) === JSON.stringify(other.authenticatorTypes) &&
this._verified === other._verified &&
this._premiumType === other._premiumType &&
this.premiumSince?.getTime() === other.premiumSince?.getTime() &&
this.premiumUntil?.getTime() === other.premiumUntil?.getTime() &&
this.premiumWillCancel === other.premiumWillCancel &&
this._premiumBillingCycle === other._premiumBillingCycle &&
this.premiumLifetimeSequence === other.premiumLifetimeSequence &&
this.premiumBadgeHidden === other.premiumBadgeHidden &&
this.premiumBadgeMasked === other.premiumBadgeMasked &&
this.premiumBadgeTimestampHidden === other.premiumBadgeTimestampHidden &&
this.premiumBadgeSequenceHidden === other.premiumBadgeSequenceHidden &&
this.premiumPurchaseDisabled === other.premiumPurchaseDisabled &&
this.premiumEnabledOverride === other.premiumEnabledOverride &&
this.passwordLastChangedAt?.getTime() === other.passwordLastChangedAt?.getTime() &&
JSON.stringify(this.requiredActions) === JSON.stringify(other.requiredActions) &&
this.pendingManualVerification === other.pendingManualVerification &&
((this.pendingBulkMessageDeletion === null && other.pendingBulkMessageDeletion === null) ||
(this.pendingBulkMessageDeletion != null &&
other.pendingBulkMessageDeletion != null &&
this.pendingBulkMessageDeletion.channelCount === other.pendingBulkMessageDeletion.channelCount &&
this.pendingBulkMessageDeletion.messageCount === other.pendingBulkMessageDeletion.messageCount &&
this.pendingBulkMessageDeletion.scheduledAt.getTime() ===
other.pendingBulkMessageDeletion.scheduledAt.getTime())) &&
this._nsfwAllowed === other._nsfwAllowed &&
this.hasDismissedPremiumOnboarding === other.hasDismissedPremiumOnboarding &&
this._hasUnreadGiftInventory === other._hasUnreadGiftInventory &&
this._unreadGiftInventoryCount === other._unreadGiftInventoryCount &&
this._usedMobileClient === other._usedMobileClient
);
}
toJSON(): User {
const normalizeDate = (value: Date | string | number | null | undefined): string | null => {
if (value === null || value === undefined) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
};
const baseFields: UserPartial = {
id: this.id,
username: this.username,
discriminator: this.discriminator,
global_name: this.globalName,
avatar: this.avatar,
bot: this.bot,
system: this.system,
flags: this.flags,
};
const privateFields: Partial<UserPrivate> = {
...(this._email !== undefined ? {email: this._email} : {}),
...(this.bio !== undefined ? {bio: this.bio} : {}),
...(this.banner !== undefined ? {banner: this.banner} : {}),
...(this.avatarColor !== undefined ? {avatar_color: this.avatarColor} : {}),
...(this.bannerColor !== undefined ? {banner_color: this.bannerColor} : {}),
...(this.pronouns !== undefined ? {pronouns: this.pronouns} : {}),
...(this.accentColor !== undefined ? {accent_color: this.accentColor} : {}),
...(this.mfaEnabled !== undefined ? {mfa_enabled: this.mfaEnabled} : {}),
...(this.phone !== undefined ? {phone: this.phone} : {}),
...(this.authenticatorTypes !== undefined ? {authenticator_types: this.authenticatorTypes} : {}),
...(this._verified !== undefined ? {verified: this._verified} : {}),
...(this._premiumType !== undefined ? {premium_type: this._premiumType} : {}),
...(this._premiumSince !== undefined ? {premium_since: normalizeDate(this._premiumSince)} : {}),
...(this._premiumUntil !== undefined ? {premium_until: normalizeDate(this._premiumUntil)} : {}),
...(this._premiumWillCancel !== undefined ? {premium_will_cancel: this._premiumWillCancel} : {}),
...(this._premiumBillingCycle !== undefined ? {premium_billing_cycle: this._premiumBillingCycle} : {}),
...(this.premiumLifetimeSequence !== undefined ? {premium_lifetime_sequence: this.premiumLifetimeSequence} : {}),
...(this.premiumBadgeHidden !== undefined ? {premium_badge_hidden: this.premiumBadgeHidden} : {}),
...(this.premiumBadgeMasked !== undefined ? {premium_badge_masked: this.premiumBadgeMasked} : {}),
...(this.premiumBadgeTimestampHidden !== undefined
? {premium_badge_timestamp_hidden: this.premiumBadgeTimestampHidden}
: {}),
...(this.premiumBadgeSequenceHidden !== undefined
? {premium_badge_sequence_hidden: this.premiumBadgeSequenceHidden}
: {}),
...(this.premiumPurchaseDisabled !== undefined ? {premium_purchase_disabled: this.premiumPurchaseDisabled} : {}),
...(this.premiumEnabledOverride !== undefined ? {premium_enabled_override: this.premiumEnabledOverride} : {}),
...(this.passwordLastChangedAt !== undefined
? {password_last_changed_at: normalizeDate(this.passwordLastChangedAt)}
: {}),
...(this.requiredActions !== undefined ? {required_actions: this.requiredActions} : {}),
...(this.pendingManualVerification !== undefined
? {pending_manual_verification: this.pendingManualVerification}
: {}),
...(this.pendingBulkMessageDeletion !== undefined
? {
pending_bulk_message_deletion: this.pendingBulkMessageDeletion
? {
scheduled_at: this.pendingBulkMessageDeletion.scheduledAt.toISOString(),
channel_count: this.pendingBulkMessageDeletion.channelCount,
message_count: this.pendingBulkMessageDeletion.messageCount,
}
: null,
}
: {}),
...(this._nsfwAllowed !== undefined ? {nsfw_allowed: this._nsfwAllowed} : {}),
...(this.hasDismissedPremiumOnboarding !== undefined
? {has_dismissed_premium_onboarding: this.hasDismissedPremiumOnboarding}
: {}),
...(this._hasUnreadGiftInventory !== undefined ? {has_unread_gift_inventory: this._hasUnreadGiftInventory} : {}),
...(this._unreadGiftInventoryCount !== undefined
? {unread_gift_inventory_count: this._unreadGiftInventoryCount}
: {}),
...(this._usedMobileClient !== undefined ? {used_mobile_client: this._usedMobileClient} : {}),
};
return {
...baseFields,
...privateFields,
};
}
}

View File

@@ -0,0 +1,95 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {UserPartial, UserRecord} from '~/records/UserRecord';
import UserStore from '~/stores/UserStore';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
import {webhookUrl} from '~/utils/UrlUtils';
export type Webhook = Readonly<{
id: string;
guild_id: string;
channel_id: string;
user: UserPartial;
name: string;
avatar: string | null;
token: string;
}>;
export class WebhookRecord {
readonly id: string;
readonly guildId: string;
readonly channelId: string;
readonly name: string;
readonly avatar: string | null;
readonly token: string;
readonly creatorId: string;
readonly createdAt: Date;
private readonly creatorSnapshot: UserPartial;
constructor(webhook: Webhook) {
this.id = webhook.id;
this.guildId = webhook.guild_id;
this.channelId = webhook.channel_id;
this.name = webhook.name;
this.avatar = webhook.avatar ?? null;
this.token = webhook.token;
this.creatorId = webhook.user.id;
this.createdAt = new Date(SnowflakeUtils.extractTimestamp(webhook.id));
this.creatorSnapshot = webhook.user;
UserStore.cacheUsers([webhook.user]);
}
get webhookUrl(): string {
return webhookUrl(this.id, this.token);
}
get creator(): UserRecord | null {
return UserStore.getUser(this.creatorId)!;
}
get displayName(): string {
return this.name;
}
withUpdates(updates: Partial<Webhook>): WebhookRecord {
return new WebhookRecord({
id: updates.id ?? this.id,
guild_id: updates.guild_id ?? this.guildId,
channel_id: updates.channel_id ?? this.channelId,
user: updates.user ?? this.creatorSnapshot,
name: updates.name ?? this.name,
avatar: updates.avatar ?? this.avatar,
token: updates.token ?? this.token,
});
}
toJSON(): Webhook {
const creator = this.creator;
return {
id: this.id,
guild_id: this.guildId,
channel_id: this.channelId,
user: creator ? creator.toJSON() : this.creatorSnapshot,
name: this.name,
avatar: this.avatar,
token: this.token,
};
}
}