mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
813 lines
30 KiB
TypeScript
813 lines
30 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import type {ApiContext} from '@app/api/ApiContext';
|
|
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
|
|
import type {UserID} from '@app/api/BrandedTypes';
|
|
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
|
|
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
|
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
|
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
|
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
|
|
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
|
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
|
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
|
import type {Relationship} from '@app/api/models/Relationship';
|
|
import type {User} from '@app/api/models/User';
|
|
import {assertMayStartConversation} from '@app/api/user/NewConversationLimit';
|
|
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
|
import {getCachedUserPartialResponse} from '@app/api/user/UserCacheHelpers';
|
|
import {isDirectDeliverySuppressed} from '@app/api/user/UserHelpers';
|
|
import {mapRelationshipToResponse} from '@app/api/user/UserMappers';
|
|
import type {UserPermissionUtils} from '@app/api/utils/UserPermissionUtils';
|
|
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
|
import {MAX_RELATIONSHIPS} from '@fluxer/constants/src/LimitConstants';
|
|
import {RelationshipTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
|
|
import {DirectMessagesDisabledError} from '@fluxer/errors/src/domains/channel/DirectMessagesDisabledError';
|
|
import {BotsCannotSendFriendRequestsError} from '@fluxer/errors/src/domains/oauth/BotsCannotSendFriendRequestsError';
|
|
import {AlreadyFriendsError} from '@fluxer/errors/src/domains/user/AlreadyFriendsError';
|
|
import {CannotBlockSystemUserError} from '@fluxer/errors/src/domains/user/CannotBlockSystemUserError';
|
|
import {CannotSendFriendRequestToBlockedUserError} from '@fluxer/errors/src/domains/user/CannotSendFriendRequestToBlockedUserError';
|
|
import {CannotSendFriendRequestToSelfError} from '@fluxer/errors/src/domains/user/CannotSendFriendRequestToSelfError';
|
|
import {FriendRequestBlockedError} from '@fluxer/errors/src/domains/user/FriendRequestBlockedError';
|
|
import {InvalidDiscriminatorError} from '@fluxer/errors/src/domains/user/InvalidDiscriminatorError';
|
|
import {MaxRelationshipsError} from '@fluxer/errors/src/domains/user/MaxRelationshipsError';
|
|
import {NoUsersWithFluxertagError} from '@fluxer/errors/src/domains/user/NoUsersWithFluxertagError';
|
|
import {UnclaimedAccountCannotAcceptFriendRequestsError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotAcceptFriendRequestsError';
|
|
import {UnclaimedAccountCannotSendFriendRequestsError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotSendFriendRequestsError';
|
|
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
|
import type {
|
|
BulkIgnoreFriendRequestsRequest,
|
|
FriendRequestByTagRequest,
|
|
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
|
import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
|
|
|
|
function emitUserBlocked(userId: UserID, targetId: UserID): void {
|
|
void emitActivity('user_blocked', targetId.toString(), {
|
|
blocker_id: userId.toString(),
|
|
blocked_id: targetId.toString(),
|
|
});
|
|
}
|
|
|
|
function emitFriendRequest(userId: UserID, targetId: UserID, delivered: boolean): void {
|
|
void emitActivity('friend_request', userId.toString(), {
|
|
user_id: userId.toString(),
|
|
target_id: targetId.toString(),
|
|
delivered,
|
|
});
|
|
}
|
|
|
|
export class UserRelationshipService {
|
|
private readonly userRepository: UserRepository;
|
|
private readonly gatewayService: IGatewayService;
|
|
|
|
constructor(
|
|
apiContext: ApiContext,
|
|
private userPermissionUtils: UserPermissionUtils,
|
|
private readonly limitConfigService: LimitConfigService,
|
|
) {
|
|
const {users, gateway} = apiContext.services;
|
|
this.userRepository = users;
|
|
this.gatewayService = gateway;
|
|
}
|
|
|
|
async getRelationship(params: {userId: UserID; targetId: UserID; type: number}): Promise<Relationship | null> {
|
|
return await this.userRepository.getRelationship(params.userId, params.targetId, params.type);
|
|
}
|
|
|
|
async getRelationships(userId: UserID): Promise<Array<Relationship>> {
|
|
return await this.userRepository.listRelationships(userId);
|
|
}
|
|
|
|
async sendFriendRequestByTag({
|
|
userId,
|
|
data,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
userId: UserID;
|
|
data: FriendRequestByTagRequest;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<Relationship> {
|
|
const {username, discriminator} = data;
|
|
const discrimValue = discriminator;
|
|
if (!Number.isInteger(discrimValue) || discrimValue < 0 || discrimValue > 9999) {
|
|
throw new InvalidDiscriminatorError();
|
|
}
|
|
const targetUser = await this.userRepository.findByUsernameDiscriminator(username, discrimValue);
|
|
if (!targetUser) {
|
|
throw new NoUsersWithFluxertagError();
|
|
}
|
|
if (this.isDeletedUser(targetUser)) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
const existingRelationship = await this.userRepository.getRelationship(
|
|
userId,
|
|
targetUser.id,
|
|
RelationshipTypes.FRIEND,
|
|
);
|
|
if (existingRelationship) {
|
|
throw new AlreadyFriendsError();
|
|
}
|
|
return this.sendFriendRequest({userId, targetId: targetUser.id, userCacheService, requestCache});
|
|
}
|
|
|
|
async sendFriendRequest({
|
|
userId,
|
|
targetId,
|
|
staffForceAccept,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
userId: UserID;
|
|
targetId: UserID;
|
|
staffForceAccept?: boolean;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<Relationship> {
|
|
const requesterUser = await this.userRepository.findUnique(userId);
|
|
const requesterIsStaff = requesterUser != null && (requesterUser.flags & UserFlags.STAFF) === UserFlags.STAFF;
|
|
if (!requesterIsStaff && (await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled) {
|
|
throw new DirectMessagesDisabledError();
|
|
}
|
|
if (staffForceAccept && requesterIsStaff) {
|
|
return await this.forceCreateFriendship({userId, targetId, userCacheService, requestCache});
|
|
}
|
|
if (!requesterUser) {
|
|
throw new UnknownUserError();
|
|
}
|
|
if (isDirectDeliverySuppressed(requesterUser)) {
|
|
emitFriendRequest(userId, targetId, false);
|
|
return await this.createShadowFriendRequest({
|
|
requesterUser,
|
|
userId,
|
|
targetId,
|
|
userCacheService,
|
|
requestCache,
|
|
});
|
|
}
|
|
if (userId === targetId) {
|
|
throw new CannotSendFriendRequestToSelfError();
|
|
}
|
|
const targetUserForAttempt = await this.userRepository.findUnique(targetId);
|
|
if (!targetUserForAttempt) {
|
|
throw new UnknownUserError();
|
|
}
|
|
if (this.isDeletedUser(targetUserForAttempt)) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
const pendingIncoming = await this.userRepository.getRelationship(
|
|
targetId,
|
|
userId,
|
|
RelationshipTypes.OUTGOING_REQUEST,
|
|
);
|
|
if (pendingIncoming) {
|
|
return this.acceptFriendRequest({userId, targetId, userCacheService, requestCache});
|
|
}
|
|
const existingFriendship = await this.userRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND);
|
|
const existingOutgoingRequest = await this.userRepository.getRelationship(
|
|
userId,
|
|
targetId,
|
|
RelationshipTypes.OUTGOING_REQUEST,
|
|
);
|
|
if (existingFriendship || existingOutgoingRequest) {
|
|
const relationships = await this.userRepository.listRelationships(userId);
|
|
const relationship = relationships.find((r) => r.targetUserId === targetId);
|
|
if (relationship) {
|
|
return relationship;
|
|
}
|
|
}
|
|
const targetUser = await this.validateFriendRequest({userId, targetId});
|
|
await assertMayStartConversation({user: requesterUser, targetId, users: this.userRepository});
|
|
await this.validateRelationshipCounts({userId, targetId});
|
|
const requestRelationship = await this.createFriendRequest({userId, targetId, userCacheService, requestCache});
|
|
emitFriendRequest(userId, targetId, true);
|
|
const targetIsFriendlyBot =
|
|
targetUser.isBot && (targetUser.flags & UserFlags.FRIENDLY_BOT) === UserFlags.FRIENDLY_BOT;
|
|
const manualApprovalFlag = UserFlags.FRIENDLY_BOT_MANUAL_APPROVAL;
|
|
const manualApprovalRequired = targetUser.isBot && (targetUser.flags & manualApprovalFlag) === manualApprovalFlag;
|
|
if (targetIsFriendlyBot && !manualApprovalRequired) {
|
|
const finalFriendship = await this.acceptFriendRequest({
|
|
userId: targetId,
|
|
targetId: userId,
|
|
userCacheService,
|
|
requestCache,
|
|
});
|
|
return finalFriendship;
|
|
}
|
|
return requestRelationship;
|
|
}
|
|
|
|
async acceptFriendRequest({
|
|
userId,
|
|
targetId,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
userId: UserID;
|
|
targetId: UserID;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<Relationship> {
|
|
if ((await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled) {
|
|
throw new DirectMessagesDisabledError();
|
|
}
|
|
const user = await this.userRepository.findUnique(userId);
|
|
if (!user) {
|
|
throw new UnknownUserError();
|
|
}
|
|
if (this.isDeletedUser(user)) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
if (user?.isUnclaimedAccount()) {
|
|
throw new UnclaimedAccountCannotAcceptFriendRequestsError();
|
|
}
|
|
const requesterUser = await this.userRepository.findUnique(targetId);
|
|
if (!requesterUser) {
|
|
throw new UnknownUserError();
|
|
}
|
|
if (this.isDeletedUser(requesterUser)) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
const incomingRequest = await this.userRepository.getRelationship(
|
|
userId,
|
|
targetId,
|
|
RelationshipTypes.INCOMING_REQUEST,
|
|
);
|
|
if (!incomingRequest) {
|
|
throw new UnknownUserError();
|
|
}
|
|
await this.validateRelationshipCounts({userId, targetId});
|
|
await this.userRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
|
|
await this.userRepository.deleteRelationship(targetId, userId, RelationshipTypes.OUTGOING_REQUEST);
|
|
const now = new Date();
|
|
const [userDefault, targetDefault] = await Promise.all([
|
|
this.userRepository.findSettings(userId).then((s) => s?.defaultShareVoiceActivity ?? true),
|
|
this.userRepository.findSettings(targetId).then((s) => s?.defaultShareVoiceActivity ?? true),
|
|
]);
|
|
const userRelationship = await this.userRepository.upsertRelationship({
|
|
source_user_id: userId,
|
|
target_user_id: targetId,
|
|
type: RelationshipTypes.FRIEND,
|
|
nickname: null,
|
|
since: now,
|
|
share_voice_activity: userDefault,
|
|
version: 1,
|
|
});
|
|
const targetRelationship = await this.userRepository.upsertRelationship({
|
|
source_user_id: targetId,
|
|
target_user_id: userId,
|
|
type: RelationshipTypes.FRIEND,
|
|
nickname: null,
|
|
since: now,
|
|
share_voice_activity: targetDefault,
|
|
version: 1,
|
|
});
|
|
await this.dispatchRelationshipUpdate({
|
|
userId,
|
|
relationship: userRelationship,
|
|
userCacheService,
|
|
requestCache,
|
|
});
|
|
await this.dispatchRelationshipUpdate({
|
|
userId: targetId,
|
|
relationship: targetRelationship,
|
|
userCacheService,
|
|
requestCache,
|
|
});
|
|
return userRelationship;
|
|
}
|
|
|
|
async blockUser({
|
|
userId,
|
|
targetId,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
userId: UserID;
|
|
targetId: UserID;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<Relationship> {
|
|
const targetUser = await this.userRepository.findUnique(targetId);
|
|
if (!targetUser) {
|
|
throw new UnknownUserError();
|
|
}
|
|
if (targetUser.isSystem) {
|
|
throw new CannotBlockSystemUserError();
|
|
}
|
|
const existingBlocked = await this.userRepository.getRelationship(userId, targetId, RelationshipTypes.BLOCKED);
|
|
if (existingBlocked) {
|
|
return existingBlocked;
|
|
}
|
|
const existingFriend = await this.userRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND);
|
|
const existingIncomingRequest = await this.userRepository.getRelationship(
|
|
userId,
|
|
targetId,
|
|
RelationshipTypes.INCOMING_REQUEST,
|
|
);
|
|
const existingOutgoingRequest = await this.userRepository.getRelationship(
|
|
userId,
|
|
targetId,
|
|
RelationshipTypes.OUTGOING_REQUEST,
|
|
);
|
|
if (existingFriend) {
|
|
await this.userRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
|
|
await this.userRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
|
|
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
|
} else if (existingOutgoingRequest) {
|
|
await this.userRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
|
|
await this.userRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
|
|
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
|
} else if (existingIncomingRequest) {
|
|
await this.userRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
|
|
}
|
|
const now = new Date();
|
|
const blockRelationship = await this.userRepository.upsertRelationship({
|
|
source_user_id: userId,
|
|
target_user_id: targetId,
|
|
type: RelationshipTypes.BLOCKED,
|
|
nickname: null,
|
|
since: now,
|
|
share_voice_activity: true,
|
|
version: 1,
|
|
});
|
|
await this.dispatchRelationshipCreate({
|
|
userId,
|
|
relationship: blockRelationship,
|
|
userCacheService,
|
|
requestCache,
|
|
});
|
|
emitUserBlocked(userId, targetId);
|
|
return blockRelationship;
|
|
}
|
|
|
|
async removeRelationship({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
|
const [friend, incoming, outgoing, blocked] = await Promise.all([
|
|
this.userRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND),
|
|
this.userRepository.getRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST),
|
|
this.userRepository.getRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST),
|
|
this.userRepository.getRelationship(userId, targetId, RelationshipTypes.BLOCKED),
|
|
]);
|
|
const existingRelationship = friend || incoming || outgoing || blocked;
|
|
if (!existingRelationship) throw new UnknownUserError();
|
|
const relationshipType = existingRelationship.type;
|
|
if (relationshipType === RelationshipTypes.INCOMING_REQUEST || relationshipType === RelationshipTypes.BLOCKED) {
|
|
await this.userRepository.deleteRelationship(userId, targetId, relationshipType);
|
|
await this.dispatchRelationshipRemove({
|
|
userId,
|
|
targetId: targetId.toString(),
|
|
});
|
|
return;
|
|
}
|
|
if (relationshipType === RelationshipTypes.OUTGOING_REQUEST) {
|
|
await this.userRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
|
|
await this.userRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
|
|
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
|
|
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
|
return;
|
|
}
|
|
if (relationshipType === RelationshipTypes.FRIEND) {
|
|
await this.userRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
|
|
await this.userRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
|
|
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
|
|
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
|
return;
|
|
}
|
|
await this.userRepository.deleteRelationship(userId, targetId, relationshipType);
|
|
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
|
|
}
|
|
|
|
async updateFriendNickname({
|
|
userId,
|
|
targetId,
|
|
nickname,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
userId: UserID;
|
|
targetId: UserID;
|
|
nickname: string | null;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<Relationship> {
|
|
const relationship = await this.userRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND);
|
|
if (!relationship) {
|
|
throw new UnknownUserError();
|
|
}
|
|
const updatedRelationship = await this.userRepository.upsertRelationship({
|
|
source_user_id: userId,
|
|
target_user_id: targetId,
|
|
type: RelationshipTypes.FRIEND,
|
|
nickname,
|
|
since: relationship.since ?? new Date(),
|
|
share_voice_activity: relationship.shareVoiceActivity,
|
|
version: 1,
|
|
});
|
|
await this.dispatchRelationshipUpdate({
|
|
userId,
|
|
relationship: updatedRelationship,
|
|
userCacheService,
|
|
requestCache,
|
|
});
|
|
return updatedRelationship;
|
|
}
|
|
|
|
async bulkIgnoreIncomingRequests({userId, data}: {userId: UserID; data: BulkIgnoreFriendRequestsRequest}): Promise<{
|
|
ignoredCount: number;
|
|
}> {
|
|
const incomingRequests = await this.userRepository.listIncomingRequests(userId);
|
|
let requestsToIgnore = incomingRequests;
|
|
if (data.filter === 'new_accounts' && data.max_account_age_seconds != null) {
|
|
const cutoffMs = data.max_account_age_seconds * 1000;
|
|
const now = Date.now();
|
|
requestsToIgnore = incomingRequests.filter((rel) => {
|
|
const senderCreatedAt = extractTimestamp(rel.targetUserId.toString());
|
|
return now - senderCreatedAt < cutoffMs;
|
|
});
|
|
}
|
|
for (const relationship of requestsToIgnore) {
|
|
await this.userRepository.deleteRelationship(
|
|
userId,
|
|
relationship.targetUserId,
|
|
RelationshipTypes.INCOMING_REQUEST,
|
|
);
|
|
await this.userRepository.deleteRelationship(
|
|
relationship.targetUserId,
|
|
userId,
|
|
RelationshipTypes.OUTGOING_REQUEST,
|
|
);
|
|
await this.dispatchRelationshipRemove({userId, targetId: relationship.targetUserId.toString()});
|
|
await this.dispatchRelationshipRemove({
|
|
userId: relationship.targetUserId,
|
|
targetId: userId.toString(),
|
|
});
|
|
}
|
|
return {ignoredCount: requestsToIgnore.length};
|
|
}
|
|
|
|
private async forceCreateFriendship({
|
|
userId,
|
|
targetId,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
userId: UserID;
|
|
targetId: UserID;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<Relationship> {
|
|
if (userId === targetId) {
|
|
throw new CannotSendFriendRequestToSelfError();
|
|
}
|
|
const requesterUser = await this.userRepository.findUnique(userId);
|
|
if (!requesterUser) {
|
|
throw new UnknownUserError();
|
|
}
|
|
if (this.isDeletedUser(requesterUser)) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
if (requesterUser.isUnclaimedAccount()) {
|
|
throw new UnclaimedAccountCannotSendFriendRequestsError();
|
|
}
|
|
requireEmailVerified(requesterUser, 'friend_request');
|
|
if (requesterUser.isBot) {
|
|
throw new BotsCannotSendFriendRequestsError();
|
|
}
|
|
const targetUser = await this.userRepository.findUnique(targetId);
|
|
if (!targetUser) {
|
|
throw new UnknownUserError();
|
|
}
|
|
if (this.isDeletedUser(targetUser)) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
const [
|
|
userFriend,
|
|
userBlocked,
|
|
userIncoming,
|
|
userOutgoing,
|
|
targetFriend,
|
|
targetBlocked,
|
|
targetIncoming,
|
|
targetOutgoing,
|
|
] = await Promise.all([
|
|
this.userRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND),
|
|
this.userRepository.getRelationship(userId, targetId, RelationshipTypes.BLOCKED),
|
|
this.userRepository.getRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST),
|
|
this.userRepository.getRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST),
|
|
this.userRepository.getRelationship(targetId, userId, RelationshipTypes.FRIEND),
|
|
this.userRepository.getRelationship(targetId, userId, RelationshipTypes.BLOCKED),
|
|
this.userRepository.getRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST),
|
|
this.userRepository.getRelationship(targetId, userId, RelationshipTypes.OUTGOING_REQUEST),
|
|
]);
|
|
const userAlreadyFriends = userFriend != null && targetFriend != null;
|
|
const hasExtraUserRelationship = userBlocked != null || userIncoming != null || userOutgoing != null;
|
|
const hasExtraTargetRelationship = targetBlocked != null || targetIncoming != null || targetOutgoing != null;
|
|
if (userAlreadyFriends && !hasExtraUserRelationship && !hasExtraTargetRelationship) {
|
|
return userFriend;
|
|
}
|
|
await Promise.all([
|
|
...(userFriend ? [this.userRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND)] : []),
|
|
...(userBlocked ? [this.userRepository.deleteRelationship(userId, targetId, RelationshipTypes.BLOCKED)] : []),
|
|
...(userIncoming
|
|
? [this.userRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST)]
|
|
: []),
|
|
...(userOutgoing
|
|
? [this.userRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST)]
|
|
: []),
|
|
...(targetFriend ? [this.userRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND)] : []),
|
|
...(targetBlocked ? [this.userRepository.deleteRelationship(targetId, userId, RelationshipTypes.BLOCKED)] : []),
|
|
...(targetIncoming
|
|
? [this.userRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST)]
|
|
: []),
|
|
...(targetOutgoing
|
|
? [this.userRepository.deleteRelationship(targetId, userId, RelationshipTypes.OUTGOING_REQUEST)]
|
|
: []),
|
|
]);
|
|
const now = new Date();
|
|
const [userDefaultStaff, targetDefaultStaff] = await Promise.all([
|
|
this.userRepository.findSettings(userId).then((s) => s?.defaultShareVoiceActivity ?? true),
|
|
this.userRepository.findSettings(targetId).then((s) => s?.defaultShareVoiceActivity ?? true),
|
|
]);
|
|
const userRelationship = await this.userRepository.upsertRelationship({
|
|
source_user_id: userId,
|
|
target_user_id: targetId,
|
|
type: RelationshipTypes.FRIEND,
|
|
nickname: null,
|
|
since: now,
|
|
share_voice_activity: userDefaultStaff,
|
|
version: 1,
|
|
});
|
|
const targetRelationship = await this.userRepository.upsertRelationship({
|
|
source_user_id: targetId,
|
|
target_user_id: userId,
|
|
type: RelationshipTypes.FRIEND,
|
|
nickname: null,
|
|
since: now,
|
|
share_voice_activity: targetDefaultStaff,
|
|
version: 1,
|
|
});
|
|
const hadUserRelationship = userFriend != null || hasExtraUserRelationship;
|
|
const hadTargetRelationship = targetFriend != null || hasExtraTargetRelationship;
|
|
if (hadUserRelationship) {
|
|
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
|
|
}
|
|
if (hadTargetRelationship) {
|
|
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
|
}
|
|
await this.dispatchRelationshipCreate({
|
|
userId,
|
|
relationship: userRelationship,
|
|
userCacheService,
|
|
requestCache,
|
|
});
|
|
await this.dispatchRelationshipCreate({
|
|
userId: targetId,
|
|
relationship: targetRelationship,
|
|
userCacheService,
|
|
requestCache,
|
|
});
|
|
return userRelationship;
|
|
}
|
|
|
|
private async validateFriendRequest({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<User> {
|
|
if (userId === targetId) {
|
|
throw new CannotSendFriendRequestToSelfError();
|
|
}
|
|
const requesterUser = await this.userRepository.findUnique(userId);
|
|
if (!requesterUser) {
|
|
throw new UnknownUserError();
|
|
}
|
|
if (this.isDeletedUser(requesterUser)) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
if (requesterUser?.isUnclaimedAccount()) {
|
|
throw new UnclaimedAccountCannotSendFriendRequestsError();
|
|
}
|
|
requireEmailVerified(requesterUser, 'friend_request');
|
|
if (requesterUser?.isBot) {
|
|
throw new BotsCannotSendFriendRequestsError();
|
|
}
|
|
const targetUser = await this.userRepository.findUnique(targetId);
|
|
if (!targetUser) throw new UnknownUserError();
|
|
if (this.isDeletedUser(targetUser)) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
const targetIsFriendlyBot =
|
|
targetUser.isBot && (targetUser.flags & UserFlags.FRIENDLY_BOT) === UserFlags.FRIENDLY_BOT;
|
|
if (targetUser.isBot && !targetIsFriendlyBot) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
if (targetUser.flags & UserFlags.APP_STORE_REVIEWER) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
const requesterBlockedTarget = await this.userRepository.getRelationship(
|
|
userId,
|
|
targetId,
|
|
RelationshipTypes.BLOCKED,
|
|
);
|
|
if (requesterBlockedTarget) {
|
|
throw new CannotSendFriendRequestToBlockedUserError();
|
|
}
|
|
const targetBlockedRequester = await this.userRepository.getRelationship(
|
|
targetId,
|
|
userId,
|
|
RelationshipTypes.BLOCKED,
|
|
);
|
|
if (targetBlockedRequester) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
await this.userPermissionUtils.validateFriendSourcePermissions({userId, targetId});
|
|
return targetUser;
|
|
}
|
|
|
|
private async validateRelationshipCounts({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
|
const user = await this.userRepository.findUnique(userId);
|
|
const targetUser = await this.userRepository.findUnique(targetId);
|
|
if (!user?.isBot) {
|
|
const userLimit = this.resolveLimitForUser(user ?? null, 'max_relationships', MAX_RELATIONSHIPS);
|
|
const hasReachedLimit = await this.userRepository.hasReachedRelationshipLimit(userId, userLimit);
|
|
if (hasReachedLimit) {
|
|
throw new MaxRelationshipsError(userLimit);
|
|
}
|
|
}
|
|
if (!targetUser?.isBot) {
|
|
const targetLimit = this.resolveLimitForUser(targetUser ?? null, 'max_relationships', MAX_RELATIONSHIPS);
|
|
const hasReachedLimit = await this.userRepository.hasReachedRelationshipLimit(targetId, targetLimit);
|
|
if (hasReachedLimit) {
|
|
throw new MaxRelationshipsError(targetLimit);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async createFriendRequest({
|
|
userId,
|
|
targetId,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
userId: UserID;
|
|
targetId: UserID;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<Relationship> {
|
|
const now = new Date();
|
|
const userRelationship = await this.userRepository.upsertRelationship({
|
|
source_user_id: userId,
|
|
target_user_id: targetId,
|
|
type: RelationshipTypes.OUTGOING_REQUEST,
|
|
nickname: null,
|
|
since: now,
|
|
share_voice_activity: true,
|
|
version: 1,
|
|
});
|
|
const targetRelationship = await this.userRepository.upsertRelationship({
|
|
source_user_id: targetId,
|
|
target_user_id: userId,
|
|
type: RelationshipTypes.INCOMING_REQUEST,
|
|
nickname: null,
|
|
since: now,
|
|
share_voice_activity: true,
|
|
version: 1,
|
|
});
|
|
await this.dispatchRelationshipCreate({userId, relationship: userRelationship, userCacheService, requestCache});
|
|
await this.dispatchRelationshipCreate({
|
|
userId: targetId,
|
|
relationship: targetRelationship,
|
|
userCacheService,
|
|
requestCache,
|
|
});
|
|
return userRelationship;
|
|
}
|
|
|
|
private async createShadowFriendRequest({
|
|
requesterUser,
|
|
userId,
|
|
targetId,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
requesterUser: User;
|
|
userId: UserID;
|
|
targetId: UserID;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<Relationship> {
|
|
if (userId === targetId) {
|
|
throw new CannotSendFriendRequestToSelfError();
|
|
}
|
|
if (this.isDeletedUser(requesterUser)) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
if (requesterUser.isUnclaimedAccount()) {
|
|
throw new UnclaimedAccountCannotSendFriendRequestsError();
|
|
}
|
|
requireEmailVerified(requesterUser, 'friend_request');
|
|
if (requesterUser.isBot) {
|
|
throw new BotsCannotSendFriendRequestsError();
|
|
}
|
|
const targetUser = await this.userRepository.findUnique(targetId);
|
|
if (!targetUser) {
|
|
throw new UnknownUserError();
|
|
}
|
|
if (this.isDeletedUser(targetUser)) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
const targetIsFriendlyBot =
|
|
targetUser.isBot && (targetUser.flags & UserFlags.FRIENDLY_BOT) === UserFlags.FRIENDLY_BOT;
|
|
if (targetUser.isBot && !targetIsFriendlyBot) {
|
|
throw new FriendRequestBlockedError();
|
|
}
|
|
const [requesterBlockedTarget, existingFriendship, existingOutgoingRequest] = await Promise.all([
|
|
this.userRepository.getRelationship(userId, targetId, RelationshipTypes.BLOCKED),
|
|
this.userRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND),
|
|
this.userRepository.getRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST),
|
|
]);
|
|
if (requesterBlockedTarget) {
|
|
throw new CannotSendFriendRequestToBlockedUserError();
|
|
}
|
|
if (existingFriendship || existingOutgoingRequest) {
|
|
const relationships = await this.userRepository.listRelationships(userId);
|
|
const relationship = relationships.find((r) => r.targetUserId === targetId);
|
|
if (relationship) {
|
|
return relationship;
|
|
}
|
|
}
|
|
const now = new Date();
|
|
const userRelationship = await this.userRepository.upsertRelationship({
|
|
source_user_id: userId,
|
|
target_user_id: targetId,
|
|
type: RelationshipTypes.OUTGOING_REQUEST,
|
|
nickname: null,
|
|
since: now,
|
|
share_voice_activity: true,
|
|
version: 1,
|
|
});
|
|
await this.dispatchRelationshipCreate({userId, relationship: userRelationship, userCacheService, requestCache});
|
|
return userRelationship;
|
|
}
|
|
|
|
async dispatchRelationshipCreate({
|
|
userId,
|
|
relationship,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
userId: UserID;
|
|
relationship: Relationship;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<void> {
|
|
const userPartialResolver = (userId: UserID) =>
|
|
getCachedUserPartialResponse({userId, userCacheService, requestCache});
|
|
await this.gatewayService.dispatchPresence({
|
|
userId,
|
|
event: 'RELATIONSHIP_ADD',
|
|
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
|
|
});
|
|
}
|
|
|
|
async dispatchRelationshipUpdate({
|
|
userId,
|
|
relationship,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
userId: UserID;
|
|
relationship: Relationship;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<void> {
|
|
const userPartialResolver = (userId: UserID) =>
|
|
getCachedUserPartialResponse({userId, userCacheService, requestCache});
|
|
await this.gatewayService.dispatchPresence({
|
|
userId,
|
|
event: 'RELATIONSHIP_UPDATE',
|
|
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
|
|
});
|
|
}
|
|
|
|
async dispatchRelationshipRemove({userId, targetId}: {userId: UserID; targetId: string}): Promise<void> {
|
|
await this.gatewayService.dispatchPresence({
|
|
userId,
|
|
event: 'RELATIONSHIP_REMOVE',
|
|
data: {id: targetId},
|
|
});
|
|
}
|
|
|
|
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
|
|
const ctx = createLimitMatchContext({user});
|
|
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
|
|
}
|
|
|
|
private isDeletedUser(user: User | null | undefined): boolean {
|
|
if (!user) {
|
|
return false;
|
|
}
|
|
if (user.pendingDeletionAt !== null) {
|
|
return false;
|
|
}
|
|
return (user.flags & UserFlags.DELETED) === UserFlags.DELETED;
|
|
}
|
|
}
|