/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {UserRecord} from '@app/records/UserRecord'; import AuthenticationStore from '@app/stores/AuthenticationStore'; import ChannelStore from '@app/stores/ChannelStore'; import GuildMemberStore from '@app/stores/GuildMemberStore'; import GuildStore from '@app/stores/GuildStore'; import RelationshipStore from '@app/stores/RelationshipStore'; import RuntimeConfigStore from '@app/stores/RuntimeConfigStore'; import UserStore from '@app/stores/UserStore'; import * as GiftCodeUtils from '@app/utils/GiftCodeUtils'; import * as InviteUtils from '@app/utils/InviteUtils'; import {emojiEquals} from '@app/utils/ReactionUtils'; import * as ThemeUtils from '@app/utils/ThemeUtils'; import {MessageFlags, MessageStates, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; import type {MessageEmbed} from '@fluxer/schema/src/domains/message/EmbedSchemas'; import type { AllowedMentions, ChannelMention, Message, MessageAttachment, MessageCall, MessageReaction, MessageReference, MessageSnapshot, MessageStickerItem, ReactionEmoji, } from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; interface TransformedMessageCall { participants: ReadonlyArray; 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): 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; instanceId?: string; } export class MessageRecord { readonly instanceId: string; readonly id: string; readonly channelId: string; readonly guildId?: string; readonly author: UserRecord; readonly webhookId?: 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; readonly mentionRoles: ReadonlyArray; readonly mentionChannels: ReadonlyArray; readonly embeds: ReadonlyArray; readonly attachments: ReadonlyArray; readonly stickerItems: ReadonlyArray; readonly reactions: ReadonlyArray; readonly messageReference?: MessageReference; readonly referencedMessage?: MessageRecord | null; readonly messageSnapshots?: ReadonlyArray; readonly call: TransformedMessageCall | null; readonly state: string; readonly nonce?: string; readonly blocked: boolean; readonly loggingName?: string; readonly invites: ReadonlyArray; readonly gifts: ReadonlyArray; readonly themes: ReadonlyArray; readonly _allowedMentions?: AllowedMentions; readonly _favoriteMemeId?: string; readonly stickers?: ReadonlyArray; constructor(message: Message, options?: MessageRecordOptions) { this.instanceId = options?.instanceId ?? RuntimeConfigStore.localInstanceDomain; const shouldCacheAuthor = !message.webhook_id; if (!options?.skipUserCache) { const authorsToCache = [...(shouldCacheAuthor ? [message.author] : []), ...(message.mentions ?? [])].filter( Boolean, ); if (authorsToCache.length > 0) { UserStore.cacheUsers(authorsToCache); } } const isBlocked = RelationshipStore.isBlocked(message.author.id); if (message.webhook_id) { this.author = new UserRecord(message.author, {instanceId: this.instanceId}); } else { this.author = UserStore.getUser(message.author.id) || new UserRecord(message.author, {instanceId: this.instanceId}); } this.id = message.id; this.channelId = message.channel_id; this.guildId = message.guild_id; this.webhookId = message.webhook_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 ); } isClientSystemMessage(): boolean { return 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): 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, 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, instanceId: this.instanceId}, ); } withReaction(emoji: ReactionEmoji, add = true, me = false): MessageRecord { const existingReaction = this.getReaction(emoji); if (!existingReaction && !add) { return this; } let newReactions: Array; 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.instanceId !== other.instanceId) return false; 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.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.animated !== s2.animated) { 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, 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; };