From fb32bdf16e3118ff6803b0d64ada9830410840fa Mon Sep 17 00:00:00 2001 From: Marcos Spessatto Defendi Date: Fri, 18 Nov 2022 15:43:15 -0300 Subject: [PATCH] [NEW] Federation events coverage expansion (#27119) --- apps/meteor/.gitignore | 3 +- .../application/AbstractFederationService.ts | 37 ++-- .../server/application/RoomServiceListener.ts | 51 +++-- .../server/application/UserServiceListener.ts | 75 ++++++++ .../application/input/RoomReceiverDto.ts | 11 +- .../application/input/UserReceiverDto.ts | 17 ++ .../application/sender/RoomServiceSender.ts | 12 +- .../application/sender/UserServiceSender.ts | 46 +++++ .../server/domain/FederatedUser.ts | 4 + .../server/domain/IFederationBridge.ts | 37 +++- apps/meteor/app/federation-v2/server/index.ts | 55 +++++- .../server/infrastructure/Factory.ts | 47 ++++- .../server/infrastructure/matrix/Bridge.ts | 82 ++++---- .../matrix/converters/RoomReceiver.ts | 6 +- .../matrix/converters/UserReceiver.ts | 14 ++ .../matrix/definitions/MatrixEventType.ts | 1 + .../events/UserTypingStatusChanged.ts | 13 ++ .../infrastructure/matrix/handlers/User.ts | 17 ++ .../rocket-chat/adapters/Notification.ts | 47 +++++ .../rocket-chat/adapters/Room.ts | 8 +- .../rocket-chat/adapters/Settings.ts | 116 ++++++++++-- .../rocket-chat/adapters/User.ts | 32 +++- .../app/lib/server/functions/setRealName.ts | 5 + .../sender/AbstractFederationService.ts | 33 ++-- .../room/DMRoomInternalHooksServiceSender.ts | 13 +- .../room/RoomInternalHooksServiceSender.ts | 4 +- .../sender/room/RoomServiceSender.ts | 12 +- .../ee/app/federation-v2/server/index.ts | 37 +++- .../server/infrastructure/Factory.ts | 4 +- .../server/infrastructure/matrix/Bridge.ts | 2 +- .../rocket-chat/adapters/Notification.ts | 14 -- .../DMRoomInternalHooksServiceSender.spec.ts | 26 ++- .../RoomInternalHooksServiceSender.spec.ts | 153 +++++++++++++-- .../rocketchat-i18n/i18n/en.i18n.json | 2 + .../server/models/raw/MatrixBridgedUser.ts | 6 + apps/meteor/server/models/raw/Rooms.js | 8 + apps/meteor/server/sdk/lib/Events.ts | 2 + .../server/services/federation/service.ts | 20 ++ .../application/RoomServiceListener.spec.ts | 101 +++++++--- .../application/UserServiceListener.spec.ts | 175 ++++++++++++++++++ .../sender/RoomServiceSender.spec.ts | 5 + .../sender/UserServiceSender.spec.ts | 168 ++++++++++++++++- .../server/unit/domain/FederatedUser.spec.ts | 16 ++ .../matrix/converters/RoomReceiver.spec.ts | 52 +----- .../matrix/converters/UserReceiver.spec.ts | 27 +++ .../src/models/IMatrixBridgeUserModel.ts | 1 + .../model-typings/src/models/IRoomsModel.ts | 2 + 47 files changed, 1401 insertions(+), 218 deletions(-) create mode 100644 apps/meteor/app/federation-v2/server/application/UserServiceListener.ts create mode 100644 apps/meteor/app/federation-v2/server/application/input/UserReceiverDto.ts create mode 100644 apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/UserReceiver.ts create mode 100644 apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/UserTypingStatusChanged.ts create mode 100644 apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/User.ts create mode 100644 apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Notification.ts delete mode 100644 apps/meteor/ee/app/federation-v2/server/infrastructure/rocket-chat/adapters/Notification.ts create mode 100644 apps/meteor/tests/unit/app/federation-v2/server/unit/application/UserServiceListener.spec.ts create mode 100644 apps/meteor/tests/unit/app/federation-v2/server/unit/infrastructure/matrix/converters/UserReceiver.spec.ts diff --git a/apps/meteor/.gitignore b/apps/meteor/.gitignore index 31e6bfee5c60..3a7ccfde4253 100644 --- a/apps/meteor/.gitignore +++ b/apps/meteor/.gitignore @@ -85,4 +85,5 @@ coverage tests/e2e/test-failures/ out.txt dist -*-session.json \ No newline at end of file +*-session.json +matrix-federation-config/* \ No newline at end of file diff --git a/apps/meteor/app/federation-v2/server/application/AbstractFederationService.ts b/apps/meteor/app/federation-v2/server/application/AbstractFederationService.ts index b524b0d3fdb9..61647b5126b4 100644 --- a/apps/meteor/app/federation-v2/server/application/AbstractFederationService.ts +++ b/apps/meteor/app/federation-v2/server/application/AbstractFederationService.ts @@ -18,7 +18,7 @@ export abstract class FederationService { this.internalHomeServerDomain = this.internalSettingsAdapter.getHomeServerDomain(); } - protected async createFederatedUser( + protected async createFederatedUserInternallyOnly( externalUserId: string, username: string, existsOnlyOnProxyServer = false, @@ -42,25 +42,36 @@ export abstract class FederationService { if (!insertedUser) { return; } - await this.updateUserAvatarInternally(insertedUser); + await this.updateUserAvatarInternally(insertedUser, externalUserProfileInformation?.avatarUrl); + await this.updateUserDisplayNameInternally(insertedUser, externalUserProfileInformation?.displayName); } - protected async updateUserAvatarInternally(federatedUser: FederatedUser): Promise { - const externalUserProfileInformation = await this.bridge.getUserProfileInformation(federatedUser.getExternalId()); - if (!externalUserProfileInformation?.avatarUrl) { + protected async updateUserAvatarInternally(federatedUser: FederatedUser, avatarUrl?: string): Promise { + if (!avatarUrl) { return; } - if (!federatedUser.isRemote() || !federatedUser.shouldUpdateFederationAvatar(externalUserProfileInformation.avatarUrl)) { + if (!federatedUser.isRemote()) { return; } - await this.internalUserAdapter.setAvatar( - federatedUser, - this.bridge.convertMatrixUrlToHttp(federatedUser.getExternalId(), externalUserProfileInformation.avatarUrl), - ); - await this.internalUserAdapter.updateFederationAvatar(federatedUser.getInternalId(), externalUserProfileInformation.avatarUrl); + if (federatedUser.shouldUpdateFederationAvatar(avatarUrl)) { + await this.internalUserAdapter.setAvatar(federatedUser, this.bridge.convertMatrixUrlToHttp(federatedUser.getExternalId(), avatarUrl)); + await this.internalUserAdapter.updateFederationAvatar(federatedUser.getInternalId(), avatarUrl); + } + } + + protected async updateUserDisplayNameInternally(federatedUser: FederatedUser, displayName?: string): Promise { + if (!displayName) { + return; + } + if (!federatedUser.isRemote()) { + return; + } + if (federatedUser.shouldUpdateDisplayName(displayName)) { + await this.internalUserAdapter.updateRealName(federatedUser.getInternalReference(), displayName); + } } - protected async createFederatedUserForInviterUsingLocalInformation(internalInviterId: string): Promise { + protected async createFederatedUserIncludingHomeserverUsingLocalInformation(internalInviterId: string): Promise { const internalUser = await this.internalUserAdapter.getInternalUserById(internalInviterId); if (!internalUser || !internalUser?.username) { throw new Error(`Could not find user id for ${internalInviterId}`); @@ -68,7 +79,7 @@ export abstract class FederationService { const name = internalUser.name || internalUser.username; const externalInviterId = await this.bridge.createUser(internalUser.username, name, this.internalHomeServerDomain); const existsOnlyOnProxyServer = true; - await this.createFederatedUser(externalInviterId, internalUser.username, existsOnlyOnProxyServer, name); + await this.createFederatedUserInternallyOnly(externalInviterId, internalUser.username, existsOnlyOnProxyServer, name); await this.updateUserAvatarExternally( internalUser, (await this.internalUserAdapter.getFederatedUserByExternalId(externalInviterId)) as FederatedUser, diff --git a/apps/meteor/app/federation-v2/server/application/RoomServiceListener.ts b/apps/meteor/app/federation-v2/server/application/RoomServiceListener.ts index c2974b3cf81e..8ed4c803e9a6 100644 --- a/apps/meteor/app/federation-v2/server/application/RoomServiceListener.ts +++ b/apps/meteor/app/federation-v2/server/application/RoomServiceListener.ts @@ -23,6 +23,7 @@ import type { import { FederationService } from './AbstractFederationService'; import type { RocketChatFileAdapter } from '../infrastructure/rocket-chat/adapters/File'; import { getRedactMessageHandler } from './RoomRedactionHandlers'; +import type { RocketChatNotificationAdapter } from '../infrastructure/rocket-chat/adapters/Notification'; export class FederationRoomServiceListener extends FederationService { constructor( @@ -31,6 +32,7 @@ export class FederationRoomServiceListener extends FederationService { protected internalMessageAdapter: RocketChatMessageAdapter, protected internalFileAdapter: RocketChatFileAdapter, protected internalSettingsAdapter: RocketChatSettingsAdapter, + protected internalNotificationAdapter: RocketChatNotificationAdapter, protected bridge: IFederationBridge, ) { super(bridge, internalUserAdapter, internalFileAdapter, internalSettingsAdapter); @@ -62,7 +64,7 @@ export class FederationRoomServiceListener extends FederationService { const creatorUser = await this.internalUserAdapter.getFederatedUserByExternalId(externalInviterId); if (!creatorUser) { - await this.createFederatedUser(externalInviterId, normalizedInviterId); + await this.createFederatedUserInternallyOnly(externalInviterId, normalizedInviterId); } const creator = creatorUser || (await this.internalUserAdapter.getFederatedUserByExternalId(externalInviterId)); if (!creator) { @@ -75,7 +77,11 @@ export class FederationRoomServiceListener extends FederationService { roomType || RoomType.CHANNEL, externalRoomName, ); - await this.internalRoomAdapter.createFederatedRoom(newFederatedRoom); + const createdInternalRoomId = await this.internalRoomAdapter.createFederatedRoom(newFederatedRoom); + await this.internalNotificationAdapter.subscribeToUserTypingEventsOnFederatedRoomId( + createdInternalRoomId, + this.internalNotificationAdapter.broadcastUserTypingOnRoom.bind(this.internalNotificationAdapter), + ); } public async onChangeRoomMembership(roomChangeMembershipInput: FederationRoomChangeMembershipDto): Promise { @@ -92,15 +98,18 @@ export class FederationRoomServiceListener extends FederationService { eventOrigin, roomType, leave, - userAvatarUrl, + userProfile, } = roomChangeMembershipInput; const wasGeneratedOnTheProxyServer = eventOrigin === EVENT_ORIGIN.LOCAL; const affectedFederatedRoom = await this.internalRoomAdapter.getFederatedRoomByExternalId(externalRoomId); - if (userAvatarUrl) { + if (userProfile?.avatarUrl) { const federatedUser = await this.internalUserAdapter.getFederatedUserByExternalId(externalInviteeId); - federatedUser && (await this.updateUserAvatarInternally(federatedUser)); - return; + federatedUser && (await this.updateUserAvatarInternally(federatedUser, userProfile?.avatarUrl)); + } + if (userProfile?.displayName) { + const federatedUser = await this.internalUserAdapter.getFederatedUserByExternalId(externalInviteeId); + federatedUser && (await this.updateUserDisplayNameInternally(federatedUser, userProfile?.displayName)); } if (wasGeneratedOnTheProxyServer && !affectedFederatedRoom) { @@ -120,12 +129,12 @@ export class FederationRoomServiceListener extends FederationService { const inviterUser = await this.internalUserAdapter.getFederatedUserByExternalId(externalInviterId); if (!inviterUser) { - await this.createFederatedUser(externalInviterId, inviterUsername, isInviterFromTheSameHomeServer); + await this.createFederatedUserInternallyOnly(externalInviterId, inviterUsername, isInviterFromTheSameHomeServer); } const inviteeUser = await this.internalUserAdapter.getFederatedUserByExternalId(externalInviteeId); if (!inviteeUser) { - await this.createFederatedUser(externalInviteeId, inviteeUsername, isInviteeFromTheSameHomeServer); + await this.createFederatedUserInternallyOnly(externalInviteeId, inviteeUsername, isInviteeFromTheSameHomeServer); } const federatedInviteeUser = inviteeUser || (await this.internalUserAdapter.getFederatedUserByExternalId(externalInviteeId)); const federatedInviterUser = inviterUser || (await this.internalUserAdapter.getFederatedUserByExternalId(externalInviterId)); @@ -141,8 +150,12 @@ export class FederationRoomServiceListener extends FederationService { if (isDirectMessageRoom({ t: roomType })) { const members = [federatedInviterUser, federatedInviteeUser]; const newFederatedRoom = DirectMessageFederatedRoom.createInstance(externalRoomId, federatedInviterUser, members); - await this.internalRoomAdapter.createFederatedRoomForDirectMessage(newFederatedRoom); + const createdInternalRoomId = await this.internalRoomAdapter.createFederatedRoomForDirectMessage(newFederatedRoom); await this.bridge.joinRoom(externalRoomId, externalInviteeId); + await this.internalNotificationAdapter.subscribeToUserTypingEventsOnFederatedRoomId( + createdInternalRoomId, + this.internalNotificationAdapter.broadcastUserTypingOnRoom.bind(this.internalNotificationAdapter), + ); return; } const newFederatedRoom = FederatedRoom.createInstance( @@ -153,8 +166,12 @@ export class FederationRoomServiceListener extends FederationService { externalRoomName, ); - await this.internalRoomAdapter.createFederatedRoom(newFederatedRoom); + const createdInternalRoomId = await this.internalRoomAdapter.createFederatedRoom(newFederatedRoom); await this.bridge.joinRoom(externalRoomId, externalInviteeId); + await this.internalNotificationAdapter.subscribeToUserTypingEventsOnFederatedRoomId( + createdInternalRoomId, + this.internalNotificationAdapter.broadcastUserTypingOnRoom.bind(this.internalNotificationAdapter), + ); } const federatedRoom = affectedFederatedRoom || (await this.internalRoomAdapter.getFederatedRoomByExternalId(externalRoomId)); @@ -162,6 +179,14 @@ export class FederationRoomServiceListener extends FederationService { return; } + const inviteeAlreadyJoinedTheInternalRoom = await this.internalRoomAdapter.isUserAlreadyJoined( + federatedRoom.getInternalId(), + federatedInviteeUser.getInternalId(), + ); + if (!leave && inviteeAlreadyJoinedTheInternalRoom) { + return; + } + if (leave) { const inviteeAlreadyJoinedTheInternalRoom = await this.internalRoomAdapter.isUserAlreadyJoined( federatedRoom.getInternalId(), @@ -185,7 +210,11 @@ export class FederationRoomServiceListener extends FederationService { directMessageRoom.getMembers(), ); await this.internalRoomAdapter.removeDirectMessageRoom(federatedRoom); - await this.internalRoomAdapter.createFederatedRoomForDirectMessage(newFederatedRoom); + const createdInternalRoomId = await this.internalRoomAdapter.createFederatedRoomForDirectMessage(newFederatedRoom); + await this.internalNotificationAdapter.subscribeToUserTypingEventsOnFederatedRoomId( + createdInternalRoomId, + this.internalNotificationAdapter.broadcastUserTypingOnRoom.bind(this.internalNotificationAdapter), + ); return; } diff --git a/apps/meteor/app/federation-v2/server/application/UserServiceListener.ts b/apps/meteor/app/federation-v2/server/application/UserServiceListener.ts new file mode 100644 index 000000000000..a263f33c6f2c --- /dev/null +++ b/apps/meteor/app/federation-v2/server/application/UserServiceListener.ts @@ -0,0 +1,75 @@ +import type { IFederationBridge } from '../domain/IFederationBridge'; +import type { RocketChatFileAdapter } from '../infrastructure/rocket-chat/adapters/File'; +import type { RocketChatNotificationAdapter } from '../infrastructure/rocket-chat/adapters/Notification'; +import type { RocketChatRoomAdapter } from '../infrastructure/rocket-chat/adapters/Room'; +import type { RocketChatSettingsAdapter } from '../infrastructure/rocket-chat/adapters/Settings'; +import type { RocketChatUserAdapter } from '../infrastructure/rocket-chat/adapters/User'; +import { FederationService } from './AbstractFederationService'; +import type { FederationUserTypingStatusEventDto } from './input/UserReceiverDto'; + +export class FederationUserServiceListener extends FederationService { + private usersTypingByRoomIdCache: Map[]> = new Map(); + + constructor( + protected internalRoomAdapter: RocketChatRoomAdapter, + protected internalUserAdapter: RocketChatUserAdapter, + protected internalFileAdapter: RocketChatFileAdapter, + protected internalNotificationAdapter: RocketChatNotificationAdapter, + protected internalSettingsAdapter: RocketChatSettingsAdapter, + protected bridge: IFederationBridge, + ) { + super(bridge, internalUserAdapter, internalFileAdapter, internalSettingsAdapter); + } + + private handleUsersWhoStoppedTyping(externalRoomId: string, internalRoomId: string, externalUserIdsTyping: string[]): void { + const isTyping = false; + const notTypingAnymore = this.usersTypingByRoomIdCache + .get(externalRoomId) + ?.filter((user) => !externalUserIdsTyping.includes(user.externalUserId)); + + const stillTyping = this.usersTypingByRoomIdCache + .get(externalRoomId) + ?.filter((user) => externalUserIdsTyping.includes(user.externalUserId)); + + notTypingAnymore?.forEach((user) => this.internalNotificationAdapter.notifyUserTypingOnRoom(internalRoomId, user.username, isTyping)); + this.usersTypingByRoomIdCache.set(externalRoomId, stillTyping || []); + } + + public async onUserTyping(userTypingInput: FederationUserTypingStatusEventDto): Promise { + const { externalUserIdsTyping, externalRoomId } = userTypingInput; + const federatedRoom = await this.internalRoomAdapter.getFederatedRoomByExternalId(externalRoomId); + if (!federatedRoom) { + return; + } + if (this.usersTypingByRoomIdCache.has(externalRoomId)) { + this.handleUsersWhoStoppedTyping(externalRoomId, federatedRoom.getInternalId(), externalUserIdsTyping); + } + + if (externalUserIdsTyping.length === 0) { + return; + } + + const federatedUsers = await this.internalUserAdapter.getFederatedUsersByExternalIds(externalUserIdsTyping); + if (federatedUsers.length === 0) { + return; + } + + const isTyping = true; + + this.usersTypingByRoomIdCache.set( + externalRoomId, + federatedUsers.map((federatedUser) => { + this.internalNotificationAdapter.notifyUserTypingOnRoom( + federatedRoom.getInternalId(), + federatedUser.getUsername() as string, + isTyping, + ); + + return { + externalUserId: federatedUser.getInternalId(), + username: federatedUser.getUsername() as string, + }; + }), + ); + } +} diff --git a/apps/meteor/app/federation-v2/server/application/input/RoomReceiverDto.ts b/apps/meteor/app/federation-v2/server/application/input/RoomReceiverDto.ts index 9e10b15f438c..5fae4860b4d9 100644 --- a/apps/meteor/app/federation-v2/server/application/input/RoomReceiverDto.ts +++ b/apps/meteor/app/federation-v2/server/application/input/RoomReceiverDto.ts @@ -31,7 +31,10 @@ export interface IFederationChangeMembershipInputDto extends IFederationReceiver leave?: boolean; roomType?: RoomType; externalRoomName?: string; - userAvatarUrl?: string; + userProfile?: { + avatarUrl?: string; + displayName?: string; + }; } export interface IFederationSendInternalMessageInputDto extends IFederationReceiverBaseRoomInputDto { @@ -133,8 +136,8 @@ export class FederationRoomChangeMembershipDto extends FederationBaseRoomInputDt leave, roomType, externalRoomName, - userAvatarUrl, externalEventId, + userProfile, }: IFederationChangeMembershipInputDto) { super({ externalRoomId, normalizedRoomId, externalEventId }); this.externalInviterId = externalInviterId; @@ -147,7 +150,7 @@ export class FederationRoomChangeMembershipDto extends FederationBaseRoomInputDt this.leave = leave; this.roomType = roomType; this.externalRoomName = externalRoomName; - this.userAvatarUrl = userAvatarUrl; + this.userProfile = userProfile; } externalInviterId: string; @@ -170,7 +173,7 @@ export class FederationRoomChangeMembershipDto extends FederationBaseRoomInputDt externalRoomName?: string; - userAvatarUrl?: string; + userProfile?: { avatarUrl?: string; displayName?: string }; } class ExternalMessageBaseDto extends FederationBaseRoomInputDto { diff --git a/apps/meteor/app/federation-v2/server/application/input/UserReceiverDto.ts b/apps/meteor/app/federation-v2/server/application/input/UserReceiverDto.ts new file mode 100644 index 000000000000..221bf5aca63e --- /dev/null +++ b/apps/meteor/app/federation-v2/server/application/input/UserReceiverDto.ts @@ -0,0 +1,17 @@ +import type { IFederationReceiverBaseRoomInputDto } from './RoomReceiverDto'; +import { FederationBaseRoomInputDto } from './RoomReceiverDto'; + +interface IFederationUserTypingStatusInputDto extends IFederationReceiverBaseRoomInputDto { + externalUserIdsTyping: string[]; +} + +export class FederationUserTypingStatusEventDto extends FederationBaseRoomInputDto { + constructor({ externalRoomId, normalizedRoomId, externalUserIdsTyping }: IFederationUserTypingStatusInputDto) { + super({ externalRoomId, normalizedRoomId, externalEventId: '' }); + this.externalRoomId = externalRoomId; + this.normalizedRoomId = normalizedRoomId; + this.externalUserIdsTyping = externalUserIdsTyping; + } + + externalUserIdsTyping: string[]; +} diff --git a/apps/meteor/app/federation-v2/server/application/sender/RoomServiceSender.ts b/apps/meteor/app/federation-v2/server/application/sender/RoomServiceSender.ts index 637d23950aab..5b6700076231 100644 --- a/apps/meteor/app/federation-v2/server/application/sender/RoomServiceSender.ts +++ b/apps/meteor/app/federation-v2/server/application/sender/RoomServiceSender.ts @@ -6,6 +6,7 @@ import { FederatedUser } from '../../domain/FederatedUser'; import type { IFederationBridge } from '../../domain/IFederationBridge'; import type { RocketChatFileAdapter } from '../../infrastructure/rocket-chat/adapters/File'; import type { RocketChatMessageAdapter } from '../../infrastructure/rocket-chat/adapters/Message'; +import type { RocketChatNotificationAdapter } from '../../infrastructure/rocket-chat/adapters/Notification'; import type { RocketChatRoomAdapter } from '../../infrastructure/rocket-chat/adapters/Room'; import type { RocketChatSettingsAdapter } from '../../infrastructure/rocket-chat/adapters/Settings'; import type { RocketChatUserAdapter } from '../../infrastructure/rocket-chat/adapters/User'; @@ -25,6 +26,7 @@ export class FederationRoomServiceSender extends FederationService { protected internalFileAdapter: RocketChatFileAdapter, protected internalMessageAdapter: RocketChatMessageAdapter, protected internalSettingsAdapter: RocketChatSettingsAdapter, + protected internalNotificationAdapter: RocketChatNotificationAdapter, protected bridge: IFederationBridge, ) { super(bridge, internalUserAdapter, internalFileAdapter, internalSettingsAdapter); @@ -35,13 +37,13 @@ export class FederationRoomServiceSender extends FederationService { const internalInviterUser = await this.internalUserAdapter.getFederatedUserByInternalId(internalInviterId); if (!internalInviterUser) { - await this.createFederatedUserForInviterUsingLocalInformation(internalInviterId); + await this.createFederatedUserIncludingHomeserverUsingLocalInformation(internalInviterId); } const internalInviteeUser = await this.internalUserAdapter.getFederatedUserByInternalId(normalizedInviteeId); if (!internalInviteeUser) { const existsOnlyOnProxyServer = false; - await this.createFederatedUser(rawInviteeId, normalizedInviteeId, existsOnlyOnProxyServer); + await this.createFederatedUserInternallyOnly(rawInviteeId, normalizedInviteeId, existsOnlyOnProxyServer); } const federatedInviterUser = internalInviterUser || (await this.internalUserAdapter.getFederatedUserByInternalId(internalInviterId)); @@ -71,7 +73,11 @@ export class FederationRoomServiceSender extends FederationService { federatedInviterUser, federatedInviteeUser, ]); - await this.internalRoomAdapter.createFederatedRoomForDirectMessage(newFederatedRoom); + const createdInternalRoomId = await this.internalRoomAdapter.createFederatedRoomForDirectMessage(newFederatedRoom); + await this.internalNotificationAdapter.subscribeToUserTypingEventsOnFederatedRoomId( + createdInternalRoomId, + this.internalNotificationAdapter.broadcastUserTypingOnRoom.bind(this.internalNotificationAdapter), + ); } const federatedRoom = diff --git a/apps/meteor/app/federation-v2/server/application/sender/UserServiceSender.ts b/apps/meteor/app/federation-v2/server/application/sender/UserServiceSender.ts index ff05e1eac7c4..540d202a4608 100644 --- a/apps/meteor/app/federation-v2/server/application/sender/UserServiceSender.ts +++ b/apps/meteor/app/federation-v2/server/application/sender/UserServiceSender.ts @@ -1,11 +1,13 @@ import type { IFederationBridge } from '../../domain/IFederationBridge'; import type { RocketChatFileAdapter } from '../../infrastructure/rocket-chat/adapters/File'; +import type { RocketChatRoomAdapter } from '../../infrastructure/rocket-chat/adapters/Room'; import type { RocketChatSettingsAdapter } from '../../infrastructure/rocket-chat/adapters/Settings'; import type { RocketChatUserAdapter } from '../../infrastructure/rocket-chat/adapters/User'; import { FederationService } from '../AbstractFederationService'; export class FederationUserServiceSender extends FederationService { constructor( + protected internalRoomAdapter: RocketChatRoomAdapter, protected internalUserAdapter: RocketChatUserAdapter, protected internalFileAdapter: RocketChatFileAdapter, protected internalSettingsAdapter: RocketChatSettingsAdapter, @@ -17,6 +19,11 @@ export class FederationUserServiceSender extends FederationService { public async afterUserAvatarChanged(internalUsername: string): Promise { const federatedUser = await this.internalUserAdapter.getFederatedUserByInternalUsername(internalUsername); if (!federatedUser) { + const internalUser = await this.internalUserAdapter.getInternalUserByUsername(internalUsername); + if (!internalUser) { + return; + } + await this.createFederatedUserIncludingHomeserverUsingLocalInformation(internalUser._id); return; } @@ -26,4 +33,43 @@ export class FederationUserServiceSender extends FederationService { await this.updateUserAvatarExternally(federatedUser.getInternalReference(), federatedUser); } + + public async afterUserRealNameChanged(internalUserId: string, name: string): Promise { + const federatedUser = await this.internalUserAdapter.getFederatedUserByInternalId(internalUserId); + if (!federatedUser) { + const internalUser = await this.internalUserAdapter.getInternalUserById(internalUserId); + if (!internalUser) { + return; + } + await this.createFederatedUserIncludingHomeserverUsingLocalInformation(internalUser._id); + return; + } + + if (federatedUser.isRemote()) { + return; + } + const externalUserProfileInformation = await this.bridge.getUserProfileInformation(federatedUser.getExternalId()); + if (!federatedUser.shouldUpdateDisplayName(externalUserProfileInformation?.displayName || '')) { + return; + } + + await this.bridge.setUserDisplayName(federatedUser.getExternalId(), name); + } + + public async onUserTyping(internalUsername: string, internalRoomId: string, isTyping: boolean): Promise { + if (!this.internalSettingsAdapter.isTypingStatusEnabled()) { + return; + } + const federatedUser = await this.internalUserAdapter.getFederatedUserByInternalUsername(internalUsername); + if (!federatedUser) { + return; + } + + const federatedRoom = await this.internalRoomAdapter.getFederatedRoomByInternalId(internalRoomId); + if (!federatedRoom) { + return; + } + + await this.bridge.notifyUserTyping(federatedRoom.getExternalId(), federatedUser.getExternalId(), isTyping); + } } diff --git a/apps/meteor/app/federation-v2/server/domain/FederatedUser.ts b/apps/meteor/app/federation-v2/server/domain/FederatedUser.ts index b98ede3b1381..0d99c349aae8 100644 --- a/apps/meteor/app/federation-v2/server/domain/FederatedUser.ts +++ b/apps/meteor/app/federation-v2/server/domain/FederatedUser.ts @@ -110,6 +110,10 @@ export class FederatedUser { return this.internalReference.federation?.avatarUrl !== federationAvatarUrl; } + public shouldUpdateDisplayName(displayName: string): boolean { + return this.internalReference.name !== displayName; + } + public getInternalId(): string { return this.internalId; } diff --git a/apps/meteor/app/federation-v2/server/domain/IFederationBridge.ts b/apps/meteor/app/federation-v2/server/domain/IFederationBridge.ts index 3adc24cb7dbd..ae3b1f95e78e 100644 --- a/apps/meteor/app/federation-v2/server/domain/IFederationBridge.ts +++ b/apps/meteor/app/federation-v2/server/domain/IFederationBridge.ts @@ -10,10 +10,39 @@ export enum EVENT_ORIGIN { REMOTE = 'REMOTE', } +interface IRegistrationFileNamespaceRule { + exclusive: boolean; + regex: string; +} + +interface IRegistrationFileNamespaces { + users: IRegistrationFileNamespaceRule[]; + rooms: IRegistrationFileNamespaceRule[]; + aliases: IRegistrationFileNamespaceRule[]; +} + +export interface IFederationBridgeRegistrationFile { + id: string; + homeserverToken: string; + applicationServiceToken: string; + bridgeUrl: string; + botName: string; + listenTo: IRegistrationFileNamespaces; + enableEphemeralEvents: boolean; +} + export interface IFederationBridge { start(): Promise; stop(): Promise; - onFederationAvailabilityChanged(enabled: boolean): Promise; + onFederationAvailabilityChanged( + enabled: boolean, + appServiceId: string, + homeServerUrl: string, + homeServerDomain: string, + bridgeUrl: string, + bridgePort: number, + homeServerRegistrationFile: IFederationBridgeRegistrationFile, + ): Promise; getUserProfileInformation(externalUserId: string): Promise; joinRoom(externalRoomId: string, externalUserId: string): Promise; createDirectMessageRoom(externalCreatorId: string, externalInviteeIds: string[], extraData?: Record): Promise; @@ -26,8 +55,6 @@ export interface IFederationBridge { leaveRoom(externalRoomId: string, externalUserId: string): Promise; kickUserFromRoom(externalRoomId: string, externalUserId: string, externalOwnerId: string): Promise; logFederationStartupInfo(info?: string): void; - uploadContent(externalSenderId: string, content: Buffer, options?: { name?: string; type?: string }): Promise; - convertMatrixUrlToHttp(externalUserId: string, matrixUrl: string): string; setUserAvatar(externalUserId: string, avatarUrl: string): Promise; getReadStreamForFileFromUrl(externaUserId: string, fileUrl: string): Promise; redactEvent(externalRoomId: string, externalUserId: string, externalEventId: string): Promise; @@ -39,6 +66,8 @@ export interface IFederationBridge { content: Buffer, fileDetails: { filename: string; fileSize: number; mimeType: string; metadata?: { width?: number; height?: number; format?: string } }, ): Promise; + uploadContent(externalSenderId: string, content: Buffer, options?: { name?: string; type?: string }): Promise; + convertMatrixUrlToHttp(externalUserId: string, matrixUrl: string): string; sendReplyToMessage( externalRoomId: string, externalUserId: string, @@ -53,4 +82,6 @@ export interface IFederationBridge { fileDetails: { filename: string; fileSize: number; mimeType: string; metadata?: { width?: number; height?: number; format?: string } }, eventToReplyTo: string, ): Promise; + notifyUserTyping(externalRoomId: string, externalUserId: string, isTyping: boolean): Promise; + setUserDisplayName(externalUserId: string, displayName: string): Promise; } diff --git a/apps/meteor/app/federation-v2/server/index.ts b/apps/meteor/app/federation-v2/server/index.ts index 8a953c5087ff..5eeef00c7db2 100644 --- a/apps/meteor/app/federation-v2/server/index.ts +++ b/apps/meteor/app/federation-v2/server/index.ts @@ -1,4 +1,5 @@ import type { FederationRoomServiceSender } from './application/sender/RoomServiceSender'; +import type { IFederationBridgeRegistrationFile } from './domain/IFederationBridge'; import { FederationFactory } from './infrastructure/Factory'; export const FEDERATION_PROCESSING_CONCURRENCY = 1; @@ -11,6 +12,7 @@ export const rocketFileAdapter = FederationFactory.buildRocketFileAdapter(); const federationBridge = FederationFactory.buildFederationBridge(rocketSettingsAdapter, federationQueueInstance); const rocketRoomAdapter = FederationFactory.buildRocketRoomAdapter(); const rocketUserAdapter = FederationFactory.buildRocketUserAdapter(); +export const rocketNotificationAdapter = FederationFactory.buildRocketNotificationAdapter(); export const rocketMessageAdapter = FederationFactory.buildRocketMessageAdapter(); const federationRoomServiceReceiver = FederationFactory.buildRoomServiceReceiver( @@ -19,6 +21,7 @@ const federationRoomServiceReceiver = FederationFactory.buildRoomServiceReceiver rocketMessageAdapter, rocketFileAdapter, rocketSettingsAdapter, + rocketNotificationAdapter, federationBridge, ); @@ -31,9 +34,19 @@ const federationMessageServiceReceiver = FederationFactory.buildMessageServiceRe federationBridge, ); +const federationUserServiceReceiver = FederationFactory.buildUserServiceReceiver( + rocketRoomAdapter, + rocketUserAdapter, + rocketFileAdapter, + rocketNotificationAdapter, + rocketSettingsAdapter, + federationBridge, +); + const federationEventsHandler = FederationFactory.buildFederationEventHandler( federationRoomServiceReceiver, federationMessageServiceReceiver, + federationUserServiceReceiver, rocketSettingsAdapter, ); @@ -43,6 +56,7 @@ export let federationRoomServiceSender = FederationFactory.buildRoomServiceSende rocketFileAdapter, rocketMessageAdapter, rocketSettingsAdapter, + rocketNotificationAdapter, federationBridge, ); @@ -55,6 +69,7 @@ const federationRoomInternalHooksValidator = FederationFactory.buildRoomInternal ); export const federationUserServiceSender = FederationFactory.buildUserServiceSender( + rocketRoomAdapter, rocketUserAdapter, rocketFileAdapter, rocketSettingsAdapter, @@ -71,6 +86,33 @@ const federationMessageServiceSender = FederationFactory.buildMessageServiceSend let cancelSettingsObserver: () => void; +const onFederationEnabledStatusChanged = async ( + isFederationEnabled: boolean, + appServiceId: string, + homeServerUrl: string, + homeServerDomain: string, + bridgeUrl: string, + bridgePort: number, + homeServerRegistrationFile: IFederationBridgeRegistrationFile, +): Promise => { + federationBridge.onFederationAvailabilityChanged( + isFederationEnabled, + appServiceId, + homeServerUrl, + homeServerDomain, + bridgeUrl, + bridgePort, + homeServerRegistrationFile, + ); + if (isFederationEnabled) { + federationBridge.logFederationStartupInfo('Running Federation V2'); + FederationFactory.setupActions(federationRoomServiceSender, federationMessageServiceSender); + await import('./infrastructure/rocket-chat/slash-commands'); + return; + } + FederationFactory.removeCEListeners(); +}; + export const runFederation = async (): Promise => { federationRoomServiceSender = FederationFactory.buildRoomServiceSender( rocketRoomAdapter, @@ -78,19 +120,24 @@ export const runFederation = async (): Promise => { rocketFileAdapter, rocketMessageAdapter, rocketSettingsAdapter, + rocketNotificationAdapter, federationBridge, ); - FederationFactory.setupListeners(federationRoomServiceSender, federationRoomInternalHooksValidator, federationMessageServiceSender); + FederationFactory.setupValidators(federationRoomInternalHooksValidator); federationQueueInstance.setHandler(federationEventsHandler.handleEvent.bind(federationEventsHandler), FEDERATION_PROCESSING_CONCURRENCY); cancelSettingsObserver = rocketSettingsAdapter.onFederationEnabledStatusChanged( - federationBridge.onFederationAvailabilityChanged.bind(federationBridge), + onFederationEnabledStatusChanged.bind(onFederationEnabledStatusChanged), + ); + await rocketNotificationAdapter.subscribeToUserTypingEventsOnFederatedRooms( + rocketNotificationAdapter.broadcastUserTypingOnRoom.bind(rocketNotificationAdapter), ); if (!rocketSettingsAdapter.isFederationEnabled()) { return; } + FederationFactory.setupActions(federationRoomServiceSender, federationMessageServiceSender); await federationBridge.start(); federationBridge.logFederationStartupInfo('Running Federation V2'); - require('./infrastructure/rocket-chat/slash-commands'); + await import('./infrastructure/rocket-chat/slash-commands'); }; const updateServiceSenderInstance = (federationRoomServiceSenderInstance: FederationRoomServiceSender) => { @@ -99,7 +146,7 @@ const updateServiceSenderInstance = (federationRoomServiceSenderInstance: Federa export const stopFederation = async (federationRoomServiceSenderInstance: FederationRoomServiceSender): Promise => { updateServiceSenderInstance(federationRoomServiceSenderInstance); - FederationFactory.removeListeners(); + FederationFactory.removeCEListeners(); await federationBridge.stop(); cancelSettingsObserver(); }; diff --git a/apps/meteor/app/federation-v2/server/infrastructure/Factory.ts b/apps/meteor/app/federation-v2/server/infrastructure/Factory.ts index 6965f7e3a48e..2508866b9586 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/Factory.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/Factory.ts @@ -23,11 +23,14 @@ import type { IFederationBridge } from '../domain/IFederationBridge'; import { FederationHooks } from './rocket-chat/hooks'; import { FederationRoomSenderConverter } from './rocket-chat/converters/RoomSender'; import { FederationRoomInternalHooksValidator } from '../application/sender/RoomInternalHooksValidator'; -import { FederationUserServiceSender } from '../application/sender/UserServiceSender'; import { RocketChatFileAdapter } from './rocket-chat/adapters/File'; import { FederationMessageServiceListener } from '../application/MessageServiceListener'; import { MatrixMessageReactedHandler } from './matrix/handlers/Message'; import { FederationMessageServiceSender } from '../application/sender/MessageServiceSender'; +import { FederationUserServiceListener } from '../application/UserServiceListener'; +import { MatrixUserTypingStatusChangedHandler } from './matrix/handlers/User'; +import { FederationUserServiceSender } from '../application/sender/UserServiceSender'; +import { RocketChatNotificationAdapter } from './rocket-chat/adapters/Notification'; export class FederationFactory { public static buildRocketSettingsAdapter(): RocketChatSettingsAdapter { @@ -50,6 +53,10 @@ export class FederationFactory { return new RocketChatFileAdapter(); } + public static buildRocketNotificationAdapter(): RocketChatNotificationAdapter { + return new RocketChatNotificationAdapter(); + } + public static buildFederationQueue(): InMemoryQueue { return new InMemoryQueue(); } @@ -60,6 +67,7 @@ export class FederationFactory { rocketMessageAdapter: RocketChatMessageAdapter, rocketFileAdapter: RocketChatFileAdapter, rocketSettingsAdapter: RocketChatSettingsAdapter, + rocketNotificationAdapter: RocketChatNotificationAdapter, bridge: IFederationBridge, ): FederationRoomServiceListener { return new FederationRoomServiceListener( @@ -68,6 +76,7 @@ export class FederationFactory { rocketMessageAdapter, rocketFileAdapter, rocketSettingsAdapter, + rocketNotificationAdapter, bridge, ); } @@ -78,6 +87,7 @@ export class FederationFactory { rocketFileAdapter: RocketChatFileAdapter, rocketMessageAdapter: RocketChatMessageAdapter, rocketSettingsAdapter: RocketChatSettingsAdapter, + rocketNotificationAdapter: RocketChatNotificationAdapter, bridge: IFederationBridge, ): FederationRoomServiceSender { return new FederationRoomServiceSender( @@ -86,17 +96,19 @@ export class FederationFactory { rocketFileAdapter, rocketMessageAdapter, rocketSettingsAdapter, + rocketNotificationAdapter, bridge, ); } public static buildUserServiceSender( + rocketRoomAdapter: RocketChatRoomAdapter, rocketUserAdapter: RocketChatUserAdapter, rocketFileAdapter: RocketChatFileAdapter, rocketSettingsAdapter: RocketChatSettingsAdapter, bridge: IFederationBridge, ): FederationUserServiceSender { - return new FederationUserServiceSender(rocketUserAdapter, rocketFileAdapter, rocketSettingsAdapter, bridge); + return new FederationUserServiceSender(rocketRoomAdapter, rocketUserAdapter, rocketFileAdapter, rocketSettingsAdapter, bridge); } public static buildMessageServiceSender( @@ -127,6 +139,24 @@ export class FederationFactory { ); } + public static buildUserServiceReceiver( + rocketRoomAdapter: RocketChatRoomAdapter, + rocketUserAdapter: RocketChatUserAdapter, + rocketFileAdapter: RocketChatFileAdapter, + rocketNotificationAdapter: RocketChatNotificationAdapter, + rocketSettingsAdapter: RocketChatSettingsAdapter, + bridge: IFederationBridge, + ): FederationUserServiceListener { + return new FederationUserServiceListener( + rocketRoomAdapter, + rocketUserAdapter, + rocketFileAdapter, + rocketNotificationAdapter, + rocketSettingsAdapter, + bridge, + ); + } + public static buildRoomInternalHooksValidator( rocketRoomAdapter: RocketChatRoomAdapter, rocketUserAdapter: RocketChatUserAdapter, @@ -152,14 +182,18 @@ export class FederationFactory { public static buildFederationEventHandler( roomServiceReceive: FederationRoomServiceListener, messageServiceReceiver: FederationMessageServiceListener, + userServiceReceiver: FederationUserServiceListener, rocketSettingsAdapter: RocketChatSettingsAdapter, ): MatrixEventsHandler { - return new MatrixEventsHandler(FederationFactory.getEventHandlers(roomServiceReceive, messageServiceReceiver, rocketSettingsAdapter)); + return new MatrixEventsHandler( + FederationFactory.getEventHandlers(roomServiceReceive, messageServiceReceiver, userServiceReceiver, rocketSettingsAdapter), + ); } public static getEventHandlers( roomServiceReceiver: FederationRoomServiceListener, messageServiceReceiver: FederationMessageServiceListener, + userServiceReceiver: FederationUserServiceListener, rocketSettingsAdapter: RocketChatSettingsAdapter, ): MatrixBaseEventHandler[] { return [ @@ -171,6 +205,7 @@ export class FederationFactory { new MatrixRoomTopicChangedHandler(roomServiceReceiver), new MatrixRoomEventRedactedHandler(roomServiceReceiver), new MatrixMessageReactedHandler(messageServiceReceiver), + new MatrixUserTypingStatusChangedHandler(userServiceReceiver), ]; } @@ -183,7 +218,7 @@ export class FederationFactory { FederationFactory.setupValidators(roomInternalHooksValidator); } - private static setupActions(roomServiceSender: FederationRoomServiceSender, messageServiceSender: FederationMessageServiceSender): void { + public static setupActions(roomServiceSender: FederationRoomServiceSender, messageServiceSender: FederationMessageServiceSender): void { FederationHooks.afterUserLeaveRoom((user: IUser, room: IRoom) => roomServiceSender.afterUserLeaveRoom(FederationRoomSenderConverter.toAfterUserLeaveRoom(user._id, room._id)), ); @@ -207,7 +242,7 @@ export class FederationFactory { ); } - private static setupValidators(roomInternalHooksValidator: FederationRoomInternalHooksValidator): void { + public static setupValidators(roomInternalHooksValidator: FederationRoomInternalHooksValidator): void { FederationHooks.canAddFederatedUserToNonFederatedRoom((user: IUser | string, room: IRoom) => roomInternalHooksValidator.canAddFederatedUserToNonFederatedRoom(user, room), ); @@ -219,7 +254,7 @@ export class FederationFactory { ); } - public static removeListeners(): void { + public static removeCEListeners(): void { FederationHooks.removeCEValidation(); } } diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/Bridge.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/Bridge.ts index 7d994ce01d77..71d386e1cc23 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/matrix/Bridge.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/Bridge.ts @@ -2,7 +2,7 @@ import type { IMessage } from '@rocket.chat/core-typings'; import type { AppServiceOutput, Bridge } from '@rocket.chat/forked-matrix-appservice-bridge'; import { fetch } from '../../../../../server/lib/http/fetch'; -import type { IExternalUserProfileInformation, IFederationBridge } from '../../domain/IFederationBridge'; +import type { IExternalUserProfileInformation, IFederationBridge, IFederationBridgeRegistrationFile } from '../../domain/IFederationBridge'; import { federationBridgeLogger } from '../rocket-chat/adapters/logger'; import { toExternalMessageFormat, toExternalQuoteMessageFormat } from './converters/MessageTextParser'; import { convertEmojisRCFormatToMatrixFormat } from './converters/MessageReceiver'; @@ -14,26 +14,6 @@ import { MatrixRoomVisibility } from './definitions/MatrixRoomVisibility'; let MatrixUserInstance: any; -interface IRegistrationFileNamespaceRule { - exclusive: boolean; - regex: string; -} - -interface IRegistrationFileNamespaces { - users: IRegistrationFileNamespaceRule[]; - rooms: IRegistrationFileNamespaceRule[]; - aliases: IRegistrationFileNamespaceRule[]; -} - -export interface IFederationBridgeRegistrationFile { - id: string; - homeserverToken: string; - applicationServiceToken: string; - bridgeUrl: string; - botName: string; - listenTo: IRegistrationFileNamespaces; -} - export class MatrixBridge implements IFederationBridge { protected bridgeInstance: Bridge; @@ -51,7 +31,21 @@ export class MatrixBridge implements IFederationBridge { protected eventHandler: (event: AbstractMatrixEvent) => void, ) {} // eslint-disable-line no-empty-function - public async onFederationAvailabilityChanged(enabled: boolean): Promise { + public async onFederationAvailabilityChanged( + enabled: boolean, + appServiceId: string, + homeServerUrl: string, + homeServerDomain: string, + bridgeUrl: string, + bridgePort: number, + homeServerRegistrationFile: IFederationBridgeRegistrationFile, + ): Promise { + this.appServiceId = appServiceId; + this.homeServerUrl = homeServerUrl; + this.homeServerDomain = homeServerDomain; + this.bridgeUrl = bridgeUrl; + this.bridgePort = bridgePort; + this.homeServerRegistrationFile = homeServerRegistrationFile; if (!enabled) { await this.stop(); return; @@ -83,9 +77,12 @@ export class MatrixBridge implements IFederationBridge { if (!this.isRunning) { return; } - this.isRunning = false; - // the http server might take some minutes to shutdown, and this promise can take some time to be resolved - await this.bridgeInstance?.close(); + return new Promise(async (resolve: () => void): Promise => { + // the http server might take some minutes to shutdown, and this promise can take some time to be resolved + await this.bridgeInstance?.close(); + this.isRunning = false; + resolve(); + }); } public async getUserProfileInformation(externalUserId: string): Promise { @@ -131,11 +128,19 @@ export class MatrixBridge implements IFederationBridge { } const matrixUserId = `@${username?.toLowerCase()}:${domain}`; const newUser = new MatrixUserInstance(matrixUserId); - await this.bridgeInstance.provisionUser(newUser, { name: `${username} (${name})`, ...(avatarUrl ? { url: avatarUrl } : {}) }); + await this.bridgeInstance.provisionUser(newUser, { name, ...(avatarUrl ? { url: avatarUrl } : {}) }); return matrixUserId; } + public async setUserDisplayName(externalUserId: string, displayName: string): Promise { + try { + await this.bridgeInstance.getIntent(externalUserId).setDisplayName(displayName); + } catch (e) { + // no-op + } + } + public async createDirectMessageRoom( externalCreatorId: string, externalInviteeIds: string[], @@ -261,6 +266,10 @@ export class MatrixBridge implements IFederationBridge { await this.bridgeInstance.getIntent(externalUserId).matrixClient.redactEvent(externalRoomId, externalEventId); } + public async notifyUserTyping(externalRoomId: string, externalUserId: string, isTyping: boolean): Promise { + await this.bridgeInstance.getIntent(externalUserId).sendTyping(externalRoomId, isTyping); + } + public async sendMessageReaction( externalRoomId: string, externalUserId: string, @@ -431,18 +440,27 @@ export class MatrixBridge implements IFederationBridge { onLog: async (line, isError): Promise => { console.log(line, isError); }, + ...(this.homeServerRegistrationFile.enableEphemeralEvents + ? { + onEphemeralEvent: async (request): Promise => { + const event = request.getData() as unknown as AbstractMatrixEvent; + this.eventHandler(event); + }, + } + : {}), }, }); } private convertRegistrationFileToMatrixFormat(): AppServiceOutput { return { - id: this.homeServerRegistrationFile.id, - hs_token: this.homeServerRegistrationFile.homeserverToken, - as_token: this.homeServerRegistrationFile.applicationServiceToken, - url: this.homeServerRegistrationFile.bridgeUrl, - sender_localpart: this.homeServerRegistrationFile.botName, - namespaces: this.homeServerRegistrationFile.listenTo, + 'id': this.homeServerRegistrationFile.id, + 'hs_token': this.homeServerRegistrationFile.homeserverToken, + 'as_token': this.homeServerRegistrationFile.applicationServiceToken, + 'url': this.homeServerRegistrationFile.bridgeUrl, + 'sender_localpart': this.homeServerRegistrationFile.botName, + 'namespaces': this.homeServerRegistrationFile.listenTo, + 'de.sorunome.msc2409.push_ephemeral': this.homeServerRegistrationFile.enableEphemeralEvents, }; } } diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/RoomReceiver.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/RoomReceiver.ts index 55d070451f91..7fc24354c6e1 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/RoomReceiver.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/RoomReceiver.ts @@ -133,8 +133,10 @@ export class MatrixRoomReceiverConverter { inviterUsernameOnly: formatExternalUserIdToInternalUsernameFormat(externalEvent.sender), eventOrigin: getEventOrigin(externalEvent.sender, homeServerDomain), leave: externalEvent.content?.membership === RoomMembershipChangedEventType.LEAVE, - userAvatarUrl: - externalEvent.content?.membership === RoomMembershipChangedEventType.JOIN ? externalEvent.content?.avatar_url : undefined, + userProfile: { + avatarUrl: externalEvent.content?.avatar_url, + displayName: externalEvent.content?.displayname, + }, }); } diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/UserReceiver.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/UserReceiver.ts new file mode 100644 index 000000000000..32c0e064a102 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/UserReceiver.ts @@ -0,0 +1,14 @@ +import { FederationUserTypingStatusEventDto } from '../../../application/input/UserReceiverDto'; +import type { MatrixEventUserTypingStatusChanged } from '../definitions/events/UserTypingStatusChanged'; +import { convertExternalRoomIdToInternalRoomIdFormat } from './RoomReceiver'; + +export class MatrixUserReceiverConverter { + public static toUserTypingDto(externalEvent: MatrixEventUserTypingStatusChanged): FederationUserTypingStatusEventDto { + return new FederationUserTypingStatusEventDto({ + externalEventId: '', + externalRoomId: externalEvent.room_id, + externalUserIdsTyping: externalEvent.content.user_ids, + normalizedRoomId: convertExternalRoomIdToInternalRoomIdFormat(externalEvent.room_id), + }); + } +} diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/MatrixEventType.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/MatrixEventType.ts index 60e944f09ae2..af81ec6b17c0 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/MatrixEventType.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/MatrixEventType.ts @@ -11,4 +11,5 @@ export enum MatrixEventType { ROOM_TOPIC_CHANGED = 'm.room.topic', ROOM_EVENT_REDACTED = 'm.room.redaction', MESSAGE_REACTED = 'm.reaction', + USER_TYPING_STATUS_CHANGED = 'm.typing', } diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/UserTypingStatusChanged.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/UserTypingStatusChanged.ts new file mode 100644 index 000000000000..97607da44301 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/UserTypingStatusChanged.ts @@ -0,0 +1,13 @@ +import type { IBaseEventContent } from '../AbstractMatrixEvent'; +import { AbstractMatrixEvent } from '../AbstractMatrixEvent'; +import { MatrixEventType } from '../MatrixEventType'; + +export interface IMatrixEventContentUserTypingStatusChanged extends IBaseEventContent { + user_ids: string[]; +} + +export class MatrixEventUserTypingStatusChanged extends AbstractMatrixEvent { + public content: IMatrixEventContentUserTypingStatusChanged; + + public type = MatrixEventType.USER_TYPING_STATUS_CHANGED; +} diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/User.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/User.ts new file mode 100644 index 000000000000..ccb8c61226a6 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/User.ts @@ -0,0 +1,17 @@ +import { MatrixBaseEventHandler } from './BaseEvent'; +import { MatrixEventType } from '../definitions/MatrixEventType'; +import { MatrixUserReceiverConverter } from '../converters/UserReceiver'; +import type { FederationUserServiceListener } from '../../../application/UserServiceListener'; +import type { MatrixEventUserTypingStatusChanged } from '../definitions/events/UserTypingStatusChanged'; + +export class MatrixUserTypingStatusChangedHandler extends MatrixBaseEventHandler { + public eventType: string = MatrixEventType.USER_TYPING_STATUS_CHANGED; + + constructor(private userService: FederationUserServiceListener) { + super(); + } + + public async handle(externalEvent: MatrixEventUserTypingStatusChanged): Promise { + await this.userService.onUserTyping(MatrixUserReceiverConverter.toUserTypingDto(externalEvent)); + } +} diff --git a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Notification.ts b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Notification.ts new file mode 100644 index 000000000000..43e712c46c48 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Notification.ts @@ -0,0 +1,47 @@ +import { Rooms } from '@rocket.chat/models'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; + +import { api } from '../../../../../../server/sdk/api'; +import notifications from '../../../../../notifications/server/lib/Notifications'; + +export class RocketChatNotificationAdapter { + public async notifyUserTypingOnRoom(internalRoomId: string, username: string, isTyping: boolean): Promise { + notifications.notifyRoom(internalRoomId, 'user-activity', username, isTyping ? ['user-typing'] : []); + } + + public async subscribeToUserTypingEventsOnFederatedRooms( + callback: (username: string, activity: string[], roomId: string) => void, + ): Promise { + await Rooms.findFederatedRooms({ projection: { _id: 1 } }).forEach((room) => + this.subscribeToUserTypingEventsOnFederatedRoomId(room._id, callback), + ); + } + + public subscribeToUserTypingEventsOnFederatedRoomId( + roomId: string, + callback: (username: string, activity: string[], roomId: string) => void, + ): void { + notifications.streamRoom.on(`${roomId}/user-activity`, (username, activity) => { + if (Array.isArray(activity) && (!activity.length || activity.includes('user-typing'))) { + callback(username, activity, roomId); + } + }); + } + + public async broadcastUserTypingOnRoom(username: string, activity: string[], roomId: string): Promise { + api.broadcast('user.typing', { + user: { username }, + isTyping: activity.includes('user-typing'), + roomId, + }); + } + + public notifyWithEphemeralMessage(i18nMessageKey: string, userId: string, roomId: string, language = 'en'): void { + api.broadcast('notify.ephemeralMessage', userId, roomId, { + msg: TAPi18n.__(i18nMessageKey, { + postProcess: 'sprintf', + lng: language, + }), + }); + } +} diff --git a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Room.ts b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Room.ts index a773a065a997..c0865ca8399b 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Room.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Room.ts @@ -37,7 +37,7 @@ export class RocketChatRoomAdapter { return Rooms.findOneById(internalRoomId); } - public async createFederatedRoom(federatedRoom: FederatedRoom): Promise { + public async createFederatedRoom(federatedRoom: FederatedRoom): Promise { const usernameOrId = federatedRoom.getCreatorUsername() || federatedRoom.getCreatorId(); if (!usernameOrId) { throw new Error('Cannot create a room without a creator'); @@ -46,6 +46,8 @@ export class RocketChatRoomAdapter { const roomId = rid || _id; await MatrixBridgedRoom.createOrUpdateByLocalRoomId(roomId, federatedRoom.getExternalId()); await Rooms.setAsFederated(roomId); + + return roomId; } public async removeDirectMessageRoom(federatedRoom: FederatedRoom): Promise { @@ -55,7 +57,7 @@ export class RocketChatRoomAdapter { await MatrixBridgedRoom.removeByLocalRoomId(roomId); } - public async createFederatedRoomForDirectMessage(federatedRoom: DirectMessageFederatedRoom): Promise { + public async createFederatedRoomForDirectMessage(federatedRoom: DirectMessageFederatedRoom): Promise { const creatorId = federatedRoom.getCreatorId(); const usernameOrId = federatedRoom.getCreatorUsername() || creatorId; if (!usernameOrId) { @@ -79,6 +81,8 @@ export class RocketChatRoomAdapter { const roomId = rid || _id; await MatrixBridgedRoom.createOrUpdateByLocalRoomId(roomId, federatedRoom.getExternalId()); await Rooms.setAsFederated(roomId); + + return roomId; } public async getDirectMessageFederatedRoomByUserIds(userIds: string[]): Promise { diff --git a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings.ts b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings.ts index 9c85e1ffd7d7..c4e43678c64e 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings.ts @@ -1,11 +1,13 @@ import crypto from 'crypto'; +import fs from 'fs'; +import { resolve } from 'path'; import yaml from 'js-yaml'; import { v4 as uuidv4 } from 'uuid'; import { Settings } from '@rocket.chat/models'; import { settings, settingsRegistry } from '../../../../../settings/server'; -import type { IFederationBridgeRegistrationFile } from '../../matrix/Bridge'; +import type { IFederationBridgeRegistrationFile } from '../../../domain/IFederationBridge'; const EVERYTHING_REGEX = '.*'; const LISTEN_RULES = EVERYTHING_REGEX; @@ -14,6 +16,7 @@ export class RocketChatSettingsAdapter { public initialize(): void { this.addFederationSettings(); this.watchChangesAndUpdateRegistrationFile(); + this.updateSettingsWithProvidedConfigFileIfNecessary(); } public getApplicationServiceId(): string { @@ -58,7 +61,25 @@ export class RocketChatSettingsAdapter { return settings.get('Federation_Matrix_enabled') === true; } - public onFederationEnabledStatusChanged(callback: (enabled: boolean) => Promise): () => void { + public areEphemeralEventsEnabled(): boolean { + return this.isTypingStatusEnabled(); + } + + public isTypingStatusEnabled(): boolean { + return this.getRegistrationFileFromHomeserver()?.enableEphemeralEvents === true; + } + + public onFederationEnabledStatusChanged( + callback: ( + enabled: boolean, + appServiceId: string, + homeServerUrl: string, + homeServerDomain: string, + bridgeUrl: string, + bridgePort: number, + homeServerRegistrationFile: IFederationBridgeRegistrationFile, + ) => Promise, + ): () => void { return settings.watchMultiple( [ 'Federation_Matrix_enabled', @@ -70,7 +91,18 @@ export class RocketChatSettingsAdapter { 'Federation_Matrix_bridge_url', 'Federation_Matrix_bridge_localpart', ], - ([enabled]) => Promise.await(callback(enabled === true)), + ([enabled]) => + Promise.await( + callback( + enabled === true, + this.getApplicationServiceId(), + this.getHomeServerUrl(), + this.getHomeServerDomain(), + this.getBridgeUrl(), + this.getBridgePort(), + this.generateRegistrationFileObject(), + ), + ), ); } @@ -81,6 +113,7 @@ export class RocketChatSettingsAdapter { applicationServiceToken: this.getApplicationApplicationServiceToken(), bridgeUrl: this.getBridgeUrl(), botName: this.getBridgeBotUsername(), + enableEphemeralEvents: this.areEphemeralEventsEnabled(), listenTo: { users: [ { @@ -106,15 +139,17 @@ export class RocketChatSettingsAdapter { private async updateRegistrationFile(): Promise { const registrationFile = this.generateRegistrationFileObject(); + await Settings.updateValueById( 'Federation_Matrix_registration_file', yaml.dump({ - id: registrationFile.id, - hs_token: registrationFile.homeserverToken, - as_token: registrationFile.applicationServiceToken, - url: registrationFile.bridgeUrl, - sender_localpart: registrationFile.botName, - namespaces: registrationFile.listenTo, + 'id': registrationFile.id, + 'hs_token': registrationFile.homeserverToken, + 'as_token': registrationFile.applicationServiceToken, + 'url': registrationFile.bridgeUrl, + 'sender_localpart': registrationFile.botName, + 'namespaces': registrationFile.listenTo, + 'de.sorunome.msc2409.push_ephemeral': registrationFile.enableEphemeralEvents, }), ); } @@ -135,9 +170,11 @@ export class RocketChatSettingsAdapter { } private addFederationSettings(): void { + const preExistingConfiguration = this.getRegistrationFileFromHomeserver(); + settingsRegistry.addGroup('Federation', function () { this.section('Matrix Bridge', function () { - this.add('Federation_Matrix_enabled', false, { + this.add('Federation_Matrix_enabled', Boolean(preExistingConfiguration), { readonly: false, type: 'boolean', i18nLabel: 'Federation_Matrix_enabled', @@ -150,48 +187,48 @@ export class RocketChatSettingsAdapter { const homeserverToken = crypto.createHash('sha256').update(`hs_${uniqueId}`).digest('hex'); const applicationServiceToken = crypto.createHash('sha256').update(`as_${uniqueId}`).digest('hex'); - this.add('Federation_Matrix_id', `rocketchat_${uniqueId}`, { + this.add('Federation_Matrix_id', preExistingConfiguration?.id || `rocketchat_${uniqueId}`, { readonly: true, type: 'string', i18nLabel: 'Federation_Matrix_id', i18nDescription: 'Federation_Matrix_id_desc', }); - this.add('Federation_Matrix_hs_token', homeserverToken, { + this.add('Federation_Matrix_hs_token', preExistingConfiguration?.homeserverToken || homeserverToken, { readonly: true, type: 'string', i18nLabel: 'Federation_Matrix_hs_token', i18nDescription: 'Federation_Matrix_hs_token_desc', }); - this.add('Federation_Matrix_as_token', applicationServiceToken, { + this.add('Federation_Matrix_as_token', preExistingConfiguration?.applicationServiceToken || applicationServiceToken, { readonly: true, type: 'string', i18nLabel: 'Federation_Matrix_as_token', i18nDescription: 'Federation_Matrix_as_token_desc', }); - this.add('Federation_Matrix_homeserver_url', 'http://localhost:8008', { + this.add('Federation_Matrix_homeserver_url', preExistingConfiguration?.rocketchat?.homeServerUrl || 'http://localhost:8008', { type: 'string', i18nLabel: 'Federation_Matrix_homeserver_url', i18nDescription: 'Federation_Matrix_homeserver_url_desc', alert: 'Federation_Matrix_homeserver_url_alert', }); - this.add('Federation_Matrix_homeserver_domain', 'local.rocket.chat', { + this.add('Federation_Matrix_homeserver_domain', preExistingConfiguration?.rocketchat?.domainName || 'local.rocket.chat', { type: 'string', i18nLabel: 'Federation_Matrix_homeserver_domain', i18nDescription: 'Federation_Matrix_homeserver_domain_desc', alert: 'Federation_Matrix_homeserver_domain_alert', }); - this.add('Federation_Matrix_bridge_url', 'http://host.docker.internal:3300', { + this.add('Federation_Matrix_bridge_url', preExistingConfiguration?.bridgeUrl || 'http://host.docker.internal:3300', { type: 'string', i18nLabel: 'Federation_Matrix_bridge_url', i18nDescription: 'Federation_Matrix_bridge_url_desc', }); - this.add('Federation_Matrix_bridge_localpart', 'rocket.cat', { + this.add('Federation_Matrix_bridge_localpart', preExistingConfiguration?.botName || 'rocket.cat', { type: 'string', i18nLabel: 'Federation_Matrix_bridge_localpart', i18nDescription: 'Federation_Matrix_bridge_localpart_desc', @@ -199,11 +236,56 @@ export class RocketChatSettingsAdapter { this.add('Federation_Matrix_registration_file', '', { readonly: true, + hidden: Boolean(preExistingConfiguration), type: 'code', i18nLabel: 'Federation_Matrix_registration_file', i18nDescription: 'Federation_Matrix_registration_file_desc', + alert: 'Federation_Matrix_registration_file_Alert', }); }); }); } + + private getRegistrationFileFromHomeserver(): Record | undefined { + try { + const registrationYaml = fs.readFileSync(this.getFilePathForHomeserverConfig(), 'utf8'); + + const parsedFile = yaml.load(registrationYaml as string) as Record; + return { + applicationServiceToken: parsedFile.as_token, + bridgeUrl: parsedFile.url, + botName: parsedFile.sender_localpart, + homeserverToken: parsedFile.hs_token, + id: parsedFile.id, + listenTo: parsedFile.namespaces, + enableEphemeralEvents: parsedFile['de.sorunome.msc2409.push_ephemeral'], + rocketchat: { domainName: parsedFile.rocketchat?.homeserver_domain, homeServerUrl: parsedFile.rocketchat?.homeserver_url }, + }; + } catch (e) { + // no-op + } + } + + private getFilePathForHomeserverConfig(): string { + return process.env.NODE_ENV === 'development' + ? '../../../../../matrix-federation-config/registration.yaml' + : resolve(process.cwd(), '../../../matrix-federation-config/registration.yaml'); + } + + private updateSettingsWithProvidedConfigFileIfNecessary(): void { + const existingConfiguration = this.getRegistrationFileFromHomeserver(); + if (!existingConfiguration) { + return; + } + + Promise.await(Settings.updateValueById('Federation_Matrix_enabled', true)); + Promise.await(Settings.updateValueById('Federation_Matrix_id', existingConfiguration.id)); + Promise.await(Settings.updateValueById('Federation_Matrix_hs_token', existingConfiguration.homeserverToken)); + Promise.await(Settings.updateValueById('Federation_Matrix_as_token', existingConfiguration.applicationServiceToken)); + Promise.await(Settings.updateValueById('Federation_Matrix_homeserver_url', existingConfiguration.rocketchat?.homeServerUrl)); + Promise.await(Settings.updateValueById('Federation_Matrix_homeserver_domain', existingConfiguration.rocketchat?.domainName)); + Promise.await(Settings.updateValueById('Federation_Matrix_bridge_url', existingConfiguration.bridgeUrl)); + Promise.await(Settings.updateValueById('Federation_Matrix_bridge_localpart', existingConfiguration.botName)); + Promise.await(Settings.update({ _id: 'Federation_Matrix_registration_file' }, { $set: { hidden: Boolean(existingConfiguration) } })); + } } diff --git a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/User.ts b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/User.ts index 85b081fbed21..7fc4071e36b8 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/User.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/User.ts @@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor'; import type { IUser } from '@rocket.chat/core-typings'; import { Users, MatrixBridgedUser } from '@rocket.chat/models'; -import { setUserAvatar } from '../../../../../lib/server'; +import { setUserAvatar, _setRealName as setRealName } from '../../../../../lib/server'; import { FederatedUser } from '../../../domain/FederatedUser'; const createFederatedUserInstance = (externalUserId: string, user: IUser, remote = true): FederatedUser => { @@ -27,18 +27,36 @@ export const getFederatedUserByInternalUsername = async (username: string): Prom export class RocketChatUserAdapter { public async getFederatedUserByExternalId(externalUserId: string): Promise { - const internalBridgedUserId = await MatrixBridgedUser.getLocalUserIdByExternalId(externalUserId); - if (!internalBridgedUserId) { + const internalBridgedUser = await MatrixBridgedUser.getBridgedUserByExternalUserId(externalUserId); + if (!internalBridgedUser) { return; } - const user = await Users.findOneById(internalBridgedUserId); + const user = await Users.findOneById(internalBridgedUser.uid); if (user) { - return createFederatedUserInstance(externalUserId, user); + return createFederatedUserInstance(externalUserId, user, internalBridgedUser.remote); } } + public async getFederatedUsersByExternalIds(externalUserIds: string[]): Promise { + const internalBridgedUsers = await MatrixBridgedUser.getLocalUsersByExternalIds(externalUserIds); + if (internalBridgedUsers.length === 0) { + return []; + } + const internalUserIds = internalBridgedUsers.map((bridgedUser) => bridgedUser.uid); + const internalUserIdsMap: Record> = internalBridgedUsers.reduce( + (acc, bridgedUser) => ({ ...acc, [bridgedUser.uid]: { mui: bridgedUser.mui, remote: bridgedUser.remote } }), + {}, + ); + const users = await Users.findByIds(internalUserIds).toArray(); + + if (users.length === 0) { + return []; + } + return users.map((user) => createFederatedUserInstance(internalUserIdsMap[user._id].mui, user, internalUserIdsMap[user._id].remote)); + } + public async getFederatedUserByInternalId(internalUserId: string): Promise { const internalBridgedUser = await MatrixBridgedUser.getBridgedUserByLocalId(internalUserId); if (!internalBridgedUser) { @@ -96,4 +114,8 @@ export class RocketChatUserAdapter { public async updateFederationAvatar(internalUserId: string, externalAvatarUrl: string): Promise { await Users.setFederationAvatarUrlById(internalUserId, externalAvatarUrl); } + + public async updateRealName(internalUser: IUser, name: string): Promise { + setRealName(internalUser._id, name, internalUser); + } } diff --git a/apps/meteor/app/lib/server/functions/setRealName.ts b/apps/meteor/app/lib/server/functions/setRealName.ts index 493b211aab02..d9aa7a7b22a6 100644 --- a/apps/meteor/app/lib/server/functions/setRealName.ts +++ b/apps/meteor/app/lib/server/functions/setRealName.ts @@ -41,6 +41,11 @@ export const _setRealName = function (userId: string, name: string, fullUser: IU username: user.username, }); } + api.broadcast('user.realNameChanged', { + _id: user._id, + name, + username: user.username, + }); return user; }; diff --git a/apps/meteor/ee/app/federation-v2/server/application/sender/AbstractFederationService.ts b/apps/meteor/ee/app/federation-v2/server/application/sender/AbstractFederationService.ts index 96da7be1b234..27cd71740164 100644 --- a/apps/meteor/ee/app/federation-v2/server/application/sender/AbstractFederationService.ts +++ b/apps/meteor/ee/app/federation-v2/server/application/sender/AbstractFederationService.ts @@ -25,16 +25,27 @@ export abstract class FederationServiceEE extends FederationService { ), ); - await Promise.all( - externalUsersToBeCreatedLocally.map((invitee) => - this.internalUserAdapter.createLocalUser( - FederatedUserEE.createLocalInstanceOnly({ - username: invitee.normalizedInviteeId, - name: invitee.normalizedInviteeId, - existsOnlyOnProxyServer: false, - }), - ), - ), - ); + for await (const invitee of externalUsersToBeCreatedLocally) { + const externalUserProfileInformation = await this.bridge.getUserProfileInformation(invitee.rawInviteeId); + + const name = externalUserProfileInformation?.displayName || invitee.normalizedInviteeId; + const username = invitee.normalizedInviteeId; + const existsOnlyOnProxyServer = false; + + await this.internalUserAdapter.createLocalUser( + FederatedUserEE.createLocalInstanceOnly({ + username, + name, + existsOnlyOnProxyServer, + }), + ); + + const federatedUser = await this.internalUserAdapter.getFederatedUserByExternalId(invitee.rawInviteeId); + if (!federatedUser) { + return; + } + await this.updateUserAvatarInternally(federatedUser, externalUserProfileInformation?.avatarUrl); + await this.updateUserDisplayNameInternally(federatedUser, externalUserProfileInformation?.displayName); + } } } diff --git a/apps/meteor/ee/app/federation-v2/server/application/sender/room/DMRoomInternalHooksServiceSender.ts b/apps/meteor/ee/app/federation-v2/server/application/sender/room/DMRoomInternalHooksServiceSender.ts index 89a201fd1bb2..08e2a8f4e939 100644 --- a/apps/meteor/ee/app/federation-v2/server/application/sender/room/DMRoomInternalHooksServiceSender.ts +++ b/apps/meteor/ee/app/federation-v2/server/application/sender/room/DMRoomInternalHooksServiceSender.ts @@ -25,7 +25,14 @@ export class FederationDMRoomInternalHooksServiceSender extends FederationServic public async onDirectMessageRoomCreation(dmRoomOnCreationInput: FederationOnDirectMessageRoomCreationDto): Promise { const { internalRoomId, internalInviterId, invitees, inviteComesFromAnExternalHomeServer } = dmRoomOnCreationInput; - if (invitees.length === 0 || inviteComesFromAnExternalHomeServer) { + const atLeastOneExternalUser = invitees.some( + (invitee) => + !FederatedUserEE.isOriginalFromTheProxyServer( + this.bridge.extractHomeserverOrigin(invitee.rawInviteeId), + this.internalHomeServerDomain, + ), + ); + if (invitees.length === 0 || inviteComesFromAnExternalHomeServer || !atLeastOneExternalUser) { return; } await this.createExternalDirectMessageRoomAndInviteUsers({ @@ -59,7 +66,7 @@ export class FederationDMRoomInternalHooksServiceSender extends FederationServic const existsOnlyOnProxyServer = true; const externalInviterId = await this.bridge.createUser(username, name, this.internalHomeServerDomain); - await this.createFederatedUser(externalInviterId, username, existsOnlyOnProxyServer, name); + await this.createFederatedUserInternallyOnly(externalInviterId, username, existsOnlyOnProxyServer, name); } const federatedInviterUser = inviterUser || (await this.internalUserAdapter.getFederatedUserByInternalId(internalInviterId)); @@ -106,7 +113,7 @@ export class FederationDMRoomInternalHooksServiceSender extends FederationServic const existsOnlyOnProxyServer = isInviteeFromTheSameHomeServer; const inviteeUser = await this.internalUserAdapter.getFederatedUserByInternalUsername(username); if (!inviteeUser) { - await this.createFederatedUser(rawInviteeId, username, existsOnlyOnProxyServer); + await this.createFederatedUserInternallyOnly(rawInviteeId, username, existsOnlyOnProxyServer); } if (!isInviteeFromTheSameHomeServer) { diff --git a/apps/meteor/ee/app/federation-v2/server/application/sender/room/RoomInternalHooksServiceSender.ts b/apps/meteor/ee/app/federation-v2/server/application/sender/room/RoomInternalHooksServiceSender.ts index aef4b68b3ef7..c96389fd48f9 100644 --- a/apps/meteor/ee/app/federation-v2/server/application/sender/room/RoomInternalHooksServiceSender.ts +++ b/apps/meteor/ee/app/federation-v2/server/application/sender/room/RoomInternalHooksServiceSender.ts @@ -138,7 +138,7 @@ export class FederationRoomInternalHooksServiceSender extends FederationServiceE const inviterUser = await this.internalUserAdapter.getFederatedUserByInternalId(internalInviterId); if (!inviterUser) { - await this.createFederatedUserForInviterUsingLocalInformation(internalInviterId); + await this.createFederatedUserIncludingHomeserverUsingLocalInformation(internalInviterId); } const federatedInviterUser = inviterUser || (await this.internalUserAdapter.getFederatedUserByInternalId(internalInviterId)); @@ -182,7 +182,7 @@ export class FederationRoomInternalHooksServiceSender extends FederationServiceE const inviteeUser = await this.internalUserAdapter.getFederatedUserByInternalUsername(username); if (!inviteeUser) { const existsOnlyOnProxyServer = isInviteeFromTheSameHomeServer; - await this.createFederatedUser(rawInviteeId, username, existsOnlyOnProxyServer); + await this.createFederatedUserInternallyOnly(rawInviteeId, username, existsOnlyOnProxyServer); } const federatedInviteeUser = inviteeUser || (await this.internalUserAdapter.getFederatedUserByInternalUsername(username)); diff --git a/apps/meteor/ee/app/federation-v2/server/application/sender/room/RoomServiceSender.ts b/apps/meteor/ee/app/federation-v2/server/application/sender/room/RoomServiceSender.ts index 5e222c28d867..43debf9c87db 100644 --- a/apps/meteor/ee/app/federation-v2/server/application/sender/room/RoomServiceSender.ts +++ b/apps/meteor/ee/app/federation-v2/server/application/sender/room/RoomServiceSender.ts @@ -1,6 +1,7 @@ import { FederationRoomServiceSender } from '../../../../../../../app/federation-v2/server/application/sender/RoomServiceSender'; import type { RocketChatFileAdapter } from '../../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/File'; import type { RocketChatMessageAdapter } from '../../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Message'; +import type { RocketChatNotificationAdapter } from '../../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Notification'; import type { RocketChatSettingsAdapter } from '../../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings'; import type { IFederationBridgeEE } from '../../../domain/IFederationBridge'; import type { RocketChatRoomAdapterEE } from '../../../infrastructure/rocket-chat/adapters/Room'; @@ -14,9 +15,18 @@ export class FederationRoomServiceSenderEE extends FederationRoomServiceSender { protected internalFileAdapter: RocketChatFileAdapter, protected internalMessageAdapter: RocketChatMessageAdapter, protected internalSettingsAdapter: RocketChatSettingsAdapter, + protected internalNotificationAdapter: RocketChatNotificationAdapter, protected bridge: IFederationBridgeEE, ) { - super(internalRoomAdapter, internalUserAdapter, internalFileAdapter, internalMessageAdapter, internalSettingsAdapter, bridge); + super( + internalRoomAdapter, + internalUserAdapter, + internalFileAdapter, + internalMessageAdapter, + internalSettingsAdapter, + internalNotificationAdapter, + bridge, + ); } public async createLocalDirectMessageRoom(dmRoomCreateInput: FederationCreateDirectMessageDto): Promise { diff --git a/apps/meteor/ee/app/federation-v2/server/index.ts b/apps/meteor/ee/app/federation-v2/server/index.ts index fd0ebb55c981..2be0b25397b0 100644 --- a/apps/meteor/ee/app/federation-v2/server/index.ts +++ b/apps/meteor/ee/app/federation-v2/server/index.ts @@ -5,7 +5,9 @@ import { federationQueueInstance, rocketMessageAdapter, rocketFileAdapter, + rocketNotificationAdapter, } from '../../../../app/federation-v2/server'; +import type { IFederationBridgeRegistrationFile } from '../../../../app/federation-v2/server/domain/IFederationBridge'; import { onToggledFeature } from '../../license/server/license'; import { FederationFactoryEE } from './infrastructure/Factory'; @@ -19,6 +21,7 @@ export const federationRoomServiceSenderEE = FederationFactoryEE.buildRoomServic rocketFileAdapter, rocketMessageAdapter, rocketSettingsAdapter, + rocketNotificationAdapter, federationBridgeEE, ); @@ -46,11 +49,41 @@ const runFederationEE = async (): Promise => { let cancelSettingsObserverEE: () => void; +const onFederationEnabledStatusChangedEE = async ( + isFederationEnabled: boolean, + appServiceId: string, + homeServerUrl: string, + homeServerDomain: string, + bridgeUrl: string, + bridgePort: number, + homeServerRegistrationFile: IFederationBridgeRegistrationFile, +): Promise => { + federationBridgeEE.onFederationAvailabilityChanged( + isFederationEnabled, + appServiceId, + homeServerUrl, + homeServerDomain, + bridgeUrl, + bridgePort, + homeServerRegistrationFile, + ); + if (isFederationEnabled) { + FederationFactoryEE.setupListeners( + federationRoomInternalHooksServiceSenderEE, + federationDMRoomInternalHooksServiceSenderEE, + rocketSettingsAdapter, + ); + await import('./infrastructure/rocket-chat/slash-commands'); + return; + } + FederationFactoryEE.removeListeners(); +}; + onToggledFeature('federation', { up: async () => { await stopFederation(federationRoomServiceSenderEE); cancelSettingsObserverEE = rocketSettingsAdapter.onFederationEnabledStatusChanged( - federationBridgeEE.onFederationAvailabilityChanged.bind(federationBridgeEE), + onFederationEnabledStatusChangedEE.bind(onFederationEnabledStatusChangedEE), ); if (!rocketSettingsAdapter.isFederationEnabled()) { return; @@ -61,7 +94,7 @@ onToggledFeature('federation', { federationDMRoomInternalHooksServiceSenderEE, rocketSettingsAdapter, ); - require('./infrastructure/rocket-chat/slash-commands'); + await import('./infrastructure/rocket-chat/slash-commands'); }, down: async () => { await federationBridgeEE.stop(); diff --git a/apps/meteor/ee/app/federation-v2/server/infrastructure/Factory.ts b/apps/meteor/ee/app/federation-v2/server/infrastructure/Factory.ts index 9b556df6274a..8508dc0ae53a 100644 --- a/apps/meteor/ee/app/federation-v2/server/infrastructure/Factory.ts +++ b/apps/meteor/ee/app/federation-v2/server/infrastructure/Factory.ts @@ -7,13 +7,13 @@ import { FederationRoomInternalHooksServiceSender } from '../application/sender/ import { FederationRoomServiceSenderEE } from '../application/sender/room/RoomServiceSender'; import type { IFederationBridgeEE } from '../domain/IFederationBridge'; import { MatrixBridgeEE } from './matrix/Bridge'; -import { RocketChatNotificationAdapter } from './rocket-chat/adapters/Notification'; import { RocketChatRoomAdapterEE } from './rocket-chat/adapters/Room'; import { RocketChatUserAdapterEE } from './rocket-chat/adapters/User'; import { FederationRoomSenderConverterEE } from './rocket-chat/converters/RoomSender'; import { FederationHooksEE } from './rocket-chat/hooks'; import type { RocketChatMessageAdapter } from '../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Message'; import type { RocketChatFileAdapter } from '../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/File'; +import { RocketChatNotificationAdapter } from '../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Notification'; export class FederationFactoryEE { public static buildRoomServiceSender( @@ -22,6 +22,7 @@ export class FederationFactoryEE { rocketFileAdapter: RocketChatFileAdapter, rocketMessageAdapter: RocketChatMessageAdapter, rocketSettingsAdapter: RocketChatSettingsAdapter, + rocketNotificationAdapter: RocketChatNotificationAdapter, bridge: IFederationBridgeEE, ): FederationRoomServiceSenderEE { return new FederationRoomServiceSenderEE( @@ -30,6 +31,7 @@ export class FederationFactoryEE { rocketFileAdapter, rocketMessageAdapter, rocketSettingsAdapter, + rocketNotificationAdapter, bridge, ); } diff --git a/apps/meteor/ee/app/federation-v2/server/infrastructure/matrix/Bridge.ts b/apps/meteor/ee/app/federation-v2/server/infrastructure/matrix/Bridge.ts index 1749dd79a0be..4adb75f5e411 100644 --- a/apps/meteor/ee/app/federation-v2/server/infrastructure/matrix/Bridge.ts +++ b/apps/meteor/ee/app/federation-v2/server/infrastructure/matrix/Bridge.ts @@ -1,6 +1,6 @@ import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; -import type { IFederationBridgeRegistrationFile } from '../../../../../../app/federation-v2/server/infrastructure/matrix/Bridge'; +import type { IFederationBridgeRegistrationFile } from '../../../../../../app/federation-v2/server/domain/IFederationBridge'; import { MatrixBridge } from '../../../../../../app/federation-v2/server/infrastructure/matrix/Bridge'; import type { AbstractMatrixEvent } from '../../../../../../app/federation-v2/server/infrastructure/matrix/definitions/AbstractMatrixEvent'; import type { MatrixEventRoomNameChanged } from '../../../../../../app/federation-v2/server/infrastructure/matrix/definitions/events/RoomNameChanged'; diff --git a/apps/meteor/ee/app/federation-v2/server/infrastructure/rocket-chat/adapters/Notification.ts b/apps/meteor/ee/app/federation-v2/server/infrastructure/rocket-chat/adapters/Notification.ts deleted file mode 100644 index 51f279758a48..000000000000 --- a/apps/meteor/ee/app/federation-v2/server/infrastructure/rocket-chat/adapters/Notification.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; - -import { api } from '../../../../../../../server/sdk/api'; - -export class RocketChatNotificationAdapter { - public notifyWithEphemeralMessage(i18nMessageKey: string, userId: string, roomId: string, language = 'en'): void { - api.broadcast('notify.ephemeralMessage', userId, roomId, { - msg: TAPi18n.__(i18nMessageKey, { - postProcess: 'sprintf', - lng: language, - }), - }); - } -} diff --git a/apps/meteor/ee/tests/unit/app/federation-v2/server/application/DMRoomInternalHooksServiceSender.spec.ts b/apps/meteor/ee/tests/unit/app/federation-v2/server/application/DMRoomInternalHooksServiceSender.spec.ts index 4bdf773b2a7c..674a557fce50 100644 --- a/apps/meteor/ee/tests/unit/app/federation-v2/server/application/DMRoomInternalHooksServiceSender.spec.ts +++ b/apps/meteor/ee/tests/unit/app/federation-v2/server/application/DMRoomInternalHooksServiceSender.spec.ts @@ -144,7 +144,8 @@ describe('FederationEE - Application - FederationDMRoomInternalHooksServiceSende ).to.be.rejectedWith('User with internalId internalInviterId not found'); }); - it('should create the external room with all the invitees when the inviter is from the same homeserver', async () => { + it('should create the external room with all (the external) the invitees when the inviter is from the same homeserver, when at least one invitee is external', async () => { + bridge.extractHomeserverOrigin.onCall(0).returns('matrix.com'); bridge.extractHomeserverOrigin.returns('localDomain'); userAdapter.getFederatedUserByInternalId.resolves(user); userAdapter.getFederatedUserByInternalUsername.resolves(user); @@ -166,6 +167,23 @@ describe('FederationEE - Application - FederationDMRoomInternalHooksServiceSende expect(roomAdapter.updateFederatedRoomByInternalRoomId.calledWith('internalRoomId', 'externalRoomId')).to.be.true; }); + it('should NOT create the external room with any invitee when all of them are local only and the inviter is from the same homeserver', async () => { + bridge.extractHomeserverOrigin.returns('localDomain'); + userAdapter.getFederatedUserByInternalId.resolves(user); + userAdapter.getFederatedUserByInternalUsername.resolves(user); + roomAdapter.getFederatedRoomByInternalId.resolves(undefined); + bridge.createDirectMessageRoom.resolves('externalRoomId'); + + await service.onDirectMessageRoomCreation({ + invitees, + internalInviterId: 'internalInviterId', + internalRoomId: 'internalRoomId', + } as any); + + expect(bridge.createDirectMessageRoom.called).to.be.false; + expect(roomAdapter.updateFederatedRoomByInternalRoomId.called).to.be.false; + }); + it('should NOT create the external room with all the invitees when the inviter is NOT from the same homeserver', async () => { bridge.extractHomeserverOrigin.returns('externalDomain'); userAdapter.getFederatedUserByInternalId.resolves(user); @@ -180,7 +198,8 @@ describe('FederationEE - Application - FederationDMRoomInternalHooksServiceSende expect(roomAdapter.updateFederatedRoomByInternalRoomId.called).to.be.false; }); - it('should create the invitee user if it does not exists and it is from the same home server', async () => { + it('should create the invitee user if it does not exists and it is from the same home server, but he is not the only one, there is also an external invitee', async () => { + bridge.extractHomeserverOrigin.onCall(0).returns('matrix.com'); bridge.extractHomeserverOrigin.returns('localDomain'); userAdapter.getFederatedUserByInternalId.resolves(user); roomAdapter.getFederatedRoomByInternalId.resolves(room); @@ -249,7 +268,8 @@ describe('FederationEE - Application - FederationDMRoomInternalHooksServiceSende expect(bridge.createUser.called).to.be.false; }); - it('should throw an error if the invitee is from the same home server but the federated user does not exists', async () => { + it('should throw an error if the invitee is from the same home server but the federated user does not exists and also there is at least one external user', async () => { + bridge.extractHomeserverOrigin.onCall(0).returns('matrix.com'); bridge.extractHomeserverOrigin.returns('localDomain'); userAdapter.getFederatedUserByInternalId.resolves(user); roomAdapter.getFederatedRoomByInternalId.resolves(room); diff --git a/apps/meteor/ee/tests/unit/app/federation-v2/server/application/RoomInternalHooksServiceSender.spec.ts b/apps/meteor/ee/tests/unit/app/federation-v2/server/application/RoomInternalHooksServiceSender.spec.ts index 2c84ada8ad95..519b776f5f82 100644 --- a/apps/meteor/ee/tests/unit/app/federation-v2/server/application/RoomInternalHooksServiceSender.spec.ts +++ b/apps/meteor/ee/tests/unit/app/federation-v2/server/application/RoomInternalHooksServiceSender.spec.ts @@ -52,6 +52,9 @@ describe('FederationEE - Application - FederationRoomInternalHooksServiceSender' getFederatedUserByInternalUsername: sinon.stub(), createLocalUser: sinon.stub(), getInternalUserByUsername: sinon.stub(), + updateFederationAvatar: sinon.stub(), + setAvatar: sinon.stub(), + updateRealName: sinon.stub(), }; const settingsAdapter = { getHomeServerDomain: sinon.stub().returns('localDomain'), @@ -67,6 +70,7 @@ describe('FederationEE - Application - FederationRoomInternalHooksServiceSender' getRoomName: sinon.stub(), setRoomTopic: sinon.stub(), getRoomTopic: sinon.stub(), + convertMatrixUrlToHttp: sinon.stub().returns('toHttpUrl'), }; const fileAdapter = { getBufferForAvatarFile: sinon.stub().resolves(undefined), @@ -96,11 +100,14 @@ describe('FederationEE - Application - FederationRoomInternalHooksServiceSender' roomAdapter.getInternalRoomById.reset(); userAdapter.getFederatedUserByInternalId.reset(); userAdapter.getInternalUserById.reset(); - userAdapter.getFederatedUserByExternalId.reset(); userAdapter.createFederatedUser.reset(); userAdapter.getFederatedUserByInternalUsername.reset(); userAdapter.createLocalUser.reset(); userAdapter.getInternalUserByUsername.reset(); + userAdapter.getFederatedUserByExternalId.reset(); + userAdapter.updateFederationAvatar.reset(); + userAdapter.setAvatar.reset(); + userAdapter.updateRealName.reset(); bridge.extractHomeserverOrigin.reset(); bridge.createUser.reset(); bridge.createRoom.reset(); @@ -109,6 +116,7 @@ describe('FederationEE - Application - FederationRoomInternalHooksServiceSender' bridge.setRoomName.reset(); bridge.getRoomName.reset(); bridge.getRoomTopic.reset(); + bridge.getUserProfileInformation.reset(); }); describe('#onRoomCreated()', () => { @@ -285,27 +293,148 @@ describe('FederationEE - Application - FederationRoomInternalHooksServiceSender' }); describe('#beforeAddUserToARoom()', () => { + const federatedUser = FederatedUserEE.createInstance('externalInviteeId', { + name: 'normalizedInviteeId', + username: 'normalizedInviteeId', + existsOnlyOnProxyServer: false, + }); + const validParams = { + invitees: [ + ...invitees, + { + inviteeUsernameOnly: 'marcos.defendiNotToBeInvited', + normalizedInviteeId: 'marcos.defendi:matrix.comNotToBeInvited', + rawInviteeId: '@marcos.defendi:matrix.comNotToBeInvited', + }, + ], + } as any; + it('should create the invitee locally for each external user', async () => { + const avatarSpy = sinon.spy(service, 'updateUserAvatarInternally'); + const displayNameSpy = sinon.spy(service, 'updateUserDisplayNameInternally'); + bridge.extractHomeserverOrigin.onCall(0).returns('externalDomain'); bridge.extractHomeserverOrigin.onCall(1).returns('localDomain'); - await service.beforeAddUserToARoom({ - invitees: [ - ...invitees, - { - inviteeUsernameOnly: 'marcos.defendiNotToBeInvited', - normalizedInviteeId: 'marcos.defendi:matrix.comNotToBeInvited', - rawInviteeId: '@marcos.defendi:matrix.comNotToBeInvited', - }, - ], - } as any); + bridge.getUserProfileInformation.resolves({ avatarUrl: 'avatarUrl', displayName: 'displayName' }); + userAdapter.getFederatedUserByExternalId.resolves(federatedUser); + + await service.beforeAddUserToARoom(validParams); const invitee = FederatedUserEE.createLocalInstanceOnly({ - name: invitees[0].normalizedInviteeId, + name: 'displayName', username: invitees[0].normalizedInviteeId, existsOnlyOnProxyServer: false, }); expect(userAdapter.createLocalUser.calledOnceWithExactly(invitee)).to.be.true; + expect(avatarSpy.calledWith(federatedUser, 'avatarUrl')).to.be.true; + expect(displayNameSpy.calledWith(federatedUser, 'displayName')).to.be.true; + }); + + it('should NOT update the avatar nor the display name if both does not exists', async () => { + bridge.extractHomeserverOrigin.onCall(0).returns('externalDomain'); + bridge.extractHomeserverOrigin.onCall(1).returns('localDomain'); + bridge.getUserProfileInformation.resolves({ avatarUrl: '', displayName: '' }); + userAdapter.getFederatedUserByExternalId.resolves(federatedUser); + + await service.beforeAddUserToARoom(validParams); + + expect(userAdapter.setAvatar.called).to.be.false; + expect(userAdapter.updateFederationAvatar.called).to.be.false; + expect(userAdapter.updateRealName.called).to.be.false; + }); + + it('should NOT update the avatar url nor the display name if the user is from the local home server', async () => { + userAdapter.getFederatedUserByExternalId.resolves( + FederatedUserEE.createInstance('externalInviterId', { + name: 'normalizedInviterId', + username: 'normalizedInviterId', + existsOnlyOnProxyServer: true, + }), + ); + bridge.extractHomeserverOrigin.onCall(0).returns('externalDomain'); + bridge.extractHomeserverOrigin.onCall(1).returns('localDomain'); + bridge.getUserProfileInformation.resolves({ avatarUrl: 'avatarUrl', displayName: 'displayName' }); + + await service.beforeAddUserToARoom(validParams); + + expect(userAdapter.setAvatar.called).to.be.false; + expect(userAdapter.updateFederationAvatar.called).to.be.false; + expect(userAdapter.updateRealName.called).to.be.false; + }); + + it('should NOT update the avatar url if the url received in the event is equal to the one already used', async () => { + const existsOnlyOnProxyServer = false; + userAdapter.getFederatedUserByExternalId.resolves( + FederatedUserEE.createWithInternalReference('externalInviterId', existsOnlyOnProxyServer, { + federation: { + avatarUrl: 'avatarUrl', + }, + }), + ); + bridge.extractHomeserverOrigin.onCall(0).returns('externalDomain'); + bridge.extractHomeserverOrigin.onCall(1).returns('localDomain'); + bridge.getUserProfileInformation.resolves({ avatarUrl: 'avatarUrl', displayName: 'displayName' }); + + await service.beforeAddUserToARoom(validParams); + + expect(userAdapter.setAvatar.called).to.be.false; + expect(userAdapter.updateFederationAvatar.called).to.be.false; + }); + + it('should call the functions to update the avatar internally correctly', async () => { + const existsOnlyOnProxyServer = false; + const userAvatar = FederatedUserEE.createWithInternalReference('externalInviterId', existsOnlyOnProxyServer, { + federation: { + avatarUrl: 'currentAvatarUrl', + }, + _id: 'userId', + }); + userAdapter.getFederatedUserByExternalId.resolves(userAvatar); + bridge.extractHomeserverOrigin.onCall(0).returns('externalDomain'); + bridge.extractHomeserverOrigin.onCall(1).returns('localDomain'); + bridge.getUserProfileInformation.resolves({ avatarUrl: 'avatarUrl', displayName: 'displayName' }); + + await service.beforeAddUserToARoom(validParams); + + expect(userAdapter.setAvatar.calledWith(userAvatar, 'toHttpUrl')).to.be.true; + expect(userAdapter.updateFederationAvatar.calledWith(userAvatar.getInternalId(), 'avatarUrl')).to.be.true; + }); + + it('should NOT update the display name if the name received in the event is equal to the one already used', async () => { + const existsOnlyOnProxyServer = false; + userAdapter.getFederatedUserByExternalId.resolves( + FederatedUserEE.createWithInternalReference('externalInviterId', existsOnlyOnProxyServer, { + name: 'displayName', + }), + ); + bridge.extractHomeserverOrigin.onCall(0).returns('externalDomain'); + bridge.extractHomeserverOrigin.onCall(1).returns('localDomain'); + bridge.getUserProfileInformation.resolves({ avatarUrl: '', displayName: 'displayName' }); + + await service.beforeAddUserToARoom(validParams); + + expect(userAdapter.setAvatar.called).to.be.false; + expect(userAdapter.updateFederationAvatar.called).to.be.false; + expect(userAdapter.updateRealName.called).to.be.false; + }); + + it('should call the functions to update the display name internally correctly', async () => { + const existsOnlyOnProxyServer = false; + const user = FederatedUserEE.createWithInternalReference('externalInviterId', existsOnlyOnProxyServer, { + _id: 'userId', + name: 'currentName', + }); + userAdapter.getFederatedUserByExternalId.resolves(user); + bridge.extractHomeserverOrigin.onCall(0).returns('externalDomain'); + bridge.extractHomeserverOrigin.onCall(1).returns('localDomain'); + bridge.getUserProfileInformation.resolves({ avatarUrl: '', displayName: 'displayName' }); + + await service.beforeAddUserToARoom(validParams); + + expect(userAdapter.setAvatar.called).to.be.false; + expect(userAdapter.updateFederationAvatar.called).to.be.false; + expect(userAdapter.updateRealName.calledWith(user.getInternalReference(), 'displayName')).to.be.true; }); }); diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 4465d96e271d..9ef56f8cee41 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -2125,6 +2125,8 @@ "Federation_Matrix_bridge_url": "Bridge URL", "Federation_Matrix_bridge_localpart": "AppService User Localpart", "Federation_Matrix_registration_file": "Registration File", + "Federation_Matrix_registration_file_Alert": "Important: Enabling ephemeral events will make the server receive all the typing status of all users from all servers you are connected to.
To enable it, please update your registration file (.yaml file you are using to registrate Rocket.Chat to your home server), adding the following:
de.sorunome.msc2409.push_ephemeral: true", + "Federation_Matrix_enable_typing_status": "Enable User Typing Status", "Field": "Field", "Field_removed": "Field removed", "Field_required": "Field required", diff --git a/apps/meteor/server/models/raw/MatrixBridgedUser.ts b/apps/meteor/server/models/raw/MatrixBridgedUser.ts index 6290d6a2bcbb..11069a630512 100644 --- a/apps/meteor/server/models/raw/MatrixBridgedUser.ts +++ b/apps/meteor/server/models/raw/MatrixBridgedUser.ts @@ -32,6 +32,12 @@ export class MatrixBridgedUserRaw extends BaseRaw implements return bridgedUser ? bridgedUser.uid : null; } + async getLocalUsersByExternalIds(externalUserIds: string[]): Promise { + const bridgedUsers = await this.find({ mui: { $in: externalUserIds } }).toArray(); + + return bridgedUsers; + } + async getBridgedUserByLocalId(localUserId: string): Promise { return this.findOne({ uid: localUserId }); } diff --git a/apps/meteor/server/models/raw/Rooms.js b/apps/meteor/server/models/raw/Rooms.js index df99d042b75e..f6226398167a 100644 --- a/apps/meteor/server/models/raw/Rooms.js +++ b/apps/meteor/server/models/raw/Rooms.js @@ -662,4 +662,12 @@ export class RoomsRaw extends BaseRaw { return this.findOne(query, options); } + + findFederatedRooms(options) { + const query = { + federated: true, + }; + + return this.find(query, options); + } } diff --git a/apps/meteor/server/sdk/lib/Events.ts b/apps/meteor/server/sdk/lib/Events.ts index 6b4336d6d70c..6d1b1b3fc336 100644 --- a/apps/meteor/server/sdk/lib/Events.ts +++ b/apps/meteor/server/sdk/lib/Events.ts @@ -86,8 +86,10 @@ export type EventSignatures = { 'user.deleted'(user: Partial): void; 'user.deleteCustomStatus'(userStatus: IUserStatus): void; 'user.nameChanged'(user: Partial): void; + 'user.realNameChanged'(user: Partial): void; 'user.roleUpdate'(update: Record): void; 'user.updateCustomStatus'(userStatus: IUserStatus): void; + 'user.typing'(data: { user: Partial; isTyping: boolean; roomId: string }): void; 'presence.status'(data: { user: Pick; previousStatus: UserStatus | undefined; diff --git a/apps/meteor/server/services/federation/service.ts b/apps/meteor/server/services/federation/service.ts index 6d453f755ae0..5601e3efc142 100644 --- a/apps/meteor/server/services/federation/service.ts +++ b/apps/meteor/server/services/federation/service.ts @@ -1,16 +1,36 @@ import { ServiceClassInternal } from '../../sdk/types/ServiceClass'; import type { IFederationService } from '../../sdk/types/IFederationService'; import { federationUserServiceSender } from '../../../app/federation-v2/server'; +import { settings } from '../../../app/settings/server'; export class FederationService extends ServiceClassInternal implements IFederationService { protected name = 'federation'; async created() { this.onEvent('user.avatarUpdate', async ({ username }): Promise => { + if (!settings.get('Federation_Matrix_enabled')) { + return; + } if (!username) { return; } await federationUserServiceSender.afterUserAvatarChanged(username); }); + this.onEvent('user.typing', async ({ isTyping, roomId, user: { username } }): Promise => { + if (!roomId || !username) { + return; + } + + await federationUserServiceSender.onUserTyping(username, roomId, isTyping); + }); + this.onEvent('user.realNameChanged', async ({ _id, name }): Promise => { + if (!settings.get('Federation_Matrix_enabled')) { + return; + } + if (!name || !_id) { + return; + } + await federationUserServiceSender.afterUserRealNameChanged(_id, name); + }); } } diff --git a/apps/meteor/tests/unit/app/federation-v2/server/unit/application/RoomServiceListener.spec.ts b/apps/meteor/tests/unit/app/federation-v2/server/unit/application/RoomServiceListener.spec.ts index 13cebe2c47ce..9378240b9f7b 100644 --- a/apps/meteor/tests/unit/app/federation-v2/server/unit/application/RoomServiceListener.spec.ts +++ b/apps/meteor/tests/unit/app/federation-v2/server/unit/application/RoomServiceListener.spec.ts @@ -65,6 +65,7 @@ describe('Federation - Application - FederationRoomServiceListener', () => { updateFederationAvatar: sinon.stub(), setAvatar: sinon.stub(), getInternalUserByUsername: sinon.stub(), + updateRealName: sinon.stub(), }; const messageAdapter = { sendMessage: sinon.stub(), @@ -80,6 +81,10 @@ describe('Federation - Application - FederationRoomServiceListener', () => { const settingsAdapter = { getHomeServerDomain: sinon.stub().returns('localDomain'), }; + const notificationsAdapter = { + subscribeToUserTypingEventsOnFederatedRoomId: sinon.stub(), + broadcastUserTypingOnRoom: sinon.stub(), + }; const fileAdapter = { uploadFile: sinon.stub(), }; @@ -98,6 +103,7 @@ describe('Federation - Application - FederationRoomServiceListener', () => { messageAdapter as any, fileAdapter as any, settingsAdapter as any, + notificationsAdapter as any, bridge as any, ); }); @@ -107,7 +113,6 @@ describe('Federation - Application - FederationRoomServiceListener', () => { roomAdapter.createFederatedRoom.reset(); roomAdapter.createFederatedRoomForDirectMessage.reset(); roomAdapter.removeDirectMessageRoom.reset(); - roomAdapter.getFederatedRoomByExternalId.reset(); roomAdapter.updateRoomType.reset(); roomAdapter.updateRoomName.reset(); roomAdapter.updateFederatedRoomByInternalRoomId.reset(); @@ -121,6 +126,7 @@ describe('Federation - Application - FederationRoomServiceListener', () => { userAdapter.updateFederationAvatar.reset(); userAdapter.setAvatar.reset(); userAdapter.getInternalUserByUsername.reset(); + userAdapter.updateRealName.reset(); messageAdapter.sendMessage.reset(); messageAdapter.sendFileMessage.reset(); messageAdapter.deleteMessage.reset(); @@ -513,6 +519,27 @@ describe('Federation - Application - FederationRoomServiceListener', () => { expect(roomAdapter.addUserToRoom.called).to.be.false; }); + it('should NOT add the user to the room if its NOT a LEAVE event but the user is already in the room', async () => { + roomAdapter.getFederatedRoomByExternalId.resolves(room); + roomAdapter.isUserAlreadyJoined.resolves(true); + userAdapter.getFederatedUserByExternalId.resolves(user); + await service.onChangeRoomMembership({ + externalRoomId: 'externalRoomId', + normalizedRoomId: 'normalizedRoomId', + eventOrigin: EVENT_ORIGIN.LOCAL, + roomType: RoomType.CHANNEL, + externalInviteeId: 'externalInviteeId', + leave: false, + normalizedInviteeId: 'normalizedInviteeId', + } as any); + + expect(roomAdapter.removeUserFromRoom.called).to.be.false; + expect(roomAdapter.removeDirectMessageRoom.called).to.be.false; + expect(roomAdapter.createFederatedRoomForDirectMessage.called).to.be.false; + expect(bridge.joinRoom.called).to.be.false; + expect(roomAdapter.addUserToRoom.called).to.be.false; + }); + it('should add the user from room if its NOT a LEAVE event', async () => { roomAdapter.getFederatedRoomByExternalId.resolves(room); userAdapter.getFederatedUserByExternalId.resolves(user); @@ -533,8 +560,10 @@ describe('Federation - Application - FederationRoomServiceListener', () => { expect(roomAdapter.addUserToRoom.calledWith(room, user, user)).to.be.true; }); - describe('User avatar changed event', () => { - it('should NOT call the function to update the avatar internally if the event is not an avatar update event', async () => { + describe('User profile changed event', () => { + it('should NOT call the function to update the user avatar if the event does not include an avatarUrl property', async () => { + const spy = sinon.spy(service, 'updateUserAvatarInternally'); + await service.onChangeRoomMembership({ externalRoomId: 'externalRoomId', normalizedRoomId: 'normalizedRoomId', @@ -545,10 +574,10 @@ describe('Federation - Application - FederationRoomServiceListener', () => { normalizedInviteeId: 'normalizedInviteeId', } as any); - expect(bridge.getUserProfileInformation.called).to.be.false; + expect(spy.called).to.be.false; }); - const eventForAvatarChanges = { + const eventForUserProfileChanges = { externalRoomId: 'externalRoomId', normalizedRoomId: 'normalizedRoomId', eventOrigin: EVENT_ORIGIN.LOCAL, @@ -556,26 +585,30 @@ describe('Federation - Application - FederationRoomServiceListener', () => { externalInviteeId: 'externalInviteeId', leave: false, normalizedInviteeId: 'normalizedInviteeId', - userAvatarUrl: 'userAvatarUrl', + userProfile: { + avatarUrl: 'avatarUrl', + displayName: 'displayName', + }, } as any; it('should NOT call the function to update the avatar internally if the user does not exists', async () => { + const spy = sinon.spy(service, 'updateUserAvatarInternally'); userAdapter.getFederatedUserByExternalId.resolves(undefined); - await service.onChangeRoomMembership(eventForAvatarChanges); + await service.onChangeRoomMembership(eventForUserProfileChanges); - expect(bridge.getUserProfileInformation.called).to.be.false; + expect(spy.called).to.be.false; }); - it('should NOT update the avatar url if the url retrieved from the home server does not exists', async () => { + it('should NOT update the avatar nor the display name if both does not exists', async () => { userAdapter.getFederatedUserByExternalId.resolves(user); - bridge.getUserProfileInformation.resolves(undefined); - await service.onChangeRoomMembership(eventForAvatarChanges); + await service.onChangeRoomMembership({ ...eventForUserProfileChanges, userProfile: {} }); expect(userAdapter.setAvatar.called).to.be.false; expect(userAdapter.updateFederationAvatar.called).to.be.false; + expect(userAdapter.updateRealName.called).to.be.false; }); - it('should NOT update the avatar url if the user is from the local home server', async () => { + it('should NOT update the avatar url nor the display name if the user is from the local home server', async () => { userAdapter.getFederatedUserByExternalId.resolves( FederatedUser.createInstance('externalInviterId', { name: 'normalizedInviterId', @@ -583,24 +616,23 @@ describe('Federation - Application - FederationRoomServiceListener', () => { existsOnlyOnProxyServer: true, }), ); - bridge.getUserProfileInformation.resolves({ avatarUrl: 'currentAvatarUrl' }); - await service.onChangeRoomMembership(eventForAvatarChanges); + await service.onChangeRoomMembership(eventForUserProfileChanges); expect(userAdapter.setAvatar.called).to.be.false; expect(userAdapter.updateFederationAvatar.called).to.be.false; + expect(userAdapter.updateRealName.called).to.be.false; }); - it('should NOT update the avatar url if the url retrieved from the home server is equal to the one already used', async () => { + it('should NOT update the avatar url if the url received in the event is equal to the one already used', async () => { const existsOnlyOnProxyServer = false; - bridge.getUserProfileInformation.resolves({ avatarUrl: 'currentAvatarUrl' }); userAdapter.getFederatedUserByExternalId.resolves( FederatedUser.createWithInternalReference('externalInviterId', existsOnlyOnProxyServer, { federation: { - avatarUrl: 'currentAvatarUrl', + avatarUrl: 'avatarUrl', }, }), ); - await service.onChangeRoomMembership(eventForAvatarChanges); + await service.onChangeRoomMembership({ ...eventForUserProfileChanges, userProfile: { avatarUrl: 'avatarUrl' } }); expect(userAdapter.setAvatar.called).to.be.false; expect(userAdapter.updateFederationAvatar.called).to.be.false; @@ -614,12 +646,39 @@ describe('Federation - Application - FederationRoomServiceListener', () => { }, _id: 'userId', }); - bridge.getUserProfileInformation.resolves({ avatarUrl: 'newAvatarUrl' }); userAdapter.getFederatedUserByExternalId.resolves(userAvatar); - await service.onChangeRoomMembership(eventForAvatarChanges); + await service.onChangeRoomMembership(eventForUserProfileChanges); expect(userAdapter.setAvatar.calledWith(userAvatar, 'toHttpUrl')).to.be.true; - expect(userAdapter.updateFederationAvatar.calledWith(userAvatar.getInternalId(), 'newAvatarUrl')).to.be.true; + expect(userAdapter.updateFederationAvatar.calledWith(userAvatar.getInternalId(), 'avatarUrl')).to.be.true; + }); + + it('should NOT update the display name if the name received in the event is equal to the one already used', async () => { + const existsOnlyOnProxyServer = false; + userAdapter.getFederatedUserByExternalId.resolves( + FederatedUser.createWithInternalReference('externalInviterId', existsOnlyOnProxyServer, { + name: 'displayName', + }), + ); + await service.onChangeRoomMembership({ ...eventForUserProfileChanges, userProfile: { displayName: 'displayName' } }); + + expect(userAdapter.setAvatar.called).to.be.false; + expect(userAdapter.updateFederationAvatar.called).to.be.false; + expect(userAdapter.updateRealName.called).to.be.false; + }); + + it('should call the functions to update the display name internally correctly', async () => { + const existsOnlyOnProxyServer = false; + const user = FederatedUser.createWithInternalReference('externalInviterId', existsOnlyOnProxyServer, { + _id: 'userId', + name: 'currentName', + }); + userAdapter.getFederatedUserByExternalId.resolves(user); + await service.onChangeRoomMembership({ ...eventForUserProfileChanges, userProfile: { displayName: 'displayName' } }); + + expect(userAdapter.setAvatar.called).to.be.false; + expect(userAdapter.updateFederationAvatar.called).to.be.false; + expect(userAdapter.updateRealName.calledWith(user.getInternalReference(), 'displayName')).to.be.true; }); }); }); diff --git a/apps/meteor/tests/unit/app/federation-v2/server/unit/application/UserServiceListener.spec.ts b/apps/meteor/tests/unit/app/federation-v2/server/unit/application/UserServiceListener.spec.ts new file mode 100644 index 000000000000..b89dd549b6c9 --- /dev/null +++ b/apps/meteor/tests/unit/app/federation-v2/server/unit/application/UserServiceListener.spec.ts @@ -0,0 +1,175 @@ +/* eslint-disable import/first */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import proxyquire from 'proxyquire'; + +const { FederationUserServiceListener } = proxyquire + .noCallThru() + .load('../../../../../../../app/federation-v2/server/application/UserServiceListener', { + mongodb: { + 'ObjectId': class ObjectId { + toHexString(): string { + return 'hexString'; + } + }, + '@global': true, + }, + }); + +const { FederatedUser } = proxyquire.noCallThru().load('../../../../../../../app/federation-v2/server/domain/FederatedUser', { + mongodb: { + 'ObjectId': class ObjectId { + toHexString(): string { + return 'hexString'; + } + }, + '@global': true, + }, +}); + +const { FederatedRoom } = proxyquire.noCallThru().load('../../../../../../../app/federation-v2/server/domain/FederatedRoom', { + mongodb: { + 'ObjectId': class ObjectId { + toHexString(): string { + return 'hexString'; + } + }, + '@global': true, + }, +}); + +describe('Federation - Application - FederationUserServiceListener', () => { + let service: typeof FederationUserServiceListener; + const roomAdapter = { + getFederatedRoomByExternalId: sinon.stub(), + }; + const userAdapter = { + getFederatedUsersByExternalIds: sinon.stub(), + }; + const settingsAdapter = { + getHomeServerDomain: sinon.stub().returns('localDomain'), + }; + const fileAdapter = {}; + const notificationAdapter = { + notifyUserTypingOnRoom: sinon.stub(), + }; + const bridge = {}; + + beforeEach(() => { + service = new FederationUserServiceListener( + roomAdapter as any, + userAdapter as any, + fileAdapter as any, + notificationAdapter as any, + settingsAdapter as any, + bridge as any, + ); + }); + + afterEach(() => { + roomAdapter.getFederatedRoomByExternalId.reset(); + userAdapter.getFederatedUsersByExternalIds.reset(); + notificationAdapter.notifyUserTypingOnRoom.reset(); + service.usersTypingByRoomIdCache.clear(); + }); + + describe('#onUserTyping()', () => { + const user = FederatedUser.createInstance('externalInviterId', { + name: 'normalizedInviterId', + username: 'normalizedInviterId', + existsOnlyOnProxyServer: false, + }); + const room = FederatedRoom.createInstance('externalRoomId', 'normalizedRoomId', user, RoomType.CHANNEL, 'externalRoomName'); + + it('should NOT notify about the typing event internally if the room does not exists', async () => { + roomAdapter.getFederatedRoomByExternalId.resolves(undefined); + await service.onUserTyping({} as any); + + expect(userAdapter.getFederatedUsersByExternalIds.called).to.be.false; + expect(notificationAdapter.notifyUserTypingOnRoom.called).to.be.false; + }); + + it('should NOT notify about the typing nor not typing event internally there is no external users typing', async () => { + roomAdapter.getFederatedRoomByExternalId.resolves({}); + userAdapter.getFederatedUsersByExternalIds.resolves([]); + await service.onUserTyping({ + externalRoomId: 'externalRoomId', + externalUserIdsTyping: ['id1', 'id2', 'id3'], + } as any); + + expect(notificationAdapter.notifyUserTypingOnRoom.called).to.be.false; + }); + + it('should NOT notify about internally when all external users are still styping', async () => { + roomAdapter.getFederatedRoomByExternalId.resolves(room); + userAdapter.getFederatedUsersByExternalIds.resolves([]); + service.usersTypingByRoomIdCache.set('externalRoomId', [ + { externalUserId: 'id1', username: 'id1' }, + { externalUserId: 'id2', username: 'id2' }, + { externalUserId: 'id3', username: 'id3' }, + ]); + await service.onUserTyping({ + externalRoomId: 'externalRoomId', + externalUserIdsTyping: ['id1', 'id2', 'id3'], + } as any); + + expect(notificationAdapter.notifyUserTypingOnRoom.called).to.be.false; + expect(service.usersTypingByRoomIdCache.get('externalRoomId')).to.deep.equal([ + { externalUserId: 'id1', username: 'id1' }, + { externalUserId: 'id2', username: 'id2' }, + { externalUserId: 'id3', username: 'id3' }, + ]); + }); + + it('should notify about internally when the external users stopped typing', async () => { + const notTypingAnymore = ['id2', 'id3']; + roomAdapter.getFederatedRoomByExternalId.resolves(room); + userAdapter.getFederatedUsersByExternalIds.resolves([]); + service.usersTypingByRoomIdCache.set('externalRoomId', [ + { externalUserId: 'id1', username: 'id1' }, + { externalUserId: 'id2', username: 'id2' }, + { externalUserId: 'id3', username: 'id3' }, + ]); + await service.onUserTyping({ + externalRoomId: 'externalRoomId', + externalUserIdsTyping: ['id1'], + } as any); + + notTypingAnymore.forEach((username) => { + expect(notificationAdapter.notifyUserTypingOnRoom.calledWith('hexString', username, false)).to.be.true; + }); + expect(service.usersTypingByRoomIdCache.get('externalRoomId')).to.deep.equal([{ externalUserId: 'id1', username: 'id1' }]); + }); + + it('should notify about internally when one users stopped typing and other started it', async () => { + const notTypingAnymore = ['id3', 'id4']; + const startedTyping = ['id1', 'id2']; + const user1 = FederatedUser.createWithInternalReference('!externalId@id1', true, { _id: 'id1', username: 'id1' } as any); + const user2 = FederatedUser.createWithInternalReference('!externalId@id2', true, { _id: 'id2', username: 'id2' } as any); + roomAdapter.getFederatedRoomByExternalId.resolves(room); + userAdapter.getFederatedUsersByExternalIds.resolves([user1, user2]); + service.usersTypingByRoomIdCache.set('externalRoomId', [ + { externalUserId: 'id1', username: 'id1' }, + { externalUserId: 'id2', username: 'id2' }, + { externalUserId: 'id3', username: 'id3' }, + { externalUserId: 'id4', username: 'id4' }, + ]); + await service.onUserTyping({ + externalRoomId: 'externalRoomId', + externalUserIdsTyping: ['id1', 'id2'], + } as any); + + notTypingAnymore.forEach((username) => { + expect(notificationAdapter.notifyUserTypingOnRoom.calledWith('hexString', username, false)).to.be.true; + }); + startedTyping.forEach((username) => { + expect(notificationAdapter.notifyUserTypingOnRoom.calledWith('hexString', username, true)).to.be.true; + }); + expect(service.usersTypingByRoomIdCache.get('externalRoomId')).to.deep.equal([ + { externalUserId: 'id1', username: 'id1' }, + { externalUserId: 'id2', username: 'id2' }, + ]); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/federation-v2/server/unit/application/sender/RoomServiceSender.spec.ts b/apps/meteor/tests/unit/app/federation-v2/server/unit/application/sender/RoomServiceSender.spec.ts index 96833d6e5fe0..cb5379b28861 100644 --- a/apps/meteor/tests/unit/app/federation-v2/server/unit/application/sender/RoomServiceSender.spec.ts +++ b/apps/meteor/tests/unit/app/federation-v2/server/unit/application/sender/RoomServiceSender.spec.ts @@ -72,6 +72,10 @@ describe('Federation - Application - FederationRoomServiceSender', () => { getMessageById: sinon.stub(), setExternalFederationEventOnMessage: sinon.stub(), }; + const notificationsAdapter = { + subscribeToUserTypingEventsOnFederatedRoomId: sinon.stub(), + broadcastUserTypingOnRoom: sinon.stub(), + }; const bridge = { getUserProfileInformation: sinon.stub().resolves({}), extractHomeserverOrigin: sinon.stub(), @@ -93,6 +97,7 @@ describe('Federation - Application - FederationRoomServiceSender', () => { fileAdapter as any, messageAdapter as any, settingsAdapter as any, + notificationsAdapter as any, bridge as any, ); }); diff --git a/apps/meteor/tests/unit/app/federation-v2/server/unit/application/sender/UserServiceSender.spec.ts b/apps/meteor/tests/unit/app/federation-v2/server/unit/application/sender/UserServiceSender.spec.ts index e766f9135c72..dd242d8ade19 100644 --- a/apps/meteor/tests/unit/app/federation-v2/server/unit/application/sender/UserServiceSender.spec.ts +++ b/apps/meteor/tests/unit/app/federation-v2/server/unit/application/sender/UserServiceSender.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import proxyquire from 'proxyquire'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; const { FederationUserServiceSender } = proxyquire .noCallThru() @@ -26,16 +27,31 @@ const { FederatedUser } = proxyquire.noCallThru().load('../../../../../../../../ }, }); +const { FederatedRoom } = proxyquire.noCallThru().load('../../../../../../../../app/federation-v2/server/domain/FederatedRoom', { + mongodb: { + 'ObjectId': class ObjectId { + toHexString(): string { + return 'hexString'; + } + }, + '@global': true, + }, +}); + describe('Federation - Application - FederationUserServiceSender', () => { let service: typeof FederationUserServiceSender; const userAdapter = { getFederatedUserByExternalId: sinon.stub(), getFederatedUserByInternalId: sinon.stub(), + getInternalUserById: sinon.stub(), updateFederationAvatar: sinon.stub(), getFederatedUserByInternalUsername: sinon.stub(), + getInternalUserByUsername: sinon.stub(), + createFederatedUser: sinon.stub(), }; const settingsAdapter = { getHomeServerDomain: sinon.stub().returns('localDomain'), + isTypingStatusEnabled: sinon.stub(), }; const fileAdapter = { getBufferForAvatarFile: sinon.stub(), @@ -44,21 +60,46 @@ describe('Federation - Application - FederationUserServiceSender', () => { const bridge = { uploadContent: sinon.stub(), setUserAvatar: sinon.stub(), + notifyUserTyping: sinon.stub(), + setUserDisplayName: sinon.stub(), + createUser: sinon.stub(), + getUserProfileInformation: sinon.stub(), + }; + const roomAdapter = { + getFederatedRoomByInternalId: sinon.stub(), + setUserDisplayName: sinon.stub(), + createUser: sinon.stub(), + getUserProfileInformation: sinon.stub(), }; beforeEach(() => { - service = new FederationUserServiceSender(userAdapter as any, fileAdapter as any, settingsAdapter as any, bridge as any); + service = new FederationUserServiceSender( + roomAdapter as any, + userAdapter as any, + fileAdapter as any, + settingsAdapter as any, + bridge as any, + ); }); afterEach(() => { userAdapter.getFederatedUserByInternalId.reset(); userAdapter.getFederatedUserByExternalId.reset(); userAdapter.updateFederationAvatar.reset(); + userAdapter.getInternalUserById.reset(); + userAdapter.getInternalUserByUsername.reset(); userAdapter.getFederatedUserByInternalUsername.reset(); + userAdapter.createFederatedUser.reset(); fileAdapter.getBufferForAvatarFile.reset(); fileAdapter.getFileMetadataForAvatarFile.reset(); bridge.uploadContent.reset(); bridge.setUserAvatar.reset(); + bridge.notifyUserTyping.reset(); + settingsAdapter.isTypingStatusEnabled.reset(); + roomAdapter.getFederatedRoomByInternalId.reset(); + bridge.setUserDisplayName.reset(); + bridge.createUser.reset(); + bridge.getUserProfileInformation.reset(); }); describe('#afterUserAvatarChanged()', () => { @@ -67,11 +108,25 @@ describe('Federation - Application - FederationUserServiceSender', () => { username: 'normalizedInviterId', existsOnlyOnProxyServer: true, }); - it('should NOT update the avatar externally if the user does not exists', async () => { + + it('should NOT update the avatar externally if the user does not exists remotely nor locally', async () => { + const spy = sinon.spy(service, 'createFederatedUserIncludingHomeserverUsingLocalInformation'); userAdapter.getFederatedUserByInternalUsername.resolves(undefined); + userAdapter.getInternalUserByUsername.resolves(undefined); await service.afterUserAvatarChanged({} as any); expect(fileAdapter.getBufferForAvatarFile.called).to.be.false; + expect(spy.called).to.be.false; + }); + + it('should create a federated user first if it does not exists yet, but it does exists locally only (the case when the local user didnt have any contact with federation yet', async () => { + const spy = sinon.spy(service, 'createFederatedUserIncludingHomeserverUsingLocalInformation'); + userAdapter.getFederatedUserByInternalUsername.resolves(undefined); + userAdapter.getInternalUserById.resolves({ username: 'username' }); + userAdapter.getInternalUserByUsername.resolves({ _id: 'id' }); + await service.afterUserAvatarChanged({} as any); + + expect(spy.calledWith('id')).to.be.true; }); it('should NOT update the avatar externally if the user exists but is from an external home server', async () => { @@ -141,4 +196,113 @@ describe('Federation - Application - FederationUserServiceSender', () => { expect(bridge.setUserAvatar.calledWith('externalInviterId', 'url')).to.be.true; }); }); + + describe('#afterUserRealNameChanged()', () => { + it('should NOT update the name externally if the user does not exists remotely nor locally', async () => { + const spy = sinon.spy(service, 'createFederatedUserIncludingHomeserverUsingLocalInformation'); + userAdapter.getFederatedUserByInternalId.resolves(undefined); + userAdapter.getInternalUserById.resolves(undefined); + await service.afterUserRealNameChanged('id', 'name'); + + expect(bridge.setUserDisplayName.called).to.be.false; + expect(spy.called).to.be.false; + }); + + it('should create a federated user first if it does not exists yet, but it does exists locally only (the case when the local user didnt have any contact with federation yet', async () => { + const spy = sinon.spy(service, 'createFederatedUserIncludingHomeserverUsingLocalInformation'); + userAdapter.getFederatedUserByInternalId.resolves(undefined); + userAdapter.getInternalUserById.resolves({ _id: 'id', username: 'username' }); + await service.afterUserRealNameChanged('id', 'name'); + + expect(spy.calledWith('id')).to.be.true; + }); + + it('should NOT update the name externally if the user exists but is from an external home server', async () => { + userAdapter.getFederatedUserByInternalId.resolves( + FederatedUser.createInstance('externalInviterId', { + name: 'normalizedInviterId', + username: 'normalizedInviterId', + existsOnlyOnProxyServer: false, + }), + ); + await service.afterUserRealNameChanged('id', 'name'); + + expect(bridge.setUserDisplayName.called).to.be.false; + }); + + it('should NOT update the name externally if the external username is equal to the current one', async () => { + userAdapter.getFederatedUserByInternalId.resolves( + FederatedUser.createInstance('externalInviterId', { + name: 'normalizedInviterId', + username: 'normalizedInviterId', + existsOnlyOnProxyServer: false, + }), + ); + bridge.getUserProfileInformation.resolves({ displayname: 'normalizedInviterId' }); + await service.afterUserRealNameChanged('id', 'name'); + + expect(bridge.setUserDisplayName.called).to.be.false; + }); + + it('should update the name externally correctly', async () => { + userAdapter.getFederatedUserByInternalId.resolves( + FederatedUser.createWithInternalReference('externalInviterId', true, { + name: 'normalizedInviterId', + username: 'normalizedInviterId', + _id: '_id', + }), + ); + bridge.getUserProfileInformation.resolves({ displayname: 'different' }); + await service.afterUserRealNameChanged('id', 'name'); + + expect(bridge.setUserDisplayName.calledWith('externalInviterId', 'name')).to.be.true; + }); + }); + + describe('#onUserTyping()', () => { + const user = FederatedUser.createWithInternalReference('externalInviterId', true, { + name: 'normalizedInviterId', + username: 'normalizedInviterId', + _id: '_id', + }); + it('should NOT notify about the typing event externally if the setting is disabled', async () => { + settingsAdapter.isTypingStatusEnabled.returns(false); + await service.onUserTyping({} as any); + + expect(bridge.notifyUserTyping.called).to.be.false; + }); + + it('should NOT notify about the typing event externally if the user does not exists', async () => { + settingsAdapter.isTypingStatusEnabled.returns(true); + userAdapter.getFederatedUserByInternalUsername.resolves(undefined); + await service.onUserTyping({} as any); + + expect(bridge.notifyUserTyping.called).to.be.false; + }); + + it('should NOT notify about the typing event externally if the room does not exists', async () => { + settingsAdapter.isTypingStatusEnabled.returns(true); + userAdapter.getFederatedUserByInternalUsername.resolves({}); + await service.onUserTyping({} as any); + + expect(bridge.notifyUserTyping.called).to.be.false; + }); + + it('should notify about the typing event externally correctly', async () => { + const room = FederatedRoom.createInstance('externalRoomId', 'normalizedRoomId', user, RoomType.CHANNEL, 'externalRoomName'); + settingsAdapter.isTypingStatusEnabled.returns(true); + userAdapter.getFederatedUserByInternalUsername.resolves( + FederatedUser.createWithInternalReference('externalInviterId', true, { + name: 'normalizedInviterId', + username: 'normalizedInviterId', + _id: '_id', + }), + ); + roomAdapter.getFederatedRoomByInternalId.resolves(room); + const isTyping = true; + await service.onUserTyping('internalUsername', 'internalRoomId', isTyping); + + expect(bridge.notifyUserTyping.calledWith(room.getExternalId(), user.getExternalId(), isTyping)).to.be.true; + }); + }); }); diff --git a/apps/meteor/tests/unit/app/federation-v2/server/unit/domain/FederatedUser.spec.ts b/apps/meteor/tests/unit/app/federation-v2/server/unit/domain/FederatedUser.spec.ts index 9ed307fd95ed..37275d952e32 100644 --- a/apps/meteor/tests/unit/app/federation-v2/server/unit/domain/FederatedUser.spec.ts +++ b/apps/meteor/tests/unit/app/federation-v2/server/unit/domain/FederatedUser.spec.ts @@ -186,4 +186,20 @@ describe('Federation - Domain - FederatedUser', () => { expect(federatedUser.shouldUpdateFederationAvatar('url2')).to.be.equal(true); }); }); + + describe('#shouldUpdateDisplayName()', () => { + it('should return false if the current name is equal to the desired one', () => { + const federatedUser = FederatedUser.createWithInternalReference('externalId', true, { + name: 'name', + } as any); + expect(federatedUser.shouldUpdateDisplayName('name')).to.be.equal(false); + }); + + it('should return true if the current name is different to the desired one', () => { + const federatedUser = FederatedUser.createWithInternalReference('externalId', true, { + name: 'name', + } as any); + expect(federatedUser.shouldUpdateFederationAvatar('name')).to.be.equal(true); + }); + }); }); diff --git a/apps/meteor/tests/unit/app/federation-v2/server/unit/infrastructure/matrix/converters/RoomReceiver.spec.ts b/apps/meteor/tests/unit/app/federation-v2/server/unit/infrastructure/matrix/converters/RoomReceiver.spec.ts index 608884b05beb..4b45ad57d0f9 100644 --- a/apps/meteor/tests/unit/app/federation-v2/server/unit/infrastructure/matrix/converters/RoomReceiver.spec.ts +++ b/apps/meteor/tests/unit/app/federation-v2/server/unit/infrastructure/matrix/converters/RoomReceiver.spec.ts @@ -89,7 +89,7 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', ( describe('#toChangeRoomMembershipDto()', () => { const event = { - content: { name: 'roomName', avatar_url: 'avatar_url' }, + content: { name: 'roomName' }, event_id: 'eventId', room_id: '!roomId:matrix.org', sender: '@marcos.defendi:matrix.org', @@ -196,49 +196,14 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', ( expect(result.eventOrigin).to.be.equal(EVENT_ORIGIN.LOCAL); }); - it('should return the avatarUrl if the event is equal to join', () => { + it('should return the user profile properties when the event contains those infos', () => { const result = MatrixRoomReceiverConverter.toChangeRoomMembershipDto( - { ...event, content: { ...event.content, membership: 'join' } } as any, + { ...event, content: { avatar_url: 'avatarUrl', displayname: 'displayname', membership: 'join' } } as any, 'domain', ); - expect(result).to.be.eql({ - externalEventId: 'eventId', - externalRoomId: '!roomId:matrix.org', - normalizedRoomId: 'roomId', - externalInviterId: '@marcos.defendi:matrix.org', - normalizedInviterId: 'marcos.defendi:matrix.org', - externalInviteeId: '@marcos.defendi2:matrix.org', - normalizedInviteeId: 'marcos.defendi2:matrix.org', - inviteeUsernameOnly: 'marcos.defendi2', - inviterUsernameOnly: 'marcos.defendi', - eventOrigin: EVENT_ORIGIN.REMOTE, - leave: false, - externalRoomName: undefined, - roomType: undefined, - userAvatarUrl: 'avatar_url', - }); - }); - - it('should NOT return the avatarUrl if the event is different from join', () => { - const result = MatrixRoomReceiverConverter.toChangeRoomMembershipDto( - { ...event, content: { ...event.content, membership: 'invite' } } as any, - 'domain', - ); - expect(result).to.be.eql({ - externalEventId: 'eventId', - externalRoomId: '!roomId:matrix.org', - normalizedRoomId: 'roomId', - externalInviterId: '@marcos.defendi:matrix.org', - normalizedInviterId: 'marcos.defendi:matrix.org', - externalInviteeId: '@marcos.defendi2:matrix.org', - normalizedInviteeId: 'marcos.defendi2:matrix.org', - inviteeUsernameOnly: 'marcos.defendi2', - inviterUsernameOnly: 'marcos.defendi', - eventOrigin: EVENT_ORIGIN.REMOTE, - leave: false, - externalRoomName: undefined, - roomType: undefined, - userAvatarUrl: undefined, + expect(result.userProfile).to.be.eql({ + avatarUrl: 'avatarUrl', + displayName: 'displayname', }); }); @@ -258,7 +223,10 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', ( leave: false, externalRoomName: undefined, roomType: undefined, - userAvatarUrl: undefined, + userProfile: { + avatarUrl: undefined, + displayName: undefined, + }, }); }); }); diff --git a/apps/meteor/tests/unit/app/federation-v2/server/unit/infrastructure/matrix/converters/UserReceiver.spec.ts b/apps/meteor/tests/unit/app/federation-v2/server/unit/infrastructure/matrix/converters/UserReceiver.spec.ts new file mode 100644 index 000000000000..d8489aa37f9c --- /dev/null +++ b/apps/meteor/tests/unit/app/federation-v2/server/unit/infrastructure/matrix/converters/UserReceiver.spec.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai'; + +import { MatrixUserReceiverConverter } from '../../../../../../../../../app/federation-v2/server/infrastructure/matrix/converters/UserReceiver'; +import { FederationUserTypingStatusEventDto } from '../../../../../../../../../app/federation-v2/server/application/input/UserReceiverDto'; + +describe('Federation - Infrastructure - Matrix - MatrixUserReceiverConverter', () => { + describe('#toUserTypingDto()', () => { + const event = { + content: { user_ids: ['id'] }, + room_id: '!roomId:matrix.org', + }; + + it('should return an instance of FederationUserTypingStatusEventDto', () => { + expect(MatrixUserReceiverConverter.toUserTypingDto(event as any)).to.be.instanceOf(FederationUserTypingStatusEventDto); + }); + + it('should convert the event properly', () => { + const result = MatrixUserReceiverConverter.toUserTypingDto(event as any); + expect(result).to.be.eql({ + externalEventId: '', + externalRoomId: event.room_id, + externalUserIdsTyping: event.content.user_ids, + normalizedRoomId: 'roomId', + }); + }); + }); +}); diff --git a/packages/model-typings/src/models/IMatrixBridgeUserModel.ts b/packages/model-typings/src/models/IMatrixBridgeUserModel.ts index 7b9fefddcd4c..2b3c14d24ae2 100644 --- a/packages/model-typings/src/models/IMatrixBridgeUserModel.ts +++ b/packages/model-typings/src/models/IMatrixBridgeUserModel.ts @@ -6,6 +6,7 @@ export interface IMatrixBridgedUserModel extends IBaseModel getExternalUserIdByLocalUserId(localUserId: string): Promise; getBridgedUserByExternalUserId(externalUserId: string): Promise; getLocalUserIdByExternalId(externalUserId: string): Promise; + getLocalUsersByExternalIds(externalUserIds: string[]): Promise; getBridgedUserByLocalId(localUserId: string): Promise; createOrUpdateByLocalId(localUserId: string, externalUserId: string, remote: boolean): Promise; } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 6d2213986e0d..ec0eaa6a7900 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -99,4 +99,6 @@ export interface IRoomsModel extends IBaseModel { ): FindPaginated>; findPaginatedByTypeAndIds(type: IRoom['t'], ids: string[], options?: FindOptions): FindPaginated>; + + findFederatedRooms(options?: FindOptions): FindCursor; }