548 lines
18 KiB
TypeScript
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);
|
|
}
|
|
}
|