fluxer/packages/api/src/user/services/UserChannelService.tsx
2026-02-17 12:22:36 +00:00

548 lines
18 KiB
TypeScript

/*
* 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 {ChannelID, UserID} from '@fluxer/api/src/BrandedTypes';
import {createChannelID, createMessageID, createUserID} from '@fluxer/api/src/BrandedTypes';
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
import {dispatchMessageCreate} from '@fluxer/api/src/channel/services/group_dm/GroupDmHelpers';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {Channel} from '@fluxer/api/src/models/Channel';
import type {Message} from '@fluxer/api/src/models/Message';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import type {UserPermissionUtils} from '@fluxer/api/src/utils/UserPermissionUtils';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {MAX_GROUP_DM_RECIPIENTS, MAX_GROUP_DMS_PER_USER} from '@fluxer/constants/src/LimitConstants';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {CannotSendMessagesToUserError} from '@fluxer/errors/src/domains/channel/CannotSendMessagesToUserError';
import {MaxGroupDmRecipientsError} from '@fluxer/errors/src/domains/channel/MaxGroupDmRecipientsError';
import {MaxGroupDmsError} from '@fluxer/errors/src/domains/channel/MaxGroupDmsError';
import {UnclaimedAccountCannotSendDirectMessagesError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotSendDirectMessagesError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {NotFriendsWithUserError} from '@fluxer/errors/src/domains/user/NotFriendsWithUserError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
export class UserChannelService {
constructor(
private userAccountRepository: IUserAccountRepository,
private userChannelRepository: IUserChannelRepository,
private userRelationshipRepository: IUserRelationshipRepository,
private channelService: ChannelService,
private channelRepository: IChannelRepository,
private gatewayService: IGatewayService,
private mediaService: IMediaService,
private snowflakeService: SnowflakeService,
private userPermissionUtils: UserPermissionUtils,
private readonly limitConfigService: LimitConfigService,
) {}
async getPrivateChannels(userId: UserID): Promise<Array<Channel>> {
return await this.userChannelRepository.listPrivateChannels(userId);
}
async createOrOpenDMChannel({
userId,
data,
userCacheService,
requestCache,
}: {
userId: UserID;
data: CreatePrivateChannelRequest;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
if (data.recipients !== undefined) {
return await this.createGroupDMChannel({
userId,
recipients: data.recipients,
userCacheService,
requestCache,
});
}
if (!data.recipient_id) {
throw InputValidationError.fromCode('recipient_id', ValidationErrorCodes.RECIPIENT_IDS_CANNOT_BE_EMPTY);
}
const recipientId = createUserID(data.recipient_id);
if (userId === recipientId) {
throw InputValidationError.fromCode('recipient_id', ValidationErrorCodes.CANNOT_DM_YOURSELF);
}
const targetUser = await this.userAccountRepository.findUnique(recipientId);
if (!targetUser) throw new UnknownUserError();
const existingChannel = await this.userChannelRepository.findExistingDmState(userId, recipientId);
if (existingChannel) {
return await this.reopenExistingDMChannel({userId, existingChannel, userCacheService, requestCache});
}
await this.validateDmPermission(userId, recipientId, targetUser);
const channel = await this.createNewDMChannel({userId, recipientId, userCacheService, requestCache});
return channel;
}
async pinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
const channel = await this.channelService.getChannel({userId, channelId});
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.CHANNEL_MUST_BE_DM_OR_GROUP_DM);
}
if (!channel.recipientIds.has(userId)) {
throw new MissingAccessError();
}
const newPinnedDMs = await this.userChannelRepository.addPinnedDm(userId, channelId);
await this.gatewayService.dispatchPresence({
userId: userId,
event: 'USER_PINNED_DMS_UPDATE',
data: newPinnedDMs.map(String),
});
}
async unpinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
const channel = await this.channelService.getChannel({userId, channelId});
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.CHANNEL_MUST_BE_DM_OR_GROUP_DM);
}
if (!channel.recipientIds.has(userId)) {
throw new MissingAccessError();
}
const newPinnedDMs = await this.userChannelRepository.removePinnedDm(userId, channelId);
await this.gatewayService.dispatchPresence({
userId: userId,
event: 'USER_PINNED_DMS_UPDATE',
data: newPinnedDMs.map(String),
});
}
async preloadDMMessages(params: {
userId: UserID;
channelIds: Array<ChannelID>;
}): Promise<Record<string, Message | null>> {
const {userId, channelIds} = params;
if (channelIds.length > 100) {
throw InputValidationError.fromCode('channels', ValidationErrorCodes.CANNOT_PRELOAD_MORE_THAN_100_CHANNELS);
}
const results: Record<string, Message | null> = {};
const fetchPromises = channelIds.map(async (channelId) => {
try {
const channel = await this.channelService.getChannel({userId, channelId});
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
return;
}
if (!channel.recipientIds.has(userId)) {
return;
}
const messages = await this.channelService.getMessages({
userId,
channelId,
limit: 1,
before: undefined,
after: undefined,
around: undefined,
});
results[channelId.toString()] = messages[0] ?? null;
} catch {
results[channelId.toString()] = null;
}
});
await Promise.all(fetchPromises);
return results;
}
async getExistingDmForUsers(userId: UserID, recipientId: UserID): Promise<Channel | null> {
return await this.userChannelRepository.findExistingDmState(userId, recipientId);
}
async ensureDmOpenForBothUsers({
userId,
recipientId,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const existingChannel = await this.userChannelRepository.findExistingDmState(userId, recipientId);
if (existingChannel) {
const [isUserOpen, isRecipientOpen] = await Promise.all([
this.userChannelRepository.isDmChannelOpen(userId, existingChannel.id),
this.userChannelRepository.isDmChannelOpen(recipientId, existingChannel.id),
]);
if (!isUserOpen) {
await this.userChannelRepository.openDmForUser(userId, existingChannel.id);
await this.dispatchChannelCreate({userId, channel: existingChannel, userCacheService, requestCache});
}
if (!isRecipientOpen) {
await this.userChannelRepository.openDmForUser(recipientId, existingChannel.id);
await this.dispatchChannelCreate({
userId: recipientId,
channel: existingChannel,
userCacheService,
requestCache,
});
}
return existingChannel;
}
return await this.createNewDmForBothUsers({userId, recipientId, userCacheService, requestCache});
}
async reopenDmForBothUsers({
userId,
recipientId,
existingChannel,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
existingChannel: Channel;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
await this.reopenExistingDMChannel({userId, existingChannel, userCacheService, requestCache});
await this.reopenExistingDMChannel({
userId: recipientId,
existingChannel,
userCacheService,
requestCache,
});
}
async createNewDmForBothUsers({
userId,
recipientId,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const newChannel = await this.createNewDMChannel({
userId,
recipientId,
userCacheService,
requestCache,
});
await this.userChannelRepository.openDmForUser(recipientId, newChannel.id);
await this.dispatchChannelCreate({userId: recipientId, channel: newChannel, userCacheService, requestCache});
return newChannel;
}
private async reopenExistingDMChannel({
userId,
existingChannel,
userCacheService,
requestCache,
}: {
userId: UserID;
existingChannel: Channel;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
await this.userChannelRepository.openDmForUser(userId, existingChannel.id);
await this.dispatchChannelCreate({userId, channel: existingChannel, userCacheService, requestCache});
return existingChannel;
}
private async createNewDMChannel({
userId,
recipientId,
userCacheService,
requestCache,
}: {
userId: UserID;
recipientId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const channelId = createChannelID(await this.snowflakeService.generate());
const newChannel = await this.userChannelRepository.createDmChannelAndState(userId, recipientId, channelId);
await this.userChannelRepository.openDmForUser(userId, channelId);
await this.dispatchChannelCreate({userId, channel: newChannel, userCacheService, requestCache});
return newChannel;
}
private async createGroupDMChannel({
userId,
recipients,
userCacheService,
requestCache,
}: {
userId: UserID;
recipients: Array<bigint>;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Channel> {
const fallbackRecipientLimit = MAX_GROUP_DM_RECIPIENTS;
const recipientLimit = this.resolveLimitForUser(
await this.userAccountRepository.findUnique(userId),
'max_group_dm_recipients',
fallbackRecipientLimit,
);
if (recipients.length > recipientLimit) {
throw new MaxGroupDmRecipientsError(recipientLimit);
}
const recipientIds = recipients.map(createUserID);
const uniqueRecipientIds = new Set(recipientIds);
if (uniqueRecipientIds.size !== recipientIds.length) {
throw InputValidationError.fromCode('recipients', ValidationErrorCodes.DUPLICATE_RECIPIENTS_NOT_ALLOWED);
}
if (uniqueRecipientIds.has(userId)) {
throw InputValidationError.fromCode('recipients', ValidationErrorCodes.CANNOT_ADD_YOURSELF_TO_GROUP_DM);
}
const usersToCheck = new Set<UserID>([userId, ...recipientIds]);
await this.ensureUsersWithinGroupDmLimit(usersToCheck);
for (const recipientId of recipientIds) {
const targetUser = await this.userAccountRepository.findUnique(recipientId);
if (!targetUser) {
throw new UnknownUserError();
}
const friendship = await this.userRelationshipRepository.getRelationship(
userId,
recipientId,
RelationshipTypes.FRIEND,
);
if (!friendship) {
throw new NotFriendsWithUserError();
}
await this.userPermissionUtils.validateGroupDmAddPermissions({userId, targetId: recipientId});
}
const channelId = createChannelID(await this.snowflakeService.generate());
const allRecipients = new Set([userId, ...recipientIds]);
const channelData = {
channel_id: channelId,
guild_id: null,
type: ChannelTypes.GROUP_DM,
name: null,
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: 0,
owner_id: userId,
recipient_ids: allRecipients,
nsfw: false,
rate_limit_per_user: 0,
bitrate: null,
user_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
};
const newChannel = await this.channelRepository.upsert(channelData);
for (const recipientId of allRecipients) {
await this.userChannelRepository.openDmForUser(recipientId, channelId);
}
const systemMessages: Array<Message> = [];
for (const recipientId of recipientIds) {
const messageId = createMessageID(await this.snowflakeService.generate());
const message = await this.channelRepository.upsertMessage({
channel_id: channelId,
bucket: BucketUtils.makeBucket(messageId),
message_id: messageId,
author_id: userId,
type: MessageTypes.RECIPIENT_ADD,
webhook_id: null,
webhook_name: null,
webhook_avatar_hash: null,
content: null,
edited_timestamp: null,
pinned_timestamp: null,
flags: 0,
mention_everyone: false,
mention_users: new Set([recipientId]),
mention_roles: null,
mention_channels: null,
attachments: null,
embeds: null,
sticker_items: null,
message_reference: null,
message_snapshots: null,
call: null,
has_reaction: false,
version: 1,
});
systemMessages.push(message);
}
for (const recipientId of allRecipients) {
await this.dispatchChannelCreate({userId: recipientId, channel: newChannel, userCacheService, requestCache});
}
for (const message of systemMessages) {
await this.dispatchSystemMessage({
channel: newChannel,
message,
userCacheService,
requestCache,
});
}
return newChannel;
}
private async dispatchSystemMessage({
channel,
message,
userCacheService,
requestCache,
}: {
channel: Channel;
message: Message;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
await dispatchMessageCreate({
channel,
message,
requestCache,
userCacheService,
gatewayService: this.gatewayService,
mediaService: this.mediaService,
getReferencedMessage: (channelId, messageId) => this.channelRepository.getMessage(channelId, messageId),
});
}
private async dispatchChannelCreate({
userId,
channel,
userCacheService,
requestCache,
}: {
userId: UserID;
channel: Channel;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
const channelResponse = await mapChannelToResponse({
channel,
currentUserId: userId,
userCacheService,
requestCache,
});
await this.gatewayService.dispatchPresence({
userId,
event: 'CHANNEL_CREATE',
data: channelResponse,
});
}
private async ensureUsersWithinGroupDmLimit(userIds: Iterable<UserID>): Promise<void> {
for (const userId of userIds) {
await this.ensureUserWithinGroupDmLimit(userId);
}
}
private async ensureUserWithinGroupDmLimit(userId: UserID): Promise<void> {
const summaries = await this.userChannelRepository.listPrivateChannelSummaries(userId);
const openGroupDms = summaries.filter((summary) => summary.open && summary.isGroupDm).length;
const user = await this.userAccountRepository.findUnique(userId);
const fallbackLimit = MAX_GROUP_DMS_PER_USER;
const limit = this.resolveLimitForUser(user ?? null, 'max_group_dms_per_user', fallbackLimit);
if (openGroupDms >= limit) {
throw new MaxGroupDmsError(limit);
}
}
private async validateDmPermission(userId: UserID, recipientId: UserID, _recipientUser?: User | null): Promise<void> {
const senderUser = await this.userAccountRepository.findUnique(userId);
if (senderUser?.isUnclaimedAccount()) {
throw new UnclaimedAccountCannotSendDirectMessagesError();
}
const userBlockedRecipient = await this.userRelationshipRepository.getRelationship(
userId,
recipientId,
RelationshipTypes.BLOCKED,
);
if (userBlockedRecipient) {
throw new CannotSendMessagesToUserError();
}
const recipientBlockedUser = await this.userRelationshipRepository.getRelationship(
recipientId,
userId,
RelationshipTypes.BLOCKED,
);
if (recipientBlockedUser) {
throw new CannotSendMessagesToUserError();
}
const friendship = await this.userRelationshipRepository.getRelationship(
userId,
recipientId,
RelationshipTypes.FRIEND,
);
if (friendship) return;
const hasMutualGuilds = await this.userPermissionUtils.checkMutualGuildsAsync({
userId,
targetId: recipientId,
});
if (hasMutualGuilds) return;
throw new CannotSendMessagesToUserError();
}
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
const ctx = createLimitMatchContext({user});
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
}
}