// SPDX-License-Identifier: AGPL-3.0-or-later import {type ChannelID, createChannelID, createGuildID, type MessageID, type UserID} from '@app/api/BrandedTypes'; import {mapChannelToResponse} from '@app/api/channel/ChannelMappers'; import type {MessageRequest, MessageUpdateRequest} from '@app/api/channel/MessageTypes'; import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; import type {MessageDispatchService} from '@app/api/channel/services/message/MessageDispatchService'; import {isPersonalNotesChannel} from '@app/api/channel/services/message/MessageHelpers'; import type {MessageMentionService} from '@app/api/channel/services/message/MessageMentionService'; import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService'; import {incrementDmMentionCounts} from '@app/api/channel/services/message/ReadStateHelpers'; import type {GatewayChannelMention, IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import {Logger} from '@app/api/Logger'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import {Channel} from '@app/api/models/Channel'; import type {Message} from '@app/api/models/Message'; import type {User} from '@app/api/models/User'; import type {ReadStateService} from '@app/api/read_state/ReadStateService'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {ChannelTypes, MessageFlags} from '@fluxer/constants/src/ChannelConstants'; import {CannotEditOtherUserMessageError} from '@fluxer/errors/src/domains/channel/CannotEditOtherUserMessageError'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import type {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas'; interface RecipientOpenState { recipientId: UserID; isOpen: boolean; } interface MentionProcessingResult { message: Message; mentionChannels: Array; } function channelWithLastMessageId(channel: Channel, messageId: MessageID): Channel { if (channel.lastMessageId != null && channel.lastMessageId >= messageId) { return channel; } return new Channel({...channel.toRow(), last_message_id: messageId}); } export class MessageProcessingService { constructor( private channelRepository: IChannelRepositoryAggregate, private userRepository: IUserRepository, private userCacheService: UserCacheService, private gatewayService: IGatewayService, private readStateService: ReadStateService, private mentionService: MessageMentionService, ) {} async processMessageAfterCreation(params: { message: Message; channel: Channel; guild: GuildResponse | null; user: User; data: MessageRequest; referencedMessage: Message | null; mentionHere?: boolean; }): Promise { const {message, guild, user, mentionHere = false} = params; await this.mentionService.handleMentionTasks({ guildId: guild ? createGuildID(BigInt(guild.id)) : null, message, authorId: user.id, mentionHere, }); } async updateDMRecipients({ channel, channelId, messageId, requestCache, }: { channel: Channel; channelId: ChannelID; messageId: MessageID; requestCache: RequestCache; }): Promise { if (channel.guildId || channel.type !== ChannelTypes.DM) return; if (channel.recipientIds?.size !== 2) return; const recipientIds = Array.from(channel.recipientIds); const openStates = await this.batchCheckDmChannelOpen(recipientIds, channelId); const closedRecipients = openStates.filter((state) => !state.isOpen); if (closedRecipients.length === 0) return; const snapshotChannel = channelWithLastMessageId(channel, messageId); await Promise.all( closedRecipients.map((state) => this.openDmAndDispatch({ recipientId: state.recipientId, channel: snapshotChannel, requestCache, }), ), ); } private async batchCheckDmChannelOpen( recipientIds: Array, channelId: ChannelID, ): Promise> { return Promise.all( recipientIds.map(async (recipientId) => ({ recipientId, isOpen: await this.userRepository.isDmChannelOpen(recipientId, channelId), })), ); } private async openDmAndDispatch(params: { recipientId: UserID; channel: Channel; requestCache: RequestCache; }): Promise { const {recipientId, channel, requestCache} = params; await this.userRepository.openPrivateChannelForUser(recipientId, channel); const channelResponse = await mapChannelToResponse({ channel, currentUserId: recipientId, userCacheService: this.userCacheService, requestCache, }); await this.gatewayService.dispatchPresence({ userId: recipientId, event: 'CHANNEL_CREATE', data: channelResponse, }); } async updateReadStates({ user, guild, channel, channelId, messageId, }: { user: User; guild: GuildResponse | null; channel: Channel; channelId: ChannelID; messageId: MessageID; }): Promise { if (!guild) { const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds)); await incrementDmMentionCounts({ readStateService: this.readStateService, userRepository: this.userRepository, user, recipients, channelId, messageId, }); } } async handleMentions(params: { channel: Channel; message: Message; referencedMessageOnSend: Message | null; allowedMentions: AllowedMentionsRequest | null; guild?: GuildResponse | null; canMentionEveryone: boolean; canMentionRoles: boolean; }): Promise { const {channel, message, referencedMessageOnSend, allowedMentions, guild, canMentionEveryone, canMentionRoles} = params; if (message.authorId != null && isPersonalNotesChannel({userId: message.authorId, channelId: channel.id})) { return {message, mentionChannels: []}; } const content = message.content ?? ''; const mentions = await this.mentionService.extractMentions({ content, referencedMessage: referencedMessageOnSend, message, channelType: channel.type, allowedMentions, guild, canMentionEveryone, }); const {validUserIds, validRoleIds, validChannelMentions} = await this.mentionService.validateMentions({ userMentions: mentions.userMentions, roleMentions: mentions.roleMentions, channelMentions: mentions.channelMentions, channel, message, guild, canMentionRoles, }); const updatedMessageData = { ...message.toRow(), flags: mentions.flags, mention_users: validUserIds.length > 0 ? new Set(validUserIds) : null, mention_roles: validRoleIds.length > 0 ? new Set(validRoleIds) : null, mention_channels: validChannelMentions.length > 0 ? new Set(validChannelMentions.map((mentionedChannel) => createChannelID(BigInt(mentionedChannel.id)))) : null, mention_everyone: mentions.mentionsEveryone, }; const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow()); return {message: updatedMessage, mentionChannels: validChannelMentions}; } async repairMentionsOnRead(message: Message, sourceChannel?: Channel): Promise { if ((message.flags & MessageFlags.IS_CROSSPOST) !== 0) { return message; } if ( message.mentionedUserIds.size === 0 && message.mentionedRoleIds.size === 0 && message.mentionedChannelIds.size === 0 && !message.mentionEveryone ) { const hasChannelMentionSyntax = (message.content ?? '').includes('<#'); if (!hasChannelMentionSyntax) { return message; } } const referencedMessage = message.reference?.messageId && message.mentionedUserIds.size > 0 ? await this.channelRepository.messages.getMessage(message.reference.channelId, message.reference.messageId) : null; const repair = await this.mentionService.buildReadRepairMentionData({message, referencedMessage}); if (!repair.changed) { return message; } const resolvedSourceChannel = sourceChannel ?? (repair.channelMentions.size > 0 || message.mentionedChannelIds.size > 0 ? await this.channelRepository.channelData.findUnique(message.channelId) : null); const validChannelMentions = resolvedSourceChannel ? await this.mentionService.validateChannelMentions({ channelMentions: repair.channelMentions, channel: resolvedSourceChannel, }) : []; const updatedMessageData = { ...message.toRow(), mention_users: repair.userMentions.size > 0 ? repair.userMentions : null, mention_roles: repair.roleMentions.size > 0 ? repair.roleMentions : null, mention_channels: validChannelMentions.length > 0 ? new Set(validChannelMentions.map((mentionedChannel) => createChannelID(BigInt(mentionedChannel.id)))) : null, mention_everyone: repair.mentionsEveryone, }; try { return await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow()); } catch (error) { Logger.warn( { error, channelId: message.channelId.toString(), messageId: message.id.toString(), }, 'Failed to repair message mentions during read', ); return message; } } async handleNonAuthorEdit(params: { message: Message; messageId: MessageID; data: MessageUpdateRequest; guild: GuildResponse | null; hasPermission: (permission: bigint) => Promise; channel: Channel; requestCache: RequestCache; persistenceService: MessagePersistenceService; dispatchService: MessageDispatchService; }): Promise { const {message, data, guild, hasPermission, channel, requestCache, persistenceService, dispatchService} = params; const editResult = await persistenceService.handleNonAuthorEdit({ message, data, guild, hasPermission, }); if (editResult.canEdit && (editResult.updatedFlags !== undefined || editResult.updatedAttachments !== undefined)) { const updatedRowData = {...message.toRow()}; if (editResult.updatedFlags !== undefined) { updatedRowData.flags = editResult.updatedFlags; } if (editResult.updatedAttachments !== undefined) { updatedRowData.attachments = editResult.updatedAttachments; } const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedRowData, message.toRow()); await dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache}); return updatedMessage; } throw new CannotEditOtherUserMessageError(); } }