diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 954e211263c6..07755329fd60 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -92,7 +92,7 @@ jobs: df -h - name: Cache meteor local - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ./apps/meteor/.meteor/local key: meteor-local-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/versions') }} @@ -100,7 +100,7 @@ jobs: meteor-local-cache-${{ runner.os }}- - name: Cache meteor - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.meteor key: meteor-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/release') }} @@ -156,9 +156,8 @@ jobs: - name: Reset Meteor if: startsWith(github.ref, 'refs/tags/') == 'true' || github.ref == 'refs/heads/develop' - run: | - cd ./apps/meteor - meteor reset + working-directory: ./apps/meteor + run: meteor reset - name: Build Rocket.Chat From Pull Request if: startsWith(github.ref, 'refs/pull/') == true @@ -232,6 +231,29 @@ jobs: mongodb-version: ${{ matrix.mongodb-version }} mongodb-replica-set: rs0 + - name: Docker env vars + id: docker-env + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + echo "LOWERCASE_REPOSITORY: ${LOWERCASE_REPOSITORY}" + echo "::set-output name=lowercase-repo::${LOWERCASE_REPOSITORY}" + + # test alpine image on mongo 5.0 (no special reason to be mongo 5.0 but we need to test alpine at least once) + if [[ '${{ matrix.mongodb-version }}' = '5.0' ]]; then + RC_DOCKERFILE="${{ github.workspace }}/apps/meteor/.docker/Dockerfile.alpine" + RC_DOCKER_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}.alpine" + else + RC_DOCKERFILE="${{ github.workspace }}/apps/meteor/.docker/Dockerfile" + RC_DOCKER_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}.official" + fi; + + echo "RC_DOCKERFILE: ${RC_DOCKERFILE}" + echo "::set-output name=rc-dockerfile::${RC_DOCKERFILE}" + + echo "RC_DOCKER_TAG: ${RC_DOCKER_TAG}" + echo "::set-output name=rc-docker-tag::${RC_DOCKER_TAG}" + - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} @@ -264,29 +286,6 @@ jobs: tar xzf Rocket.Chat.tar.gz rm Rocket.Chat.tar.gz - - name: Docker env vars - id: docker-env - run: | - LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - - echo "LOWERCASE_REPOSITORY: ${LOWERCASE_REPOSITORY}" - echo "::set-output name=lowercase-repo::${LOWERCASE_REPOSITORY}" - - # test alpine image on mongo 5.0 (no special reason to be mongo 5.0 but we need to test alpine at least once) - if [[ '${{ matrix.mongodb-version }}' = '5.0' ]]; then - RC_DOCKERFILE="${{ github.workspace }}/apps/meteor/.docker/Dockerfile.alpine" - RC_DOCKER_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}.alpine" - else - RC_DOCKERFILE="${{ github.workspace }}/apps/meteor/.docker/Dockerfile" - RC_DOCKER_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}.official" - fi; - - echo "RC_DOCKERFILE: ${RC_DOCKERFILE}" - echo "::set-output name=rc-dockerfile::${RC_DOCKERFILE}" - - echo "RC_DOCKER_TAG: ${RC_DOCKER_TAG}" - echo "::set-output name=rc-docker-tag::${RC_DOCKER_TAG}" - - name: Start containers env: MONGO_URL: 'mongodb://host.docker.internal:27017/rocketchat?replicaSet=rs0&directConnection=true' @@ -298,14 +297,6 @@ jobs: run: | docker compose -f docker-compose-ci.yml up -d --build rocketchat - sleep 10 - - until echo "$(docker compose -f docker-compose-ci.yml logs rocketchat)" | grep -q "SERVER RUNNING"; do - echo "Waiting Rocket.Chat to start up" - ((c++)) && ((c==10)) && docker compose -f docker-compose-ci.yml logs rocketchat && exit 1 - sleep 10 - done - - name: Login to GitHub Container Registry if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' uses: docker/login-action@v2 @@ -333,7 +324,7 @@ jobs: docker push $IMAGE_NAME_BASE fi; - - name: E2E Test API + - name: Wait for Rocket.Chat to start up env: LOWERCASE_REPOSITORY: ${{ steps.docker-env.outputs.lowercase-repo }} RC_DOCKERFILE: ${{ steps.docker-env.outputs.rc-dockerfile }} @@ -341,9 +332,21 @@ jobs: DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} run: | docker ps - docker compose -f docker-compose-ci.yml logs rocketchat --tail=50 - cd ./apps/meteor + until echo "$(docker compose -f docker-compose-ci.yml logs rocketchat)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && docker compose -f docker-compose-ci.yml logs rocketchat && exit 1 + sleep 10 + done; + + - name: E2E Test API + env: + LOWERCASE_REPOSITORY: ${{ steps.docker-env.outputs.lowercase-repo }} + RC_DOCKERFILE: ${{ steps.docker-env.outputs.rc-dockerfile }} + RC_DOCKER_TAG: ${{ steps.docker-env.outputs.rc-docker-tag }} + DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} + working-directory: ./apps/meteor + run: | for i in $(seq 1 5); do npm run testapi && s=0 && break || s=$? @@ -376,18 +379,18 @@ jobs: - name: Install Playwright if: steps.cache-playwright.outputs.cache-hit != 'true' - run: | - cd ./apps/meteor - npx playwright install --with-deps + working-directory: ./apps/meteor + run: npx playwright install --with-deps - - name: E2E Test UI + - name: Reset containers env: LOWERCASE_REPOSITORY: ${{ steps.docker-env.outputs.lowercase-repo }} RC_DOCKERFILE: ${{ steps.docker-env.outputs.rc-dockerfile }} RC_DOCKER_TAG: ${{ steps.docker-env.outputs.rc-docker-tag }} run: | docker ps - docker compose -f docker-compose-ci.yml logs rocketchat --tail=50 + + docker compose -f docker-compose-ci.yml stop rocketchat docker exec mongodb mongo rocketchat --eval 'db.dropDatabase()' @@ -399,10 +402,11 @@ jobs: echo "Waiting Rocket.Chat to start up" ((c++)) && ((c==10)) && exit 1 sleep 10 - done + done; - cd ./apps/meteor - yarn test:e2e + - name: E2E Test UI + working-directory: ./apps/meteor + run: yarn test:e2e - name: Store playwright test trace uses: actions/upload-artifact@v2 @@ -444,6 +448,9 @@ jobs: - name: yarn install run: yarn + - name: yarn build + run: yarn build + - name: Unit Test run: yarn testunit --api="http://127.0.0.1:9080" --token="${{ secrets.TURBO_SERVER_TOKEN }}" --team='rc' @@ -459,33 +466,26 @@ jobs: tar xzf Rocket.Chat.tar.gz rm Rocket.Chat.tar.gz + - name: Docker env vars + id: docker-env + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + echo "LOWERCASE_REPOSITORY: ${LOWERCASE_REPOSITORY}" + echo "::set-output name=lowercase-repo::${LOWERCASE_REPOSITORY}" + - name: Start containers env: MONGO_URL: 'mongodb://host.docker.internal:27017/rocketchat?replicaSet=rs0&directConnection=true' + LOWERCASE_REPOSITORY: ${{ steps.docker-env.outputs.lowercase-repo }} RC_DOCKERFILE: '${{ github.workspace }}/apps/meteor/.docker/Dockerfile' RC_DOCKER_TAG: '${{ needs.release-versions.outputs.gh-docker-tag }}.official' DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} TRANSPORTER: nats://nats:4222 ENTERPRISE_LICENSE: ${{ secrets.ENTERPRISE_LICENSE }} run: | - export LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - docker compose -f docker-compose-ci.yml up -d --build - sleep 10 - - until echo "$(docker compose -f docker-compose-ci.yml logs ddp-streamer-service)" | grep -q "NetworkBroker started successfully"; do - echo "Waiting 'ddp-streamer' to start up" - ((c++)) && ((c==10)) && docker compose -f docker-compose-ci.yml logs ddp-streamer-service && exit 1 - sleep 10 - done - - until echo "$(docker compose -f docker-compose-ci.yml logs rocketchat)" | grep -q "SERVER RUNNING"; do - echo "Waiting Rocket.Chat to start up" - ((c++)) && ((c==10)) && docker compose -f docker-compose-ci.yml logs rocketchat && exit 1 - sleep 10 - done - - name: Login to GitHub Container Registry if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' uses: docker/login-action@v2 @@ -498,9 +498,8 @@ jobs: if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' env: DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} + LOWERCASE_REPOSITORY: ${{ steps.docker-env.outputs.lowercase-repo }} run: | - export LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - docker compose -f docker-compose-ci.yml push \ authorization-service \ account-service \ @@ -508,14 +507,37 @@ jobs: presence-service \ stream-hub-service - - name: E2E Test API + - name: Wait services to start up + env: + LOWERCASE_REPOSITORY: ${{ steps.docker-env.outputs.lowercase-repo }} + RC_DOCKERFILE: '${{ github.workspace }}/apps/meteor/.docker/Dockerfile' + RC_DOCKER_TAG: '${{ needs.release-versions.outputs.gh-docker-tag }}.official' + DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} run: | docker ps - docker compose -f docker-compose-ci.yml logs --tail=50 - cd ./apps/meteor + until echo "$(docker compose -f docker-compose-ci.yml logs ddp-streamer-service)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'ddp-streamer' to start up" + ((c++)) && ((c==10)) && docker compose -f docker-compose-ci.yml logs ddp-streamer-service && exit 1 + sleep 10 + done; + + until echo "$(docker compose -f docker-compose-ci.yml logs rocketchat)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && docker compose -f docker-compose-ci.yml logs rocketchat && exit 1 + sleep 10 + done; + + - name: E2E Test API + env: + LOWERCASE_REPOSITORY: ${{ steps.docker-env.outputs.lowercase-repo }} + RC_DOCKERFILE: '${{ github.workspace }}/apps/meteor/.docker/Dockerfile' + RC_DOCKER_TAG: '${{ needs.release-versions.outputs.gh-docker-tag }}.official' + DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} + working-directory: ./apps/meteor + run: | for i in $(seq 1 5); do - IS_EE=true npm run testapi && s=0 && break || s=$?; + IS_EE=true npm run testapi && s=0 && break || s=$? docker compose -f ../../docker-compose-ci.yml logs --tail=100 @@ -525,7 +547,7 @@ jobs: NOW=$(date "+%Y-%m-%dT%H:%M:%S.000Z") - docker compose -f ../../docker-compose-ci.yml start + docker compose -f ../../docker-compose-ci.yml restart until echo "$(docker compose -f ../../docker-compose-ci.yml logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do echo "Waiting Rocket.Chat to start up" @@ -535,24 +557,14 @@ jobs: done; exit $s - - name: Cache Playwright binaries - uses: actions/cache@v3 - id: cache-playwright - with: - path: | - ~/.cache/ms-playwright - # This is the version of Playwright that we are using, if you are willing to upgrade, you should update this. - key: playwright-1.23.1 - - - name: Install Playwright - run: | - cd ./apps/meteor - npx playwright install --with-deps - - - name: E2E Test UI + - name: Reset containers + env: + LOWERCASE_REPOSITORY: ${{ steps.docker-env.outputs.lowercase-repo }} + RC_DOCKERFILE: '${{ github.workspace }}/apps/meteor/.docker/Dockerfile' + RC_DOCKER_TAG: '${{ needs.release-versions.outputs.gh-docker-tag }}.official' + DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} run: | - docker ps - docker compose -f docker-compose-ci.yml logs rocketchat --tail=50 + docker compose -f docker-compose-ci.yml stop docker exec mongodb mongo rocketchat --eval 'db.dropDatabase()' @@ -560,15 +572,34 @@ jobs: docker compose -f docker-compose-ci.yml restart - until echo "$(docker compose -f docker-compose-ci.yml logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + until echo "$(docker compose -f docker-compose-ci.yml logs ddp-streamer-service)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'ddp-streamer' to start up" + ((c++)) && ((c==10)) && docker compose -f docker-compose-ci.yml logs ddp-streamer-service && exit 1 + sleep 10 + done; + + until echo "$(docker compose -f docker-compose-ci.yml logs rocketchat)" | grep -q "SERVER RUNNING"; do echo "Waiting Rocket.Chat to start up" - ((c++)) && ((c==10)) && exit 1 + ((c++)) && ((c==10)) && docker compose -f docker-compose-ci.yml logs rocketchat && exit 1 sleep 10 - done + done; + + - name: Cache Playwright binaries + uses: actions/cache@v3 + id: cache-playwright + with: + path: | + ~/.cache/ms-playwright + # This is the version of Playwright that we are using, if you are willing to upgrade, you should update this. + key: playwright-1.23.1 - cd ./apps/meteor + - name: Install Playwright + working-directory: ./apps/meteor + run: npx playwright install --with-deps - E2E_COVERAGE=true IS_EE=true yarn test:e2e + - name: E2E Test UI + working-directory: ./apps/meteor + run: E2E_COVERAGE=true IS_EE=true yarn test:e2e - name: Store playwright test trace uses: actions/upload-artifact@v2 @@ -578,9 +609,8 @@ jobs: path: ./apps/meteor/tests/e2e/.playwright* - name: Extract e2e:ee:coverage - run: | - cd ./apps/meteor - yarn test:e2e:nyc + working-directory: ./apps/meteor + run: yarn test:e2e:nyc - uses: codecov/codecov-action@v3 with: @@ -597,7 +627,7 @@ jobs: deploy: runs-on: ubuntu-20.04 if: github.event_name == 'release' || github.ref == 'refs/heads/develop' - needs: [test, release-versions] + needs: [test, test-ee, release-versions] steps: - uses: actions/checkout@v3 diff --git a/apps/meteor/app/discussion/client/discussionFromMessageBox.js b/apps/meteor/app/discussion/client/discussionFromMessageBox.js index eb2d67e53dc1..56cf96c056f4 100644 --- a/apps/meteor/app/discussion/client/discussionFromMessageBox.js +++ b/apps/meteor/app/discussion/client/discussionFromMessageBox.js @@ -1,11 +1,14 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; +import { Session } from 'meteor/session'; +import { isRoomFederated } from '@rocket.chat/core-typings'; import { messageBox } from '../../ui-utils/client'; import { settings } from '../../settings/client'; import { hasPermission } from '../../authorization/client'; import { imperativeModal } from '../../../client/lib/imperativeModal'; import CreateDiscussion from '../../../client/components/CreateDiscussion/CreateDiscussion'; +import { Rooms } from '../../models/client'; Meteor.startup(function () { Tracker.autorun(() => { @@ -15,7 +18,13 @@ Meteor.startup(function () { messageBox.actions.add('Create_new', 'Discussion', { id: 'start-discussion', icon: 'discussion', - condition: () => hasPermission('start-discussion') || hasPermission('start-discussion-other-user'), + condition: () => { + const room = Rooms.findOne(Session.get('openedRoom')); + if (!room) { + return false; + } + return (hasPermission('start-discussion') || hasPermission('start-discussion-other-user')) && !isRoomFederated(room); + }, action(data) { imperativeModal.open({ component: CreateDiscussion, diff --git a/apps/meteor/app/federation-v2/server/Federation.ts b/apps/meteor/app/federation-v2/server/Federation.ts index 25080150b687..7c856ebe1d81 100644 --- a/apps/meteor/app/federation-v2/server/Federation.ts +++ b/apps/meteor/app/federation-v2/server/Federation.ts @@ -2,6 +2,7 @@ import type { IRoom, ValueOf } from '@rocket.chat/core-typings'; import { isDirectMessageRoom } from '@rocket.chat/core-typings'; import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { escapeExternalFederationEventId, unescapeExternalFederationEventId } from './infrastructure/rocket-chat/adapters/MessageConverter'; const allowedActionsInFederatedRooms: ValueOf[] = [ RoomMemberActions.REMOVE_USER, @@ -18,4 +19,12 @@ export class Federation { public static isAFederatedUsername(username: string): boolean { return username.includes('@') && username.includes(':'); } + + public static escapeExternalFederationEventId(externalEventId: string): string { + return escapeExternalFederationEventId(externalEventId); + } + + public static unescapeExternalFederationEventId(externalEventId: string): string { + return unescapeExternalFederationEventId(externalEventId); + } } diff --git a/apps/meteor/app/federation-v2/server/application/AbstractFederationService.ts b/apps/meteor/app/federation-v2/server/application/AbstractFederationService.ts index 773eb480afc2..a210b45c377c 100644 --- a/apps/meteor/app/federation-v2/server/application/AbstractFederationService.ts +++ b/apps/meteor/app/federation-v2/server/application/AbstractFederationService.ts @@ -20,14 +20,19 @@ export abstract class FederationService { existsOnlyOnProxyServer = false, providedName?: string, ): Promise { + const internalUser = await this.internalUserAdapter.getInternalUserByUsername(username); const externalUserProfileInformation = await this.bridge.getUserProfileInformation(externalUserId); - const name = externalUserProfileInformation?.displayName || providedName || username; - const federatedUser = FederatedUser.createInstance(externalUserId, { - name, - username, - existsOnlyOnProxyServer, - }); - + let federatedUser; + if (internalUser) { + federatedUser = FederatedUser.createWithInternalReference(externalUserId, existsOnlyOnProxyServer, internalUser); + } else { + const name = externalUserProfileInformation?.displayName || providedName || username; + federatedUser = FederatedUser.createInstance(externalUserId, { + name, + username, + existsOnlyOnProxyServer, + }); + } await this.internalUserAdapter.createFederatedUser(federatedUser); } diff --git a/apps/meteor/app/federation-v2/server/application/MessageServiceListener.ts b/apps/meteor/app/federation-v2/server/application/MessageServiceListener.ts new file mode 100644 index 000000000000..4a0a1847ad06 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/application/MessageServiceListener.ts @@ -0,0 +1,57 @@ +import { isMessageFromMatrixFederation } from '@rocket.chat/core-typings'; + +import type { IFederationBridge } from '../domain/IFederationBridge'; +import type { RocketChatMessageAdapter } from '../infrastructure/rocket-chat/adapters/Message'; +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 { FederationMessageReactionEventDto } from './input/MessageReceiverDto'; + +export class FederationMessageServiceListener extends FederationService { + constructor( + protected internalRoomAdapter: RocketChatRoomAdapter, + protected internalUserAdapter: RocketChatUserAdapter, + protected internalMessageAdapter: RocketChatMessageAdapter, + protected internalSettingsAdapter: RocketChatSettingsAdapter, + protected bridge: IFederationBridge, + ) { + super(bridge, internalUserAdapter, internalSettingsAdapter); + } + + public async onMessageReaction(messageReactionEventInput: FederationMessageReactionEventDto): Promise { + const { + externalRoomId, + emoji, + externalSenderId, + externalEventId: externalReactionEventId, + externalReactedEventId: externalMessageId, + } = messageReactionEventInput; + + const federatedRoom = await this.internalRoomAdapter.getFederatedRoomByExternalId(externalRoomId); + if (!federatedRoom) { + return; + } + + const federatedUser = await this.internalUserAdapter.getFederatedUserByExternalId(externalSenderId); + if (!federatedUser) { + return; + } + const message = await this.internalMessageAdapter.getMessageByFederationId(externalMessageId); + if (!message) { + return; + } + if (!isMessageFromMatrixFederation(message)) { + return; + } + // TODO: move this to a Message entity in the domain layer + const userAlreadyReacted = Boolean( + federatedUser.getUsername() && message.reactions?.[emoji]?.usernames?.includes(federatedUser.getUsername() as string), + ); + if (userAlreadyReacted) { + return; + } + + await this.internalMessageAdapter.reactToMessage(federatedUser, message, emoji, externalReactionEventId); + } +} diff --git a/apps/meteor/app/federation-v2/server/application/RoomRedactionHandlers.ts b/apps/meteor/app/federation-v2/server/application/RoomRedactionHandlers.ts new file mode 100644 index 000000000000..84c5fb0937ad --- /dev/null +++ b/apps/meteor/app/federation-v2/server/application/RoomRedactionHandlers.ts @@ -0,0 +1,61 @@ +import type { IMessage } from '@rocket.chat/core-typings'; + +import type { FederatedUser } from '../domain/FederatedUser'; +import { Federation } from '../Federation'; +import type { RocketChatMessageAdapter } from '../infrastructure/rocket-chat/adapters/Message'; + +export interface IRoomRedactionHandlers { + handle(): Promise; +} + +class DeleteMessageHandler implements IRoomRedactionHandlers { + constructor( + private readonly internalMessageAdapter: RocketChatMessageAdapter, + private readonly message: IMessage, + private readonly federatedUser: FederatedUser, + ) {} + + public async handle(): Promise { + await this.internalMessageAdapter.deleteMessage(this.message, this.federatedUser); + } +} + +class UnreactToMessageHandler implements IRoomRedactionHandlers { + constructor( + private readonly internalMessageAdapter: RocketChatMessageAdapter, + private readonly message: IMessage, + private readonly federatedUser: FederatedUser, + private readonly redactsEvents: string, + ) {} + + public async handle(): Promise { + const normalizedEventId = Federation.escapeExternalFederationEventId(this.redactsEvents); + const reaction = Object.keys(this.message.reactions || {}).find( + (key) => + this.message.reactions?.[key]?.federationReactionEventIds?.[normalizedEventId] === this.federatedUser.getUsername() && + this.message.reactions?.[key]?.usernames?.includes(this.federatedUser.getUsername() || ''), + ); + if (!reaction) { + return; + } + await this.internalMessageAdapter.unreactToMessage(this.federatedUser, this.message, reaction, this.redactsEvents); + } +} + +export const getRedactMessageHandler = async ( + internalMessageAdapter: RocketChatMessageAdapter, + redactsEvent: string, + federatedUser: FederatedUser, +): Promise => { + const message = await internalMessageAdapter.getMessageByFederationId(redactsEvent); + const messageWithReaction = await internalMessageAdapter.findOneByFederationIdOnReactions(redactsEvent, federatedUser); + if (!message && !messageWithReaction) { + return; + } + if (messageWithReaction) { + return new UnreactToMessageHandler(internalMessageAdapter, messageWithReaction, federatedUser, redactsEvent); + } + if (message) { + return new DeleteMessageHandler(internalMessageAdapter, message, federatedUser); + } +}; diff --git a/apps/meteor/app/federation-v2/server/application/RoomServiceListener.ts b/apps/meteor/app/federation-v2/server/application/RoomServiceListener.ts index afdd395e5657..24980387a1ab 100644 --- a/apps/meteor/app/federation-v2/server/application/RoomServiceListener.ts +++ b/apps/meteor/app/federation-v2/server/application/RoomServiceListener.ts @@ -16,8 +16,13 @@ import type { FederationRoomChangeJoinRulesDto, FederationRoomChangeNameDto, FederationRoomChangeTopicDto, + FederationRoomReceiveExternalFileMessageDto, + FederationRoomRedactEventDto, + FederationRoomEditExternalMessageDto, } from './input/RoomReceiverDto'; import { FederationService } from './AbstractFederationService'; +import type { RocketChatFileAdapter } from '../infrastructure/rocket-chat/adapters/File'; +import { getRedactMessageHandler } from './RoomRedactionHandlers'; export class FederationRoomServiceListener extends FederationService { constructor( @@ -25,6 +30,7 @@ export class FederationRoomServiceListener extends FederationService { protected internalUserAdapter: RocketChatUserAdapter, protected internalMessageAdapter: RocketChatMessageAdapter, protected internalSettingsAdapter: RocketChatSettingsAdapter, + protected internalFileAdapter: RocketChatFileAdapter, protected bridge: IFederationBridge, ) { super(bridge, internalUserAdapter, internalSettingsAdapter); @@ -180,7 +186,7 @@ export class FederationRoomServiceListener extends FederationService { } public async onExternalMessageReceived(roomReceiveExternalMessageInput: FederationRoomReceiveExternalMessageDto): Promise { - const { externalRoomId, externalSenderId, messageText } = roomReceiveExternalMessageInput; + const { externalRoomId, externalSenderId, messageText, externalEventId } = roomReceiveExternalMessageInput; const federatedRoom = await this.internalRoomAdapter.getFederatedRoomByExternalId(externalRoomId); if (!federatedRoom) { @@ -192,7 +198,62 @@ export class FederationRoomServiceListener extends FederationService { return; } - await this.internalMessageAdapter.sendMessage(senderUser, federatedRoom, messageText); + await this.internalMessageAdapter.sendMessage(senderUser, federatedRoom, messageText, externalEventId); + } + + public async onExternalMessageEditedReceived(roomEditExternalMessageInput: FederationRoomEditExternalMessageDto): Promise { + const { externalRoomId, externalSenderId, editsEvent, newMessageText } = roomEditExternalMessageInput; + + const federatedRoom = await this.internalRoomAdapter.getFederatedRoomByExternalId(externalRoomId); + if (!federatedRoom) { + return; + } + + const senderUser = await this.internalUserAdapter.getFederatedUserByExternalId(externalSenderId); + if (!senderUser) { + return; + } + + const message = await this.internalMessageAdapter.getMessageByFederationId(editsEvent); + if (!message) { + return; + } + // TODO: create an entity to abstract all the message logic + if (!FederatedRoom.shouldUpdateMessage(newMessageText, message)) { + return; + } + + await this.internalMessageAdapter.editMessage(senderUser, newMessageText, message); + } + + public async onExternalFileMessageReceived(roomReceiveExternalMessageInput: FederationRoomReceiveExternalFileMessageDto): Promise { + const { externalRoomId, externalSenderId, messageBody, externalEventId } = roomReceiveExternalMessageInput; + + const federatedRoom = await this.internalRoomAdapter.getFederatedRoomByExternalId(externalRoomId); + if (!federatedRoom) { + return; + } + + const senderUser = await this.internalUserAdapter.getFederatedUserByExternalId(externalSenderId); + if (!senderUser) { + return; + } + const fileDetails = { + name: messageBody.filename, + size: messageBody.size, + type: messageBody.mimetype, + rid: federatedRoom.getInternalId(), + userId: senderUser.getInternalId(), + }; + const readableStream = await this.bridge.getReadStreamForFileFromUrl(senderUser.getExternalId(), messageBody.url); + const { files = [], attachments } = await this.internalFileAdapter.uploadFile( + readableStream, + federatedRoom.getInternalId(), + senderUser.getInternalReference(), + fileDetails, + ); + + await this.internalMessageAdapter.sendFileMessage(senderUser, federatedRoom, files, attachments, externalEventId); } public async onChangeJoinRules(roomJoinRulesChangeInput: FederationRoomChangeJoinRulesDto): Promise { @@ -255,4 +316,23 @@ export class FederationRoomServiceListener extends FederationService { await this.internalRoomAdapter.updateRoomTopic(federatedRoom, federatedUser); } + + public async onRedactEvent(roomRedactEventInput: FederationRoomRedactEventDto): Promise { + const { externalRoomId, redactsEvent, externalSenderId } = roomRedactEventInput; + + const federatedRoom = await this.internalRoomAdapter.getFederatedRoomByExternalId(externalRoomId); + if (!federatedRoom) { + return; + } + + const federatedUser = await this.internalUserAdapter.getFederatedUserByExternalId(externalSenderId); + if (!federatedUser) { + return; + } + const handler = await getRedactMessageHandler(this.internalMessageAdapter, redactsEvent, federatedUser); + if (!handler) { + return; + } + await handler.handle(); + } } diff --git a/apps/meteor/app/federation-v2/server/application/input/MessageReceiverDto.ts b/apps/meteor/app/federation-v2/server/application/input/MessageReceiverDto.ts new file mode 100644 index 000000000000..43dc255fef9a --- /dev/null +++ b/apps/meteor/app/federation-v2/server/application/input/MessageReceiverDto.ts @@ -0,0 +1,31 @@ +import type { IFederationReceiverBaseRoomInputDto } from './RoomReceiverDto'; +import { FederationBaseRoomInputDto } from './RoomReceiverDto'; + +interface IFederationRoomMessageReactionInputDto extends IFederationReceiverBaseRoomInputDto { + externalSenderId: string; + externalEventId: string; + externalReactedEventId: string; + emoji: string; +} + +export class FederationMessageReactionEventDto extends FederationBaseRoomInputDto { + constructor({ + externalRoomId, + normalizedRoomId, + externalEventId, + externalReactedEventId, + emoji, + externalSenderId, + }: IFederationRoomMessageReactionInputDto) { + super({ externalRoomId, normalizedRoomId, externalEventId }); + this.emoji = emoji; + this.externalSenderId = externalSenderId; + this.externalReactedEventId = externalReactedEventId; + } + + emoji: string; + + externalSenderId: string; + + externalReactedEventId: 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 8e20bbe0c988..5df8bdd7db72 100644 --- a/apps/meteor/app/federation-v2/server/application/input/RoomReceiverDto.ts +++ b/apps/meteor/app/federation-v2/server/application/input/RoomReceiverDto.ts @@ -2,7 +2,11 @@ import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; import type { EVENT_ORIGIN } from '../../domain/IFederationBridge'; -export interface IFederationReceiverBaseRoomInputDto { +interface IFederationBaseInputDto { + externalEventId: string; +} + +export interface IFederationReceiverBaseRoomInputDto extends IFederationBaseInputDto { externalRoomId: string; normalizedRoomId: string; } @@ -29,30 +33,41 @@ export interface IFederationChangeMembershipInputDto extends IFederationReceiver externalRoomName?: string; } -export interface IFederationSendInternalMessageInputDto extends IFederationReceiverBaseRoomInputDto { - externalSenderId: string; - normalizedSenderId: string; - messageText: string; -} - export interface IFederationRoomChangeJoinRulesDtoInputDto extends IFederationReceiverBaseRoomInputDto { roomType: RoomType; } export interface IFederationRoomNameChangeInputDto extends IFederationReceiverBaseRoomInputDto { normalizedRoomName: string; - externalSenderId: string; } export interface IFederationRoomTopicChangeInputDto extends IFederationReceiverBaseRoomInputDto { roomTopic: string; + externalSenderId: string; +} +export interface IFederationRoomRedactEventInputDto extends IFederationReceiverBaseRoomInputDto { + redactsEvent: string; externalSenderId: string; } -export class FederationBaseRoomInputDto { - constructor({ externalRoomId, normalizedRoomId }: IFederationReceiverBaseRoomInputDto) { +export interface IFederationSendInternalMessageBaseInputDto extends IFederationReceiverBaseRoomInputDto { + externalSenderId: string; + normalizedSenderId: string; +} + +abstract class FederationBaseDto { + constructor({ externalEventId }: { externalEventId: string }) { + this.externalEventId = externalEventId; + } + + externalEventId: string; +} + +export class FederationBaseRoomInputDto extends FederationBaseDto { + constructor({ externalRoomId, normalizedRoomId, externalEventId }: IFederationReceiverBaseRoomInputDto) { + super({ externalEventId }); this.externalRoomId = externalRoomId; this.normalizedRoomId = normalizedRoomId; } @@ -72,8 +87,9 @@ export class FederationRoomCreateInputDto extends FederationBaseRoomInputDto { roomType, externalRoomName, internalRoomId, + externalEventId, }: IFederationCreateInputDto) { - super({ externalRoomId, normalizedRoomId }); + super({ externalRoomId, normalizedRoomId, externalEventId }); this.externalInviterId = externalInviterId; this.normalizedInviterId = normalizedInviterId; this.wasInternallyProgramaticallyCreated = wasInternallyProgramaticallyCreated; @@ -109,8 +125,9 @@ export class FederationRoomChangeMembershipDto extends FederationBaseRoomInputDt leave, roomType, externalRoomName, + externalEventId, }: IFederationChangeMembershipInputDto) { - super({ externalRoomId, normalizedRoomId }); + super({ externalRoomId, normalizedRoomId, externalEventId }); this.externalInviterId = externalInviterId; this.normalizedInviterId = normalizedInviterId; this.externalInviteeId = externalInviteeId; @@ -144,15 +161,28 @@ export class FederationRoomChangeMembershipDto extends FederationBaseRoomInputDt externalRoomName?: string; } -export class FederationRoomReceiveExternalMessageDto extends FederationBaseRoomInputDto { +class ExternalMessageBaseDto extends FederationBaseRoomInputDto { + constructor({ externalRoomId, normalizedRoomId, externalSenderId, normalizedSenderId, externalEventId }: Record) { + super({ externalRoomId, normalizedRoomId, externalEventId }); + this.externalSenderId = externalSenderId; + this.normalizedSenderId = normalizedSenderId; + } + + externalSenderId: string; + + normalizedSenderId: string; +} + +export class FederationRoomReceiveExternalMessageDto extends ExternalMessageBaseDto { constructor({ externalRoomId, normalizedRoomId, externalSenderId, normalizedSenderId, messageText, - }: IFederationSendInternalMessageInputDto) { - super({ externalRoomId, normalizedRoomId }); + externalEventId, + }: IFederationSendInternalMessageBaseInputDto & { messageText: string }) { + super({ externalRoomId, normalizedRoomId, externalEventId }); this.externalSenderId = externalSenderId; this.normalizedSenderId = normalizedSenderId; this.messageText = messageText; @@ -165,9 +195,89 @@ export class FederationRoomReceiveExternalMessageDto extends FederationBaseRoomI messageText: string; } +export class FederationRoomEditExternalMessageDto extends ExternalMessageBaseDto { + constructor({ + externalRoomId, + normalizedRoomId, + externalSenderId, + normalizedSenderId, + newMessageText, + editsEvent, + externalEventId, + }: IFederationSendInternalMessageBaseInputDto & { newMessageText: string; editsEvent: string }) { + super({ externalRoomId, normalizedRoomId, externalEventId }); + this.externalSenderId = externalSenderId; + this.normalizedSenderId = normalizedSenderId; + this.newMessageText = newMessageText; + this.editsEvent = editsEvent; + } + + externalSenderId: string; + + normalizedSenderId: string; + + newMessageText: string; + + editsEvent: string; +} + +export interface IFederationFileMessageInputDto { + filename: string; + mimetype: string; + size: number; + messageText: string; + url: string; +} + +class FederationFileMessageInputDto { + constructor({ filename, mimetype, size, messageText, url }: IFederationFileMessageInputDto) { + this.filename = filename; + this.mimetype = mimetype; + this.size = size; + this.messageText = messageText; + this.url = url; + } + + filename: string; + + mimetype: string; + + size: number; + + messageText: string; + + url: string; +} + +export class FederationRoomReceiveExternalFileMessageDto extends ExternalMessageBaseDto { + constructor({ + externalRoomId, + normalizedRoomId, + externalSenderId, + normalizedSenderId, + filename, + mimetype, + size, + messageText, + url, + externalEventId, + }: IFederationSendInternalMessageBaseInputDto & IFederationFileMessageInputDto) { + super({ externalRoomId, normalizedRoomId, externalEventId }); + this.externalSenderId = externalSenderId; + this.normalizedSenderId = normalizedSenderId; + this.messageBody = new FederationFileMessageInputDto({ filename, mimetype, size, messageText, url }); + } + + externalSenderId: string; + + normalizedSenderId: string; + + messageBody: FederationFileMessageInputDto; +} + export class FederationRoomChangeJoinRulesDto extends FederationBaseRoomInputDto { - constructor({ externalRoomId, normalizedRoomId, roomType }: IFederationRoomChangeJoinRulesDtoInputDto) { - super({ externalRoomId, normalizedRoomId }); + constructor({ externalRoomId, normalizedRoomId, roomType, externalEventId }: IFederationRoomChangeJoinRulesDtoInputDto) { + super({ externalRoomId, normalizedRoomId, externalEventId }); this.roomType = roomType; } @@ -175,8 +285,14 @@ export class FederationRoomChangeJoinRulesDto extends FederationBaseRoomInputDto } export class FederationRoomChangeNameDto extends FederationBaseRoomInputDto { - constructor({ externalRoomId, normalizedRoomId, normalizedRoomName, externalSenderId }: IFederationRoomNameChangeInputDto) { - super({ externalRoomId, normalizedRoomId }); + constructor({ + externalRoomId, + normalizedRoomId, + normalizedRoomName, + externalSenderId, + externalEventId, + }: IFederationRoomNameChangeInputDto) { + super({ externalRoomId, normalizedRoomId, externalEventId }); this.normalizedRoomName = normalizedRoomName; this.externalSenderId = externalSenderId; } @@ -187,8 +303,8 @@ export class FederationRoomChangeNameDto extends FederationBaseRoomInputDto { } export class FederationRoomChangeTopicDto extends FederationBaseRoomInputDto { - constructor({ externalRoomId, normalizedRoomId, roomTopic, externalSenderId }: IFederationRoomTopicChangeInputDto) { - super({ externalRoomId, normalizedRoomId }); + constructor({ externalRoomId, normalizedRoomId, roomTopic, externalSenderId, externalEventId }: IFederationRoomTopicChangeInputDto) { + super({ externalRoomId, normalizedRoomId, externalEventId }); this.roomTopic = roomTopic; this.externalSenderId = externalSenderId; } @@ -197,3 +313,15 @@ export class FederationRoomChangeTopicDto extends FederationBaseRoomInputDto { externalSenderId: string; } + +export class FederationRoomRedactEventDto extends FederationBaseRoomInputDto { + constructor({ externalRoomId, normalizedRoomId, externalEventId, redactsEvent, externalSenderId }: IFederationRoomRedactEventInputDto) { + super({ externalRoomId, normalizedRoomId, externalEventId }); + this.redactsEvent = redactsEvent; + this.externalSenderId = externalSenderId; + } + + redactsEvent: string; + + externalSenderId: string; +} diff --git a/apps/meteor/app/federation-v2/server/application/sender/MessageSenders.ts b/apps/meteor/app/federation-v2/server/application/sender/MessageSenders.ts new file mode 100644 index 000000000000..8b59267e42f0 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/application/sender/MessageSenders.ts @@ -0,0 +1,49 @@ +import type { IMessage } from '@rocket.chat/core-typings'; + +import type { IFederationBridge } from '../../domain/IFederationBridge'; +import type { RocketChatFileAdapter } from '../../infrastructure/rocket-chat/adapters/File'; + +export interface IExternalMessageSender { + sendMessage(externalRoomId: string, externalSenderId: string, message: IMessage): Promise; +} + +class TextExternalMessageSender implements IExternalMessageSender { + constructor(private readonly bridge: IFederationBridge) {} + + public async sendMessage(externalRoomId: string, externalSenderId: string, message: IMessage): Promise { + await this.bridge.sendMessage(externalRoomId, externalSenderId, message.msg); + } +} + +class FileExternalMessageSender implements IExternalMessageSender { + constructor(private readonly bridge: IFederationBridge, private readonly internalFileHelper: RocketChatFileAdapter) {} + + public async sendMessage(externalRoomId: string, externalSenderId: string, message: IMessage): Promise { + const file = await this.internalFileHelper.getFileRecordById((message.files || [])[0]?._id); + if (!file || !file.size || !file.type) { + return; + } + + const buffer = await this.internalFileHelper.getBufferFromFileRecord(file); + const metadata = await this.internalFileHelper.extractMetadataFromFile(file); + + await this.bridge.sendMessageFileToRoom(externalRoomId, externalSenderId, buffer, { + filename: file.name, + fileSize: file.size, + mimeType: file.type, + metadata: { + width: metadata?.width, + height: metadata?.height, + format: metadata?.format, + }, + }); + } +} + +export const getExternalMessageSender = ( + message: IMessage, + bridge: IFederationBridge, + internalFileHelper: RocketChatFileAdapter, +): IExternalMessageSender => { + return message.files ? new FileExternalMessageSender(bridge, internalFileHelper) : new TextExternalMessageSender(bridge); +}; diff --git a/apps/meteor/app/federation-v2/server/application/sender/MessageServiceSender.ts b/apps/meteor/app/federation-v2/server/application/sender/MessageServiceSender.ts new file mode 100644 index 000000000000..6abd358ff6a4 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/application/sender/MessageServiceSender.ts @@ -0,0 +1,98 @@ +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import { isMessageFromMatrixFederation } from '@rocket.chat/core-typings'; + +import { FederatedUser } from '../../domain/FederatedUser'; +import type { IFederationBridge } from '../../domain/IFederationBridge'; +import { Federation } from '../../Federation'; +import type { RocketChatMessageAdapter } from '../../infrastructure/rocket-chat/adapters/Message'; +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'; + +export class FederationMessageServiceSender { + constructor( + protected internalRoomAdapter: RocketChatRoomAdapter, + protected internalUserAdapter: RocketChatUserAdapter, + protected internalSettingsAdapter: RocketChatSettingsAdapter, + protected internalMessageAdapter: RocketChatMessageAdapter, + protected bridge: IFederationBridge, + ) {} + + public async sendExternalMessageReaction(internalMessage: IMessage, internalUser: IUser, reaction: string): Promise { + if (!internalMessage || !internalUser || !internalUser._id || !internalMessage.rid) { + return; + } + const federatedSender = await this.internalUserAdapter.getFederatedUserByInternalId(internalUser._id); + if (!federatedSender) { + return; + } + + const federatedRoom = await this.internalRoomAdapter.getFederatedRoomByInternalId(internalMessage.rid); + if (!federatedRoom) { + return; + } + + if (!isMessageFromMatrixFederation(internalMessage)) { + return; + } + + const isUserFromTheSameHomeServer = FederatedUser.isOriginalFromTheProxyServer( + this.bridge.extractHomeserverOrigin(federatedSender.getExternalId()), + this.internalSettingsAdapter.getHomeServerDomain(), + ); + if (!isUserFromTheSameHomeServer) { + return; + } + + const eventId = await this.bridge.sendMessageReaction( + federatedRoom.getExternalId(), + federatedSender.getExternalId(), + internalMessage.federation?.eventId as string, + reaction, + ); + federatedSender.getUsername() && + (await this.internalMessageAdapter.setExternalFederationEventOnMessage( + federatedSender.getUsername() as string, + internalMessage, + reaction, + eventId, + )); + } + + public async sendExternalMessageUnReaction(internalMessage: IMessage, internalUser: IUser, reaction: string): Promise { + if (!internalMessage || !internalUser || !internalUser._id || !internalMessage.rid) { + return; + } + const federatedSender = await this.internalUserAdapter.getFederatedUserByInternalId(internalUser._id); + if (!federatedSender) { + return; + } + + const federatedRoom = await this.internalRoomAdapter.getFederatedRoomByInternalId(internalMessage.rid); + if (!federatedRoom) { + return; + } + + if (!isMessageFromMatrixFederation(internalMessage)) { + return; + } + + const isUserFromTheSameHomeServer = FederatedUser.isOriginalFromTheProxyServer( + this.bridge.extractHomeserverOrigin(federatedSender.getExternalId()), + this.internalSettingsAdapter.getHomeServerDomain(), + ); + if (!isUserFromTheSameHomeServer) { + return; + } + // TODO: leaked business logic, move this to the domain layer + const externalEventId = Object.keys(internalMessage.reactions?.[reaction].federationReactionEventIds || {}).find( + (key) => internalMessage.reactions?.[reaction].federationReactionEventIds?.[key] === internalUser.username, + ); + if (!externalEventId) { + return; + } + const normalizedEventId = Federation.unescapeExternalFederationEventId(externalEventId); + await this.bridge.redactEvent(federatedRoom.getExternalId(), federatedSender.getExternalId(), normalizedEventId); + await this.internalMessageAdapter.unsetExternalFederationEventOnMessage(externalEventId, internalMessage, reaction); + } +} 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 721ee5f65186..990bc2fd52a3 100644 --- a/apps/meteor/app/federation-v2/server/application/sender/RoomServiceSender.ts +++ b/apps/meteor/app/federation-v2/server/application/sender/RoomServiceSender.ts @@ -1,8 +1,10 @@ import type { IMessage } from '@rocket.chat/core-typings'; +import { isDeletedMessage, isEditedMessage, isMessageFromMatrixFederation } from '@rocket.chat/core-typings'; import { DirectMessageFederatedRoom } from '../../domain/FederatedRoom'; import { FederatedUser } from '../../domain/FederatedUser'; 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'; @@ -13,12 +15,14 @@ import type { FederationCreateDMAndInviteUserDto, FederationRoomSendExternalMessageDto, } from '../input/RoomSenderDto'; +import { getExternalMessageSender } from './MessageSenders'; export class FederationRoomServiceSender extends FederationService { constructor( protected internalRoomAdapter: RocketChatRoomAdapter, protected internalUserAdapter: RocketChatUserAdapter, protected internalSettingsAdapter: RocketChatSettingsAdapter, + protected internalFileAdapter: RocketChatFileAdapter, protected bridge: IFederationBridge, ) { super(bridge, internalUserAdapter, internalSettingsAdapter); @@ -140,7 +144,6 @@ export class FederationRoomServiceSender extends FederationService { public async sendExternalMessage(roomSendExternalMessageInput: FederationRoomSendExternalMessageDto): Promise { const { internalRoomId, internalSenderId, message } = roomSendExternalMessageInput; - const federatedSender = await this.internalUserAdapter.getFederatedUserByInternalId(internalSenderId); if (!federatedSender) { throw new Error(`Could not find user id for ${internalSenderId}`); @@ -150,9 +153,72 @@ export class FederationRoomServiceSender extends FederationService { if (!federatedRoom) { throw new Error(`Could not find room id for ${internalRoomId}`); } - - await this.bridge.sendMessage(federatedRoom.getExternalId(), federatedSender.getExternalId(), message.msg); + await getExternalMessageSender(message, this.bridge, this.internalFileAdapter).sendMessage( + federatedRoom.getExternalId(), + federatedSender.getExternalId(), + message, + ); return message; // this need to be here due to a limitation in the internal API that was expecting the return of the sendMessage function. } + + public async afterMessageDeleted(internalMessage: IMessage, internalRoomId: string): Promise { + const federatedRoom = await this.internalRoomAdapter.getFederatedRoomByInternalId(internalRoomId); + if (!federatedRoom) { + return; + } + + const federatedUser = internalMessage.u?._id && (await this.internalUserAdapter.getFederatedUserByInternalId(internalMessage.u._id)); + if (!federatedUser) { + return; + } + + if (!isMessageFromMatrixFederation(internalMessage) || isDeletedMessage(internalMessage)) { + return; + } + + const isUserFromTheSameHomeServer = FederatedUser.isOriginalFromTheProxyServer( + this.bridge.extractHomeserverOrigin(federatedUser.getExternalId()), + this.internalSettingsAdapter.getHomeServerDomain(), + ); + if (!isUserFromTheSameHomeServer) { + return; + } + + await this.bridge.redactEvent( + federatedRoom.getExternalId(), + federatedUser.getExternalId(), + internalMessage.federation?.eventId as string, + ); + } + + public async afterMessageUpdated(internalMessage: IMessage, internalRoomId: string, internalUserId: string): Promise { + const federatedRoom = await this.internalRoomAdapter.getFederatedRoomByInternalId(internalRoomId); + if (!federatedRoom) { + return; + } + + const federatedUser = await this.internalUserAdapter.getFederatedUserByInternalId(internalUserId); + if (!federatedUser) { + return; + } + if (!isMessageFromMatrixFederation(internalMessage) || !isEditedMessage(internalMessage) || internalMessage.u._id !== internalUserId) { + return; + } + + const isUserFromTheSameHomeServer = FederatedUser.isOriginalFromTheProxyServer( + this.bridge.extractHomeserverOrigin(federatedUser.getExternalId()), + this.internalSettingsAdapter.getHomeServerDomain(), + ); + if (!isUserFromTheSameHomeServer) { + return; + } + + await this.bridge.updateMessage( + federatedRoom.getExternalId(), + federatedUser.getExternalId(), + internalMessage.federation?.eventId as string, + internalMessage.msg, + ); + } } diff --git a/apps/meteor/app/federation-v2/server/domain/FederatedRoom.ts b/apps/meteor/app/federation-v2/server/domain/FederatedRoom.ts index 5a740830ddf4..7a5f599850eb 100644 --- a/apps/meteor/app/federation-v2/server/domain/FederatedRoom.ts +++ b/apps/meteor/app/federation-v2/server/domain/FederatedRoom.ts @@ -1,5 +1,5 @@ import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; -import type { IRoom } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { ObjectId } from 'mongodb'; // This should not be in the domain layer, but its a known "problem" import type { FederatedUser } from './FederatedUser'; @@ -95,6 +95,10 @@ export abstract class AbstractFederatedRoom { public shouldUpdateRoomTopic(aRoomTopic: string): boolean { return this.internalReference?.topic !== aRoomTopic && !this.isDirectMessage(); } + + public static shouldUpdateMessage(newMessageText: string, originalMessage: IMessage): boolean { + return originalMessage.msg !== newMessageText; + } } export class FederatedRoom extends AbstractFederatedRoom { diff --git a/apps/meteor/app/federation-v2/server/domain/IFederationBridge.ts b/apps/meteor/app/federation-v2/server/domain/IFederationBridge.ts index da62c804cb4d..0504133b33d3 100644 --- a/apps/meteor/app/federation-v2/server/domain/IFederationBridge.ts +++ b/apps/meteor/app/federation-v2/server/domain/IFederationBridge.ts @@ -23,4 +23,16 @@ export interface IFederationBridge { leaveRoom(externalRoomId: string, externalUserId: string): Promise; kickUserFromRoom(externalRoomId: string, externalUserId: string, externalOwnerId: string): Promise; logFederationStartupInfo(info?: string): void; + getReadStreamForFileFromUrl(externaUserId: string, fileUrl: string): Promise; + redactEvent(externalRoomId: string, externalUserId: string, externalEventId: string): Promise; + updateMessage(externalRoomId: string, externalUserId: string, externalEventId: string, newMessageText: string): Promise; + sendMessageReaction(externalRoomId: string, externalUserId: string, externalEventId: string, reaction: string): Promise; + sendMessageFileToRoom( + externalRoomId: string, + externaSenderId: string, + 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; } diff --git a/apps/meteor/app/federation-v2/server/index.ts b/apps/meteor/app/federation-v2/server/index.ts index c73d09a31677..da436c6c25c1 100644 --- a/apps/meteor/app/federation-v2/server/index.ts +++ b/apps/meteor/app/federation-v2/server/index.ts @@ -1,3 +1,4 @@ +import type { FederationRoomServiceSender } from './application/sender/RoomServiceSender'; import { FederationFactory } from './infrastructure/Factory'; export const FEDERATION_PROCESSING_CONCURRENCY = 1; @@ -9,22 +10,37 @@ export const federationQueueInstance = FederationFactory.buildFederationQueue(); const federationBridge = FederationFactory.buildFederationBridge(rocketSettingsAdapter, federationQueueInstance); const rocketRoomAdapter = FederationFactory.buildRocketRoomAdapter(); const rocketUserAdapter = FederationFactory.buildRocketUserAdapter(); -const rocketMessageAdapter = FederationFactory.buildRocketMessageAdapter(); +export const rocketMessageAdapter = FederationFactory.buildRocketMessageAdapter(); +export const rocketFileAdapter = FederationFactory.buildRocketFileAdapter(); const federationRoomServiceReceiver = FederationFactory.buildRoomServiceReceiver( rocketRoomAdapter, rocketUserAdapter, rocketMessageAdapter, rocketSettingsAdapter, + rocketFileAdapter, federationBridge, ); -const federationEventsHandler = FederationFactory.buildFederationEventHandler(federationRoomServiceReceiver, rocketSettingsAdapter); +const federationMessageServiceReceiver = FederationFactory.buildMessageServiceReceiver( + rocketRoomAdapter, + rocketUserAdapter, + rocketMessageAdapter, + rocketSettingsAdapter, + federationBridge, +); -export const federationRoomServiceSender = FederationFactory.buildRoomServiceSender( +const federationEventsHandler = FederationFactory.buildFederationEventHandler( + federationRoomServiceReceiver, + federationMessageServiceReceiver, + rocketSettingsAdapter, +); + +export let federationRoomServiceSender = FederationFactory.buildRoomServiceSender( rocketRoomAdapter, rocketUserAdapter, rocketSettingsAdapter, + rocketFileAdapter, federationBridge, ); @@ -35,10 +51,25 @@ const federationRoomInternalHooksValidator = FederationFactory.buildRoomInternal federationBridge, ); -FederationFactory.setupListeners(federationRoomServiceSender, federationRoomInternalHooksValidator); +const federationMessageServiceSender = FederationFactory.buildMessageServiceSender( + rocketRoomAdapter, + rocketUserAdapter, + rocketSettingsAdapter, + rocketMessageAdapter, + federationBridge, +); + let cancelSettingsObserver: () => void; export const runFederation = async (): Promise => { + federationRoomServiceSender = FederationFactory.buildRoomServiceSender( + rocketRoomAdapter, + rocketUserAdapter, + rocketSettingsAdapter, + rocketFileAdapter, + federationBridge, + ); + FederationFactory.setupListeners(federationRoomServiceSender, federationRoomInternalHooksValidator, federationMessageServiceSender); federationQueueInstance.setHandler(federationEventsHandler.handleEvent.bind(federationEventsHandler), FEDERATION_PROCESSING_CONCURRENCY); cancelSettingsObserver = rocketSettingsAdapter.onFederationEnabledStatusChanged( federationBridge.onFederationAvailabilityChanged.bind(federationBridge), @@ -51,7 +82,12 @@ export const runFederation = async (): Promise => { require('./infrastructure/rocket-chat/slash-commands'); }; -export const stopFederation = async (): Promise => { +const updateServiceSenderInstance = (federationRoomServiceSenderInstance: FederationRoomServiceSender) => { + federationRoomServiceSender = federationRoomServiceSenderInstance; +}; + +export const stopFederation = async (federationRoomServiceSenderInstance: FederationRoomServiceSender): Promise => { + updateServiceSenderInstance(federationRoomServiceSenderInstance); FederationFactory.removeListeners(); 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 d922f6b7aab0..56034c01b2a5 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/Factory.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/Factory.ts @@ -1,4 +1,4 @@ -import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { FederationRoomServiceListener } from '../application/RoomServiceListener'; import { FederationRoomServiceSender } from '../application/sender/RoomServiceSender'; @@ -7,6 +7,7 @@ import { MatrixEventsHandler } from './matrix/handlers'; import type { MatrixBaseEventHandler } from './matrix/handlers/BaseEvent'; import { MatrixRoomCreatedHandler, + MatrixRoomEventRedactedHandler, MatrixRoomJoinRulesChangedHandler, MatrixRoomMembershipChangedHandler, MatrixRoomMessageSentHandler, @@ -22,6 +23,10 @@ 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 { RocketChatFileAdapter } from './rocket-chat/adapters/File'; +import { FederationMessageServiceListener } from '../application/MessageServiceListener'; +import { MatrixMessageReactedHandler } from './matrix/handlers/Message'; +import { FederationMessageServiceSender } from '../application/sender/MessageServiceSender'; export class FederationFactory { public static buildRocketSettingsAdapter(): RocketChatSettingsAdapter { @@ -40,6 +45,10 @@ export class FederationFactory { return new RocketChatMessageAdapter(); } + public static buildRocketFileAdapter(): RocketChatFileAdapter { + return new RocketChatFileAdapter(); + } + public static buildFederationQueue(): InMemoryQueue { return new InMemoryQueue(); } @@ -49,18 +58,47 @@ export class FederationFactory { rocketUserAdapter: RocketChatUserAdapter, rocketMessageAdapter: RocketChatMessageAdapter, rocketSettingsAdapter: RocketChatSettingsAdapter, + rocketFileAdapter: RocketChatFileAdapter, bridge: IFederationBridge, ): FederationRoomServiceListener { - return new FederationRoomServiceListener(rocketRoomAdapter, rocketUserAdapter, rocketMessageAdapter, rocketSettingsAdapter, bridge); + return new FederationRoomServiceListener( + rocketRoomAdapter, + rocketUserAdapter, + rocketMessageAdapter, + rocketSettingsAdapter, + rocketFileAdapter, + bridge, + ); } public static buildRoomServiceSender( rocketRoomAdapter: RocketChatRoomAdapter, rocketUserAdapter: RocketChatUserAdapter, rocketSettingsAdapter: RocketChatSettingsAdapter, + rocketFileAdapter: RocketChatFileAdapter, bridge: IFederationBridge, ): FederationRoomServiceSender { - return new FederationRoomServiceSender(rocketRoomAdapter, rocketUserAdapter, rocketSettingsAdapter, bridge); + return new FederationRoomServiceSender(rocketRoomAdapter, rocketUserAdapter, rocketSettingsAdapter, rocketFileAdapter, bridge); + } + + public static buildMessageServiceSender( + rocketRoomAdapter: RocketChatRoomAdapter, + rocketUserAdapter: RocketChatUserAdapter, + rocketSettingsAdapter: RocketChatSettingsAdapter, + rocketMessageAdapter: RocketChatMessageAdapter, + bridge: IFederationBridge, + ): FederationMessageServiceSender { + return new FederationMessageServiceSender(rocketRoomAdapter, rocketUserAdapter, rocketSettingsAdapter, rocketMessageAdapter, bridge); + } + + public static buildMessageServiceReceiver( + rocketRoomAdapter: RocketChatRoomAdapter, + rocketUserAdapter: RocketChatUserAdapter, + rocketMessageAdapter: RocketChatMessageAdapter, + rocketSettingsAdapter: RocketChatSettingsAdapter, + bridge: IFederationBridge, + ): FederationMessageServiceListener { + return new FederationMessageServiceListener(rocketRoomAdapter, rocketUserAdapter, rocketMessageAdapter, rocketSettingsAdapter, bridge); } public static buildRoomInternalHooksValidator( @@ -86,13 +124,15 @@ export class FederationFactory { public static buildFederationEventHandler( roomServiceReceive: FederationRoomServiceListener, + messageServiceReceiver: FederationMessageServiceListener, rocketSettingsAdapter: RocketChatSettingsAdapter, ): MatrixEventsHandler { - return new MatrixEventsHandler(FederationFactory.getEventHandlers(roomServiceReceive, rocketSettingsAdapter)); + return new MatrixEventsHandler(FederationFactory.getEventHandlers(roomServiceReceive, messageServiceReceiver, rocketSettingsAdapter)); } public static getEventHandlers( roomServiceReceiver: FederationRoomServiceListener, + messageServiceReceiver: FederationMessageServiceListener, rocketSettingsAdapter: RocketChatSettingsAdapter, ): MatrixBaseEventHandler[] { return [ @@ -102,18 +142,21 @@ export class FederationFactory { new MatrixRoomJoinRulesChangedHandler(roomServiceReceiver), new MatrixRoomNameChangedHandler(roomServiceReceiver), new MatrixRoomTopicChangedHandler(roomServiceReceiver), + new MatrixRoomEventRedactedHandler(roomServiceReceiver), + new MatrixMessageReactedHandler(messageServiceReceiver), ]; } public static setupListeners( roomServiceSender: FederationRoomServiceSender, roomInternalHooksValidator: FederationRoomInternalHooksValidator, + messageServiceSender: FederationMessageServiceSender, ): void { - FederationFactory.setupActions(roomServiceSender); + FederationFactory.setupActions(roomServiceSender, messageServiceSender); FederationFactory.setupValidators(roomInternalHooksValidator); } - private static setupActions(roomServiceSender: FederationRoomServiceSender): void { + private static setupActions(roomServiceSender: FederationRoomServiceSender, messageServiceSender: FederationMessageServiceSender): void { FederationHooks.afterUserLeaveRoom((user: IUser, room: IRoom) => roomServiceSender.afterUserLeaveRoom(FederationRoomSenderConverter.toAfterUserLeaveRoom(user._id, room._id)), ); @@ -122,6 +165,16 @@ export class FederationFactory { FederationRoomSenderConverter.toOnUserRemovedFromRoom(user._id, room._id, userWhoRemoved._id), ), ); + FederationHooks.afterMessageReacted((message: IMessage, user: IUser, reaction: string) => + messageServiceSender.sendExternalMessageReaction(message, user, reaction), + ); + FederationHooks.afterMessageunReacted((message: IMessage, user: IUser, reaction: string) => + messageServiceSender.sendExternalMessageUnReaction(message, user, reaction), + ); + FederationHooks.afterMessageDeleted((message: IMessage, roomId: string) => roomServiceSender.afterMessageDeleted(message, roomId)); + FederationHooks.afterMessageUpdated((message: IMessage, roomId: string, userId: string) => + roomServiceSender.afterMessageUpdated(message, roomId, userId), + ); } private static setupValidators(roomInternalHooksValidator: FederationRoomInternalHooksValidator): void { 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 fb9d6b614ec5..c2da4da9b2a2 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/matrix/Bridge.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/Bridge.ts @@ -1,8 +1,12 @@ 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 { federationBridgeLogger } from '../rocket-chat/adapters/logger'; +import { convertEmojisRCFormatToMatrixFormat } from './converters/MessageReceiver'; import type { AbstractMatrixEvent } from './definitions/AbstractMatrixEvent'; +import { MatrixEnumRelatesToRelType, MatrixEnumSendMessageType } from './definitions/events/RoomMessageSent'; +import { MatrixEventType } from './definitions/MatrixEventType'; import { MatrixRoomType } from './definitions/MatrixRoomType'; import { MatrixRoomVisibility } from './definitions/MatrixRoomVisibility'; @@ -144,12 +148,25 @@ export class MatrixBridge implements IFederationBridge { public async sendMessage(externalRoomId: string, externaSenderId: string, text: string): Promise { try { - await this.bridgeInstance.getIntent(externaSenderId).sendText(externalRoomId, text); + await this.bridgeInstance.getIntent(externaSenderId).sendText(externalRoomId, this.escapeEmojis(text)); } catch (e) { throw new Error('User is not part of the room.'); } } + private escapeEmojis(text: string): string { + return convertEmojisRCFormatToMatrixFormat(text); + } + + public async getReadStreamForFileFromUrl(externalUserId: string, fileUrl: string): Promise { + const response = await fetch(this.convertMatrixUrlToHttp(externalUserId, fileUrl)); + if (!response.body) { + throw new Error('Not able to download the file'); + } + + return response.body; + } + public isUserIdFromTheSameHomeserver(externalUserId: string, domain: string): boolean { const userDomain = this.extractHomeserverOrigin(externalUserId); @@ -185,6 +202,114 @@ export class MatrixBridge implements IFederationBridge { await this.bridgeInstance.getIntent(externalOwnerId).kick(externalRoomId, externalUserId); } + public async redactEvent(externalRoomId: string, externalUserId: string, externalEventId: string): Promise { + await this.bridgeInstance.getIntent(externalUserId).matrixClient.redactEvent(externalRoomId, externalEventId); + } + + public async sendMessageReaction( + externalRoomId: string, + externalUserId: string, + externalEventId: string, + reaction: string, + ): Promise { + const eventId = await this.bridgeInstance + .getIntent(externalUserId) + .matrixClient.sendEvent(externalRoomId, MatrixEventType.MESSAGE_REACTED, { + 'm.relates_to': { + event_id: externalEventId, + key: convertEmojisRCFormatToMatrixFormat(reaction), + rel_type: 'm.annotation', + }, + }); + + return eventId; + } + + public async updateMessage( + externalRoomId: string, + externalUserId: string, + externalEventId: string, + newMessageText: string, + ): Promise { + await this.bridgeInstance.getIntent(externalUserId).matrixClient.sendEvent(externalRoomId, MatrixEventType.ROOM_MESSAGE_SENT, { + 'body': ` * ${newMessageText}`, + 'm.new_content': { + body: newMessageText, + msgtype: MatrixEnumSendMessageType.TEXT, + }, + 'm.relates_to': { + rel_type: MatrixEnumRelatesToRelType.REPLACE, + event_id: externalEventId, + }, + 'msgtype': MatrixEnumSendMessageType.TEXT, + }); + } + + public async sendMessageFileToRoom( + externalRoomId: string, + externaSenderId: string, + content: Buffer, + fileDetails: { filename: string; fileSize: number; mimeType: string; metadata?: { width?: number; height?: number; format?: string } }, + ): Promise { + try { + const mxcUrl = await this.bridgeInstance.getIntent(externaSenderId).uploadContent(content); + await this.bridgeInstance.getIntent(externaSenderId).sendMessage(externalRoomId, { + body: fileDetails.filename, + filename: fileDetails.filename, + info: { + size: fileDetails.fileSize, + mimetype: fileDetails.mimeType, + ...(fileDetails.metadata?.height && fileDetails.metadata?.width + ? { h: fileDetails.metadata?.height, w: fileDetails.metadata?.width } + : {}), + }, + msgtype: this.getMsgTypeBasedOnMimeType(fileDetails.mimeType), + url: mxcUrl, + }); + } catch (e: any) { + if (e.body?.includes('413') || e.body?.includes('M_TOO_LARGE')) { + throw new Error('File is too large'); + } + } + } + + private getMsgTypeBasedOnMimeType(mimeType: string): MatrixEnumSendMessageType { + const knownImageMimeTypes = ['image/jpeg', 'image/png', 'image/gif']; + const knownAudioMimeTypes = ['audio/mpeg', 'audio/ogg', 'audio/wav']; + const knownVideoMimeTypes = ['video/mp4', 'video/ogg', 'video/webm']; + + if (knownImageMimeTypes.includes(mimeType)) { + return MatrixEnumSendMessageType.IMAGE; + } + if (knownAudioMimeTypes.includes(mimeType)) { + return MatrixEnumSendMessageType.AUDIO; + } + if (knownVideoMimeTypes.includes(mimeType)) { + return MatrixEnumSendMessageType.VIDEO; + } + return MatrixEnumSendMessageType.FILE; + } + + public async uploadContent( + externalSenderId: string, + content: Buffer, + options?: { name?: string; type?: string }, + ): Promise { + try { + const mxcUrl = await this.bridgeInstance.getIntent(externalSenderId).uploadContent(content, options); + + return mxcUrl; + } catch (e: any) { + if (e.body?.includes('413') || e.body?.includes('M_TOO_LARGE')) { + throw new Error('File is too large'); + } + } + } + + public convertMatrixUrlToHttp(externalUserId: string, matrixUrl: string): string { + return this.bridgeInstance.getIntent(externalUserId).matrixClient.mxcToHttp(matrixUrl); + } + protected async createInstance(): Promise { federationBridgeLogger.info('Performing Dynamic Import of matrix-appservice-bridge'); diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/MessageReceiver.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/MessageReceiver.ts new file mode 100644 index 000000000000..6fd409591a10 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/MessageReceiver.ts @@ -0,0 +1,21 @@ +import emojione from 'emojione'; + +import type { MatrixEventMessageReact } from '../definitions/events/MessageReacted'; +import { FederationMessageReactionEventDto } from '../../../application/input/MessageReceiverDto'; +import { convertExternalRoomIdToInternalRoomIdFormat } from './RoomReceiver'; + +const convertEmojisMatrixFormatToRCFormat = (emoji: string): string => emojione.toShort(emoji); +export const convertEmojisRCFormatToMatrixFormat = (emoji: string): string => emojione.shortnameToUnicode(emoji); + +export class MatrixMessageReceiverConverter { + public static toMessageReactionDto(externalEvent: MatrixEventMessageReact): FederationMessageReactionEventDto { + return new FederationMessageReactionEventDto({ + externalEventId: externalEvent.event_id, + externalRoomId: externalEvent.room_id, + normalizedRoomId: convertExternalRoomIdToInternalRoomIdFormat(externalEvent.room_id), + externalSenderId: externalEvent.sender, + emoji: convertEmojisMatrixFormatToRCFormat(externalEvent.content['m.relates_to'].key), + externalReactedEventId: externalEvent.content['m.relates_to'].event_id, + }); + } +} 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 dc4cdcbf2c84..284c806b6100 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 @@ -6,7 +6,10 @@ import { FederationRoomChangeNameDto, FederationRoomChangeTopicDto, FederationRoomCreateInputDto, + FederationRoomEditExternalMessageDto, + FederationRoomReceiveExternalFileMessageDto, FederationRoomReceiveExternalMessageDto, + FederationRoomRedactEventDto, } from '../../../application/input/RoomReceiverDto'; import { EVENT_ORIGIN } from '../../../domain/IFederationBridge'; import type { MatrixEventRoomMembershipChanged } from '../definitions/events/RoomMembershipChanged'; @@ -19,6 +22,7 @@ import type { MatrixEventRoomJoinRulesChanged } from '../definitions/events/Room import type { MatrixEventRoomNameChanged } from '../definitions/events/RoomNameChanged'; import type { MatrixEventRoomTopicChanged } from '../definitions/events/RoomTopicChanged'; import type { AbstractMatrixEvent } from '../definitions/AbstractMatrixEvent'; +import type { MatrixEventRoomRedacted } from '../definitions/events/RoomEventRedacted'; export const removeExternalSpecificCharsFromExternalIdentifier = (matrixIdentifier = ''): string => { return matrixIdentifier.replace('@', '').replace('!', ''); @@ -38,7 +42,7 @@ export const extractServerNameFromExternalIdentifier = (identifier = ''): string return splitted.length > 1 ? splitted[1] : ''; }; -const convertExternalRoomIdToInternalRoomIdFormat = (matrixRoomId = ''): string => { +export const convertExternalRoomIdToInternalRoomIdFormat = (matrixRoomId = ''): string => { const prefixedRoomIdOnly = matrixRoomId.split(':')[0]; const prefix = '!'; @@ -95,6 +99,7 @@ const tryToExtractAndConvertRoomTypeFromTheRoomState = ( export class MatrixRoomReceiverConverter { public static toRoomCreateDto(externalEvent: MatrixEventRoomCreated): FederationRoomCreateInputDto { return new FederationRoomCreateInputDto({ + externalEventId: externalEvent.event_id, externalRoomId: externalEvent.room_id, normalizedRoomId: convertExternalRoomIdToInternalRoomIdFormat(externalEvent.room_id), ...tryToExtractExternalRoomNameFromTheRoomState(externalEvent.invite_room_state || externalEvent.unsigned?.invite_room_state), @@ -111,6 +116,7 @@ export class MatrixRoomReceiverConverter { homeServerDomain: string, ): FederationRoomChangeMembershipDto { return new FederationRoomChangeMembershipDto({ + externalEventId: externalEvent.event_id, externalRoomId: externalEvent.room_id, normalizedRoomId: convertExternalRoomIdToInternalRoomIdFormat(externalEvent.room_id), ...tryToExtractExternalRoomNameFromTheRoomState(externalEvent.invite_room_state || externalEvent.unsigned?.invite_room_state), @@ -131,16 +137,55 @@ export class MatrixRoomReceiverConverter { public static toSendRoomMessageDto(externalEvent: MatrixEventRoomMessageSent): FederationRoomReceiveExternalMessageDto { return new FederationRoomReceiveExternalMessageDto({ + externalEventId: externalEvent.event_id, externalRoomId: externalEvent.room_id, normalizedRoomId: convertExternalRoomIdToInternalRoomIdFormat(externalEvent.room_id), externalSenderId: externalEvent.sender, normalizedSenderId: removeExternalSpecificCharsFromExternalIdentifier(externalEvent.sender), - messageText: externalEvent.content?.body, + messageText: externalEvent.content.body, + }); + } + + public static toEditRoomMessageDto(externalEvent: MatrixEventRoomMessageSent): FederationRoomEditExternalMessageDto { + return new FederationRoomEditExternalMessageDto({ + externalEventId: externalEvent.event_id, + externalRoomId: externalEvent.room_id, + normalizedRoomId: convertExternalRoomIdToInternalRoomIdFormat(externalEvent.room_id), + externalSenderId: externalEvent.sender, + normalizedSenderId: removeExternalSpecificCharsFromExternalIdentifier(externalEvent.sender), + newMessageText: externalEvent.content['m.new_content']?.body as string, + editsEvent: externalEvent.content['m.relates_to']?.event_id as string, + }); + } + + public static toSendRoomFileMessageDto(externalEvent: MatrixEventRoomMessageSent): FederationRoomReceiveExternalFileMessageDto { + if (!externalEvent.content.url) { + throw new Error('Missing url in the file message'); + } + if (!externalEvent.content.info?.mimetype) { + throw new Error('Missing mimetype in the file message info'); + } + if (!externalEvent.content.info?.size) { + throw new Error('Missing size in the file message info'); + } + + return new FederationRoomReceiveExternalFileMessageDto({ + externalEventId: externalEvent.event_id, + externalRoomId: externalEvent.room_id, + normalizedRoomId: convertExternalRoomIdToInternalRoomIdFormat(externalEvent.room_id), + externalSenderId: externalEvent.sender, + normalizedSenderId: removeExternalSpecificCharsFromExternalIdentifier(externalEvent.sender), + filename: externalEvent.content.body, + url: externalEvent.content.url, + mimetype: externalEvent.content.info.mimetype, + size: externalEvent.content.info.size, + messageText: externalEvent.content.body, }); } public static toRoomChangeJoinRulesDto(externalEvent: MatrixEventRoomJoinRulesChanged): FederationRoomChangeJoinRulesDto { return new FederationRoomChangeJoinRulesDto({ + externalEventId: externalEvent.event_id, externalRoomId: externalEvent.room_id, normalizedRoomId: convertExternalRoomIdToInternalRoomIdFormat(externalEvent.room_id), roomType: convertExternalJoinRuleToInternalRoomType(externalEvent.content?.join_rule), @@ -149,6 +194,7 @@ export class MatrixRoomReceiverConverter { public static toRoomChangeNameDto(externalEvent: MatrixEventRoomNameChanged): FederationRoomChangeNameDto { return new FederationRoomChangeNameDto({ + externalEventId: externalEvent.event_id, externalRoomId: externalEvent.room_id, normalizedRoomId: convertExternalRoomIdToInternalRoomIdFormat(externalEvent.room_id), externalSenderId: externalEvent.sender, @@ -158,10 +204,21 @@ export class MatrixRoomReceiverConverter { public static toRoomChangeTopicDto(externalEvent: MatrixEventRoomTopicChanged): FederationRoomChangeTopicDto { return new FederationRoomChangeTopicDto({ + externalEventId: externalEvent.event_id, externalRoomId: externalEvent.room_id, normalizedRoomId: convertExternalRoomIdToInternalRoomIdFormat(externalEvent.room_id), externalSenderId: externalEvent.sender, roomTopic: externalEvent.content?.topic, }); } + + public static toRoomRedactEventDto(externalEvent: MatrixEventRoomRedacted): FederationRoomRedactEventDto { + return new FederationRoomRedactEventDto({ + externalEventId: externalEvent.event_id, + externalRoomId: externalEvent.room_id, + normalizedRoomId: convertExternalRoomIdToInternalRoomIdFormat(externalEvent.room_id), + externalSenderId: externalEvent.sender, + redactsEvent: externalEvent.redacts as string, + }); + } } diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/AbstractMatrixEvent.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/AbstractMatrixEvent.ts index 4f141e8cabbb..d7a1082b9720 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/AbstractMatrixEvent.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/AbstractMatrixEvent.ts @@ -17,6 +17,8 @@ export abstract class AbstractMatrixEvent { public user_id: string; + public redacts?: string; + public abstract content: IBaseEventContent; public abstract type: string; 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 fd0fa4b21924..60e944f09ae2 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 @@ -9,4 +9,6 @@ export enum MatrixEventType { // SET_ROOM_HISTORY_VISIBILITY = 'm.room.history_visibility', // SET_ROOM_GUEST_ACCESS = 'm.room.guest_access', ROOM_TOPIC_CHANGED = 'm.room.topic', + ROOM_EVENT_REDACTED = 'm.room.redaction', + MESSAGE_REACTED = 'm.reaction', } diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/MessageReacted.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/MessageReacted.ts new file mode 100644 index 000000000000..9275ca423ce4 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/MessageReacted.ts @@ -0,0 +1,17 @@ +import type { IBaseEventContent } from '../AbstractMatrixEvent'; +import { AbstractMatrixEvent } from '../AbstractMatrixEvent'; +import { MatrixEventType } from '../MatrixEventType'; + +export interface IMatrixEventContentMessageReacted extends IBaseEventContent { + 'm.relates_to': { + event_id: string; + key: string; + rel_type: string; + }; +} + +export class MatrixEventMessageReact extends AbstractMatrixEvent { + public content: IMatrixEventContentMessageReacted; + + public type = MatrixEventType.MESSAGE_REACTED; +} diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/RoomEventRedacted.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/RoomEventRedacted.ts new file mode 100644 index 000000000000..93dd83fb85f9 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/RoomEventRedacted.ts @@ -0,0 +1,11 @@ +import type { IBaseEventContent } from '../AbstractMatrixEvent'; +import { AbstractMatrixEvent } from '../AbstractMatrixEvent'; +import { MatrixEventType } from '../MatrixEventType'; + +export type IMatrixEventContentRoomRedacted = IBaseEventContent; + +export class MatrixEventRoomRedacted extends AbstractMatrixEvent { + public content: IMatrixEventContentRoomRedacted; + + public type = MatrixEventType.ROOM_EVENT_REDACTED; +} diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/RoomMessageSent.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/RoomMessageSent.ts index 52ad3de06b94..16e7b2da2c67 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/RoomMessageSent.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/RoomMessageSent.ts @@ -2,13 +2,47 @@ import type { IBaseEventContent } from '../AbstractMatrixEvent'; import { AbstractMatrixEvent } from '../AbstractMatrixEvent'; import { MatrixEventType } from '../MatrixEventType'; -export enum MatrixSendMessageType { - 'm.text', +type MatrixSendMessageType = 'm.text' | 'm.emote' | 'm.notice' | 'm.image' | 'm.file' | 'm.audio' | 'm.location' | 'm.video' | string; + +export enum MatrixEnumSendMessageType { + TEXT = 'm.text', + EMOTE = 'm.emote', + NOTICE = 'm.notice', + IMAGE = 'm.image', + FILE = 'm.file', + AUDIO = 'm.audio', + LOCATION = 'm.location', + VIDEO = 'm.video', +} + +interface IMatrixContentInfo { + mimetype: string; + size: number; + duration?: number; +} + +type MatrixRelatesToRelType = 'm.replace'; + +export enum MatrixEnumRelatesToRelType { + REPLACE = 'm.replace', } export interface IMatrixEventContentRoomMessageSent extends IBaseEventContent { - body: string; - msgtype: MatrixSendMessageType; + 'body': string; + 'msgtype': MatrixSendMessageType; + 'info'?: IMatrixContentInfo; + 'url'?: string; + 'format'?: string; + 'formatted_body'?: string; + 'geo_uri'?: string; + 'm.new_content'?: { + body: string; + msgtype: MatrixSendMessageType; + }; + 'm.relates_to'?: { + rel_type: MatrixRelatesToRelType; + event_id: string; + }; } export class MatrixEventRoomMessageSent extends AbstractMatrixEvent { diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/Message.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/Message.ts new file mode 100644 index 000000000000..8c429e093f52 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/Message.ts @@ -0,0 +1,17 @@ +import type { FederationMessageServiceListener } from '../../../application/MessageServiceListener'; +import { MatrixMessageReceiverConverter } from '../converters/MessageReceiver'; +import type { MatrixEventMessageReact } from '../definitions/events/MessageReacted'; +import { MatrixEventType } from '../definitions/MatrixEventType'; +import { MatrixBaseEventHandler } from './BaseEvent'; + +export class MatrixMessageReactedHandler extends MatrixBaseEventHandler { + public eventType: string = MatrixEventType.MESSAGE_REACTED; + + constructor(private messageService: FederationMessageServiceListener) { + super(); + } + + public async handle(externalEvent: MatrixEventMessageReact): Promise { + await this.messageService.onMessageReaction(MatrixMessageReceiverConverter.toMessageReactionDto(externalEvent)); + } +} diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/Room.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/Room.ts index b9d596cefbd7..739c71384601 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/Room.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/Room.ts @@ -6,9 +6,11 @@ import type { MatrixEventRoomCreated } from '../definitions/events/RoomCreated'; import type { MatrixEventRoomMembershipChanged } from '../definitions/events/RoomMembershipChanged'; import type { MatrixEventRoomJoinRulesChanged } from '../definitions/events/RoomJoinRulesChanged'; import type { MatrixEventRoomNameChanged } from '../definitions/events/RoomNameChanged'; -import type { MatrixEventRoomMessageSent } from '../definitions/events/RoomMessageSent'; +import type { IMatrixEventContentRoomMessageSent, MatrixEventRoomMessageSent } from '../definitions/events/RoomMessageSent'; +import { MatrixEnumRelatesToRelType, MatrixEnumSendMessageType } from '../definitions/events/RoomMessageSent'; import type { MatrixEventRoomTopicChanged } from '../definitions/events/RoomTopicChanged'; import { MatrixEventType } from '../definitions/MatrixEventType'; +import type { MatrixEventRoomRedacted } from '../definitions/events/RoomEventRedacted'; export class MatrixRoomCreatedHandler extends MatrixBaseEventHandler { public eventType: string = MatrixEventType.ROOM_CREATED; @@ -43,8 +45,39 @@ export class MatrixRoomMessageSentHandler extends MatrixBaseEventHandler { super(); } + private executeTextMessageHandler(eventContent: IMatrixEventContentRoomMessageSent, externalEvent: MatrixEventRoomMessageSent): any { + const isAnEditionEvent = + eventContent['m.new_content'] && + eventContent['m.relates_to'] && + eventContent['m.relates_to'].rel_type === MatrixEnumRelatesToRelType.REPLACE; + return isAnEditionEvent + ? this.roomService.onExternalMessageEditedReceived(MatrixRoomReceiverConverter.toEditRoomMessageDto(externalEvent)) + : this.roomService.onExternalMessageReceived(MatrixRoomReceiverConverter.toSendRoomMessageDto(externalEvent)); + } + public async handle(externalEvent: MatrixEventRoomMessageSent): Promise { - await this.roomService.onExternalMessageReceived(MatrixRoomReceiverConverter.toSendRoomMessageDto(externalEvent)); + const handlers = { + [MatrixEnumSendMessageType.TEXT]: () => this.executeTextMessageHandler(externalEvent.content, externalEvent), + [MatrixEnumSendMessageType.AUDIO]: () => + this.roomService.onExternalFileMessageReceived(MatrixRoomReceiverConverter.toSendRoomFileMessageDto(externalEvent)), + [MatrixEnumSendMessageType.FILE]: () => + this.roomService.onExternalFileMessageReceived(MatrixRoomReceiverConverter.toSendRoomFileMessageDto(externalEvent)), + [MatrixEnumSendMessageType.IMAGE]: () => + this.roomService.onExternalFileMessageReceived(MatrixRoomReceiverConverter.toSendRoomFileMessageDto(externalEvent)), + [MatrixEnumSendMessageType.NOTICE]: () => + this.roomService.onExternalMessageReceived(MatrixRoomReceiverConverter.toSendRoomMessageDto(externalEvent)), + [MatrixEnumSendMessageType.VIDEO]: () => + this.roomService.onExternalFileMessageReceived(MatrixRoomReceiverConverter.toSendRoomFileMessageDto(externalEvent)), + [MatrixEnumSendMessageType.EMOTE]: () => + this.roomService.onExternalMessageReceived(MatrixRoomReceiverConverter.toSendRoomMessageDto(externalEvent)), + [MatrixEnumSendMessageType.LOCATION]: () => { + throw new Error('Location events are not supported yet'); + }, + }; + const defaultHandler = () => + this.roomService.onExternalMessageReceived(MatrixRoomReceiverConverter.toSendRoomMessageDto(externalEvent)); + + await (handlers[externalEvent.content.msgtype as MatrixEnumSendMessageType] || defaultHandler)(); } } @@ -83,3 +116,15 @@ export class MatrixRoomTopicChangedHandler extends MatrixBaseEventHandler { await this.roomService.onChangeRoomTopic(MatrixRoomReceiverConverter.toRoomChangeTopicDto(externalEvent)); } } + +export class MatrixRoomEventRedactedHandler extends MatrixBaseEventHandler { + public eventType: string = MatrixEventType.ROOM_EVENT_REDACTED; + + constructor(private roomService: FederationRoomServiceListener) { + super(); + } + + public async handle(externalEvent: MatrixEventRoomRedacted): Promise { + await this.roomService.onRedactEvent(MatrixRoomReceiverConverter.toRoomRedactEventDto(externalEvent)); + } +} diff --git a/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/index.ts b/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/index.ts index d99a39edbf3a..47469b74320b 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/index.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/index.ts @@ -10,6 +10,10 @@ export class MatrixEventsHandler { if (!handler) { return console.log(`Could not find handler for ${event.type}`, event); } - return handler.handle(event); + try { + await handler.handle(event); + } catch (e: any) { + throw new Meteor.Error(e.message); + } } } diff --git a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/File.ts b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/File.ts new file mode 100644 index 000000000000..a176c458be72 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/File.ts @@ -0,0 +1,64 @@ +import { Meteor } from 'meteor/meteor'; +import type { IMessage, IUpload, IUser } from '@rocket.chat/core-typings'; +import { Uploads } from '@rocket.chat/models'; + +import { FileUpload } from '../../../../../file-upload/server'; +import { parseFileIntoMessageAttachments } from '../../../../../file-upload/server/methods/sendFileMessage'; + +export class RocketChatFileAdapter { + public async uploadFile( + readableStream: ReadableStream, + internalRoomId: string, + internalUser: IUser, + fileRecord: Partial, + ): Promise<{ files: IMessage['files']; attachments: IMessage['attachments'] }> { + return new Promise<{ files: IMessage['files']; attachments: IMessage['attachments'] }>((resolve, reject) => { + const fileStore = FileUpload.getStore('Uploads'); + // this needs to be here due to a high coupling in the third party lib that rely on the logged in user + Meteor.runAsUser(internalUser._id, async () => { + const uploadedFile = fileStore.insertSync(fileRecord, readableStream); + try { + const { files, attachments } = await parseFileIntoMessageAttachments(uploadedFile, internalRoomId, internalUser); + + resolve({ files, attachments }); + } catch (error) { + reject(error); + } + }); + }); + } + + public async getBufferFromFileRecord(fileRecord: IUpload): Promise { + return new Promise((resolve, reject) => { + FileUpload.getBuffer(fileRecord, (err: Error, buffer: Buffer) => { + if (err) { + return reject(err); + } + resolve(buffer); + }); + }); + } + + public async getFileRecordById(fileId: string): Promise { + return Uploads.findOneById(fileId); + } + + public async extractMetadataFromFile(file: IUpload): Promise<{ height?: number; width?: number; format?: string }> { + if (file.type?.startsWith('image/')) { + const metadata = await FileUpload.extractMetadata(file); + + return { + format: metadata.format, + height: metadata.height, + width: metadata.width, + }; + } + if (file.type?.startsWith('video/')) { + return { + height: 200, + width: 250, + }; + } + return {}; + } +} diff --git a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Message.ts b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Message.ts index 9b5761b47e63..68550626f6b1 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Message.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Message.ts @@ -1,9 +1,99 @@ -import { sendMessage } from '../../../../../lib/server'; +import { Meteor } from 'meteor/meteor'; +import type { IMessage } from '@rocket.chat/core-typings'; +import { Messages } from '@rocket.chat/models'; + +import { deleteMessage, sendMessage, updateMessage } from '../../../../../lib/server'; +import { executeSetReaction } from '../../../../../reactions/server/setReaction'; import type { FederatedRoom } from '../../../domain/FederatedRoom'; import type { FederatedUser } from '../../../domain/FederatedUser'; +const DEFAULT_EMOJI_TO_REACT_WHEN_RECEIVED_EMOJI_DOES_NOT_EXIST = ':grey_question:'; + export class RocketChatMessageAdapter { - public async sendMessage(user: FederatedUser, room: FederatedRoom, messageText: string): Promise { - sendMessage(user.getInternalReference(), { msg: messageText }, room.getInternalReference()); + public async sendMessage(user: FederatedUser, room: FederatedRoom, messageText: string, externalEventId: string): Promise { + sendMessage(user.getInternalReference(), { federation: { eventId: externalEventId }, msg: messageText }, room.getInternalReference()); + } + + public async editMessage(user: FederatedUser, newMessageText: string, originalMessage: IMessage): Promise { + const updatedMessage = Object.assign({}, originalMessage, { msg: newMessageText }); + updateMessage(updatedMessage, user.getInternalReference(), originalMessage); + } + + public async sendFileMessage( + user: FederatedUser, + room: FederatedRoom, + files: IMessage['files'], + attachments: IMessage['attachments'], + externalEventId: string, + ): Promise { + Promise.resolve( + sendMessage( + user.getInternalReference(), + { + federation: { eventId: externalEventId }, + rid: room.getInternalId(), + ts: new Date(), + file: (files || [])[0], + files, + attachments, + }, + room.getInternalReference(), + ), + ); + } + + public async deleteMessage(message: IMessage, user: FederatedUser): Promise { + deleteMessage(message, user.getInternalReference()); + } + + public async reactToMessage(user: FederatedUser, message: IMessage, reaction: string, externalEventId: string): Promise { + // we need to run this as the user due to a high coupling in this function that relies on the logged in user + Meteor.runAsUser(user.getInternalId(), async () => { + try { + await executeSetReaction(reaction, message._id); + user.getUsername() && + (await Messages.setFederationReactionEventId(user.getUsername() as string, message._id, reaction, externalEventId)); + } catch (error: any) { + if (error?.message?.includes('Invalid emoji provided.')) { + await executeSetReaction(DEFAULT_EMOJI_TO_REACT_WHEN_RECEIVED_EMOJI_DOES_NOT_EXIST, message._id); + } + } + }); + } + + public async unreactToMessage(user: FederatedUser, message: IMessage, reaction: string, externalEventId: string): Promise { + // we need to run this as the user due to a high coupling in this function that relies on the logged in user + Meteor.runAsUser(user.getInternalId(), async () => { + await executeSetReaction(reaction, message._id); + await Messages.unsetFederationReactionEventId(externalEventId, message._id, reaction); + }); + } + + public async findOneByFederationIdOnReactions(federationEventId: string, user: FederatedUser): Promise { + return ( + (user.getUsername() && Messages.findOneByFederationIdAndUsernameOnReactions(federationEventId, user.getUsername() as string)) || + undefined + ); + } + + public async getMessageByFederationId(federationEventId: string): Promise { + return Messages.findOneByFederationId(federationEventId); + } + + public async unsetExternalFederationEventOnMessage(externalEventId: string, message: IMessage, reaction: string): Promise { + await Messages.unsetFederationReactionEventId(externalEventId, message._id, reaction); + } + + public async getMessageById(internalMessageId: string): Promise { + return Messages.findOneById(internalMessageId); + } + + public async setExternalFederationEventOnMessage( + username: string, + message: IMessage, + reaction: string, + externalEventId: string, + ): Promise { + await Messages.setFederationReactionEventId(username, message._id, reaction, externalEventId); } } diff --git a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/MessageConverter.ts b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/MessageConverter.ts new file mode 100644 index 000000000000..0559c471d4e7 --- /dev/null +++ b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/MessageConverter.ts @@ -0,0 +1,7 @@ +export const escapeExternalFederationEventId = (externalEventId: string): string => { + return externalEventId.replace(/\$/g, '__sign__'); +}; + +export const unescapeExternalFederationEventId = (externalEventId: string): string => { + return externalEventId.replace(/__sign__/g, '$'); +}; 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 3ed04a3cffab..53224e5543bd 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 @@ -72,6 +72,10 @@ export class RocketChatUserAdapter { return user; } + public async getInternalUserByUsername(username: string): Promise { + return Users.findOneByUsername(username); + } + public async createFederatedUser(federatedUser: FederatedUser): Promise { const existingLocalUser = federatedUser.getUsername() && (await Users.findOneByUsername(federatedUser.getUsername() as string)); if (existingLocalUser) { diff --git a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/hooks/index.ts b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/hooks/index.ts index 407d4f760b84..fab94889a165 100644 --- a/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/hooks/index.ts +++ b/apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/hooks/index.ts @@ -1,4 +1,5 @@ -import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { isMessageFromMatrixFederation, isRoomFederated, isEditedMessage } from '@rocket.chat/core-typings'; import { callbacks } from '../../../../../../lib/callbacks'; @@ -7,7 +8,7 @@ export class FederationHooks { callbacks.add( 'afterLeaveRoom', (user: IUser, room: IRoom | undefined): void => { - if (!room?.federated) { + if (!room || !isRoomFederated(room)) { return; } Promise.await(callback(user, room)); @@ -21,7 +22,7 @@ export class FederationHooks { callbacks.add( 'afterRemoveFromRoom', (params: { removedUser: IUser; userWhoRemoved: IUser }, room: IRoom | undefined): void => { - if (!room?.federated) { + if (!room || !isRoomFederated(room)) { return; } Promise.await(callback(params.removedUser, room, params.userWhoRemoved)); @@ -64,6 +65,65 @@ export class FederationHooks { ); } + public static afterMessageReacted(callback: (message: IMessage, user: IUser, reaction: string) => Promise): void { + callbacks.add( + 'afterSetReaction', + (message: IMessage, { user, reaction }: { user: IUser; reaction: string }): void => { + if (!message || !isMessageFromMatrixFederation(message)) { + return; + } + Promise.await(callback(message, user, reaction)); + }, + callbacks.priority.HIGH, + 'federation-v2-after-message-reacted', + ); + } + + public static afterMessageunReacted(callback: (message: IMessage, user: IUser, reaction: string) => Promise): void { + callbacks.add( + 'afterUnsetReaction', + (message: IMessage, { user, reaction, oldMessage }: any): void => { + if (!message || !isMessageFromMatrixFederation(message)) { + return; + } + Promise.await(callback(oldMessage, user, reaction)); + }, + callbacks.priority.HIGH, + 'federation-v2-after-message-unreacted', + ); + } + + public static afterMessageDeleted(callback: (message: IMessage, roomId: IRoom['_id']) => Promise): void { + callbacks.add( + 'afterDeleteMessage', + (message: IMessage, room: IRoom): void => { + if (!room || !isRoomFederated(room) || !isMessageFromMatrixFederation(message)) { + return; + } + Promise.await(callback(message, room._id)); + }, + callbacks.priority.HIGH, + 'federation-v2-after-room-message-deleted', + ); + } + + public static afterMessageUpdated(callback: (message: IMessage, roomId: IRoom['_id'], userId: string) => Promise): void { + callbacks.add( + 'afterSaveMessage', + (message: IMessage, room: IRoom): void => { + if (!room || !isRoomFederated(room) || !isMessageFromMatrixFederation(message)) { + return; + } + if (!isEditedMessage(message)) { + return; + } + Promise.await(callback(message, room._id, message.editedBy._id)); + }, + callbacks.priority.HIGH, + 'federation-v2-after-room-message-updated', + ); + } + public static removeCEValidation(): void { callbacks.remove('federation.beforeAddUserAToRoom', 'federation-v2-can-add-federated-user-to-federated-room'); callbacks.remove('federation.beforeCreateDirectMessage', 'federation-v2-can-create-direct-message-from-ui-ce'); diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.js b/apps/meteor/app/file-upload/server/lib/FileUpload.js index dc99688c5642..d0ae8333fe64 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.js +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.js @@ -292,6 +292,10 @@ export const FileUpload = { return result; }, + async extractMetadata(file) { + return sharp(FileUpload.getBufferSync(file)).metadata(); + }, + createImageThumbnail(file) { if (!settings.get('Message_Attachments_Thumbnails_Enabled')) { return; diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts index 5f605323d047..ccba5bbc24d7 100644 --- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts +++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; -import type { MessageAttachment, FileAttachmentProps, IUser } from '@rocket.chat/core-typings'; +import type { MessageAttachment, FileAttachmentProps, IUser, IUpload, AtLeast } from '@rocket.chat/core-typings'; import { Rooms, Uploads } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; @@ -9,6 +9,112 @@ import { canAccessRoom } from '../../../authorization/server/functions/canAccess import { SystemLogger } from '../../../../server/lib/logger/system'; import { omit } from '../../../../lib/utils/omit'; +function validateFileRequiredFields(file: Partial): asserts file is AtLeast { + const requiredFields = ['_id', 'name', 'type', 'size']; + requiredFields.forEach((field) => { + if (!Object.keys(file).includes(field)) { + throw new Meteor.Error('error-invalid-file', 'Invalid file'); + } + }); +} + +export const parseFileIntoMessageAttachments = async ( + file: Partial, + roomId: string, + user: IUser, +): Promise> => { + validateFileRequiredFields(file); + + await Uploads.updateFileComplete(file._id, user._id, omit(file, '_id')); + + const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name)}`); + + const attachments: MessageAttachment[] = []; + + const files = [ + { + _id: file._id, + name: file.name, + type: file.type, + }, + ]; + + if (/^image\/.+/.test(file.type as string)) { + const attachment: FileAttachmentProps = { + title: file.name, + type: 'file', + description: file?.description, + title_link: fileUrl, + title_link_download: true, + image_url: fileUrl, + image_type: file.type as string, + image_size: file.size, + }; + + if (file.identify?.size) { + attachment.image_dimensions = file.identify.size; + } + + try { + attachment.image_preview = await FileUpload.resizeImagePreview(file); + const thumbResult = await FileUpload.createImageThumbnail(file); + if (thumbResult) { + const { data: thumbBuffer, width, height } = thumbResult; + const thumbnail = FileUpload.uploadImageThumbnail(file, thumbBuffer, roomId, user._id); + const thumbUrl = FileUpload.getPath(`${thumbnail._id}/${encodeURI(file.name)}`); + attachment.image_url = thumbUrl; + attachment.image_type = thumbnail.type; + attachment.image_dimensions = { + width, + height, + }; + files.push({ + _id: thumbnail._id, + name: file.name, + type: thumbnail.type, + }); + } + } catch (e) { + SystemLogger.error(e); + } + attachments.push(attachment); + } else if (/^audio\/.+/.test(file.type as string)) { + const attachment: FileAttachmentProps = { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + title_link_download: true, + audio_url: fileUrl, + audio_type: file.type as string, + audio_size: file.size, + }; + attachments.push(attachment); + } else if (/^video\/.+/.test(file.type as string)) { + const attachment: FileAttachmentProps = { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + title_link_download: true, + video_url: fileUrl, + video_type: file.type as string, + video_size: file.size as number, + }; + attachments.push(attachment); + } else { + const attachment = { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + title_link_download: true, + }; + attachments.push(attachment); + } + return { files, attachments }; +}; + Meteor.methods({ async sendFileMessage(roomId, _store, file, msgData = {}) { const user = Meteor.user() as IUser | undefined; @@ -36,93 +142,7 @@ Meteor.methods({ tmid: Match.Optional(String), }); - await Uploads.updateFileComplete(file._id, user._id, omit(file, '_id')); - - const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name)}`); - - const attachments: MessageAttachment[] = []; - - const files = [ - { - _id: file._id, - name: file.name, - type: file.type, - }, - ]; - - if (/^image\/.+/.test(file.type)) { - const attachment: FileAttachmentProps = { - title: file.name, - type: 'file', - description: file.description, - title_link: fileUrl, - title_link_download: true, - image_url: fileUrl, - image_type: file.type, - image_size: file.size, - }; - - if (file.identify?.size) { - attachment.image_dimensions = file.identify.size; - } - - try { - attachment.image_preview = await FileUpload.resizeImagePreview(file); - const thumbResult = await FileUpload.createImageThumbnail(file); - if (thumbResult) { - const { data: thumbBuffer, width, height } = thumbResult; - const thumbnail = FileUpload.uploadImageThumbnail(file, thumbBuffer, roomId, user._id); - const thumbUrl = FileUpload.getPath(`${thumbnail._id}/${encodeURI(file.name)}`); - attachment.image_url = thumbUrl; - attachment.image_type = thumbnail.type; - attachment.image_dimensions = { - width, - height, - }; - files.push({ - _id: thumbnail._id, - name: file.name, - type: thumbnail.type, - }); - } - } catch (e) { - SystemLogger.error(e); - } - attachments.push(attachment); - } else if (/^audio\/.+/.test(file.type)) { - const attachment: FileAttachmentProps = { - title: file.name, - type: 'file', - description: file.description, - title_link: fileUrl, - title_link_download: true, - audio_url: fileUrl, - audio_type: file.type, - audio_size: file.size, - }; - attachments.push(attachment); - } else if (/^video\/.+/.test(file.type)) { - const attachment: FileAttachmentProps = { - title: file.name, - type: 'file', - description: file.description, - title_link: fileUrl, - title_link_download: true, - video_url: fileUrl, - video_type: file.type, - video_size: file.size, - }; - attachments.push(attachment); - } else { - const attachment = { - title: file.name, - type: 'file', - description: file.description, - title_link: fileUrl, - title_link_download: true, - }; - attachments.push(attachment); - } + const { files, attachments } = await parseFileIntoMessageAttachments(file, roomId, user); const msg = Meteor.call('sendMessage', { rid: roomId, diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts index b80a513c59f6..bdc3fb2c49f2 100644 --- a/apps/meteor/app/lib/server/functions/deleteMessage.ts +++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts @@ -49,7 +49,7 @@ export const deleteMessage = async function (message: IMessage, user: IUser): Pr }); } - const room = Rooms.findOneById(message.rid, { fields: { lastMessage: 1, prid: 1, mid: 1 } }); + const room = Rooms.findOneById(message.rid, { fields: { lastMessage: 1, prid: 1, mid: 1, federated: 1 } }); callbacks.run('afterDeleteMessage', deletedMsg, room); // update last message diff --git a/apps/meteor/app/livechat/client/ui.js b/apps/meteor/app/livechat/client/ui.js index 6b7eade52a14..742ffd8ad7c8 100644 --- a/apps/meteor/app/livechat/client/ui.js +++ b/apps/meteor/app/livechat/client/ui.js @@ -14,7 +14,7 @@ Tracker.autorun((c) => { }); AccountBox.addItem({ - name: 'Omnichannel', + name: 'Manage_Omnichannel', icon: 'headset', href: '/omnichannel/current', sideNav: 'omnichannelFlex', diff --git a/apps/meteor/app/reactions/client/init.js b/apps/meteor/app/reactions/client/init.js index b20a36dee39d..b76009a403cb 100644 --- a/apps/meteor/app/reactions/client/init.js +++ b/apps/meteor/app/reactions/client/init.js @@ -68,7 +68,7 @@ Meteor.startup(function () { id: 'reaction-message', icon: 'add-reaction', label: 'Add_Reaction', - context: ['message', 'message-mobile', 'threads'], + context: ['message', 'message-mobile', 'threads', 'federated'], action(event, props) { event.stopPropagation(); const { message = messageArgs(this).msg } = props; diff --git a/apps/meteor/app/reactions/server/setReaction.js b/apps/meteor/app/reactions/server/setReaction.js index 9cfbd264df88..310d0aefc8b4 100644 --- a/apps/meteor/app/reactions/server/setReaction.js +++ b/apps/meteor/app/reactions/server/setReaction.js @@ -57,6 +57,7 @@ async function setReaction(room, user, message, reaction, shouldReact) { let isReacted; if (userAlreadyReacted) { + const oldMessage = JSON.parse(JSON.stringify(message)); removeUserReaction(message, reaction, user.username); if (_.isEmpty(message.reactions)) { delete message.reactions; @@ -71,7 +72,7 @@ async function setReaction(room, user, message, reaction, shouldReact) { } } callbacks.run('unsetReaction', message._id, reaction); - callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact }); + callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact, oldMessage }); isReacted = false; } else { diff --git a/apps/meteor/app/settings/server/SettingsRegistry.ts b/apps/meteor/app/settings/server/SettingsRegistry.ts index 38daa398c317..a59667860685 100644 --- a/apps/meteor/app/settings/server/SettingsRegistry.ts +++ b/apps/meteor/app/settings/server/SettingsRegistry.ts @@ -1,6 +1,6 @@ import { Emitter } from '@rocket.chat/emitter'; import { isEqual } from 'underscore'; -import type { ISetting, ISettingGroup, SettingValue } from '@rocket.chat/core-typings'; +import type { ISetting, ISettingGroup, Optional, SettingValue } from '@rocket.chat/core-typings'; import { isSettingEnterprise } from '@rocket.chat/core-typings'; import type { ISettingsModel } from '@rocket.chat/model-typings'; @@ -157,23 +157,24 @@ export class SettingsRegistry { const overwrittenKeys = Object.keys(settingFromCodeOverwritten); const removedKeys = Object.keys(settingStored).filter((key) => !['_updatedAt'].includes(key) && !overwrittenKeys.includes(key)); - await this.model.updateOne( - { _id }, - { - $set: { ...settingOverwrittenProps }, - ...(removedKeys.length && { - $unset: removedKeys.reduce((unset, key) => ({ ...unset, [key]: 1 }), {}), - }), - }, - { upsert: true }, - ); + const updatedProps = (() => { + return { + ...settingOverwrittenProps, + ...(settingStoredOverwritten && + settingStored.value !== settingStoredOverwritten.value && { value: settingStoredOverwritten.value }), + }; + })(); + await this.saveUpdatedSetting(_id, updatedProps, removedKeys); return; } if (settingStored && isOverwritten) { if (settingStored.value !== settingFromCodeOverwritten.value) { - await this.model.updateOne({ _id }, settingProps, { upsert: true }); + const overwrittenKeys = Object.keys(settingFromCodeOverwritten); + const removedKeys = Object.keys(settingStored).filter((key) => !['_updatedAt'].includes(key) && !overwrittenKeys.includes(key)); + + await this.saveUpdatedSetting(_id, settingProps, removedKeys); } return; } @@ -263,4 +264,21 @@ export class SettingsRegistry { return groupSetWith({ group: _id })({}, callback); } + + private async saveUpdatedSetting( + _id: string, + settingProps: Omit, '_id'>, + removedKeys?: string[], + ): Promise { + await this.model.updateOne( + { _id }, + { + $set: settingProps, + ...(removedKeys?.length && { + $unset: removedKeys.reduce((unset, key) => ({ ...unset, [key]: 1 }), {}), + }), + }, + { upsert: true }, + ); + } } diff --git a/apps/meteor/app/settings/server/functions/settings.mocks.ts b/apps/meteor/app/settings/server/functions/settings.mocks.ts index 4612f8bc3ef7..9cd409ba0b83 100644 --- a/apps/meteor/app/settings/server/functions/settings.mocks.ts +++ b/apps/meteor/app/settings/server/functions/settings.mocks.ts @@ -40,7 +40,7 @@ class SettingsClass { this.insertCalls++; } - updateOne(query: any, update: any): void { + updateOne(query: any, update: any, options?: any): void { const existent = this.findOne(query); const data = { ...existent, ...query, ...update, ...update.$set }; @@ -49,6 +49,22 @@ class SettingsClass { Object.assign(data, update.$setOnInsert); } + if (update.$unset) { + Object.keys(update.$unset).forEach((key) => { + delete data[key]; + }); + } + + const modifiers = ['$set', '$setOnInsert', '$unset']; + + modifiers.forEach((key) => { + delete data[key]; + }); + + if (options?.upsert === true && !modifiers.some((key) => Object.keys(update).includes(key))) { + throw new Error('Invalid upsert'); + } + // console.log(query, data); this.data.set(query._id, data); diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index 48e6cde45b7a..09a69e65db33 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -36,6 +36,7 @@ import { getServicesStatistics } from './getServicesStatistics'; import { getStatistics as getEnterpriseStatistics } from '../../../../ee/app/license/server'; import { Analytics, Team, VideoConf } from '../../../../server/sdk'; import { getSettingsStatistics } from '../../../../server/lib/statistics/getSettingsStatistics'; +import { isRunningMs } from '../../../../server/lib/isRunningMs'; const wizardFields = ['Organization_Type', 'Industry', 'Size', 'Country', 'Language', 'Server_Type', 'Register_Server']; @@ -342,6 +343,7 @@ export const statistics = { ); const { oplogEnabled, mongoVersion, mongoStorageEngine } = getMongoInfo(); + statistics.msEnabled = isRunningMs(); statistics.oplogEnabled = oplogEnabled; statistics.mongoVersion = mongoVersion; statistics.mongoStorageEngine = mongoStorageEngine; diff --git a/apps/meteor/app/theme/client/imports/components/message-box.css b/apps/meteor/app/theme/client/imports/components/message-box.css index c775af9fa865..92c801d2be82 100644 --- a/apps/meteor/app/theme/client/imports/components/message-box.css +++ b/apps/meteor/app/theme/client/imports/components/message-box.css @@ -146,10 +146,6 @@ } } - &__federation_icon { - width: 20px; - } - &__action-menu { position: relative; diff --git a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.js b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.js index 0d047fe2dd29..00750726c84c 100644 --- a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.js +++ b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.js @@ -275,7 +275,6 @@ export class CachedCollection extends Emitter { } async setupListener(eventType, eventName) { - const { RoomManager } = await import('../../../ui-utils'); const { ChatRoom, CachedChatRoom } = await import('../../../models'); Notifications[eventType || this.eventType](eventName || this.eventName, (t, record) => { this.log('record received', t, record); @@ -292,10 +291,6 @@ export class CachedCollection extends Emitter { _id: record._id, }); } - if (room) { - room.name && RoomManager.close(room.t + room.name); - !room.name && RoomManager.close(room.t + room._id); - } this.collection.remove(record._id); } else { const { _id, ...recordData } = record; diff --git a/apps/meteor/app/ui-message/client/messageBox/messageBox.html b/apps/meteor/app/ui-message/client/messageBox/messageBox.html index c1e944faaed9..56e1bc9cd0b4 100644 --- a/apps/meteor/app/ui-message/client/messageBox/messageBox.html +++ b/apps/meteor/app/ui-message/client/messageBox/messageBox.html @@ -17,13 +17,9 @@ {{/if}}