[NEW] Matrix Federation events coverage expansion (support for 5 more events) (#26705)

pull/26556/head^2
Marcos Spessatto Defendi 3 years ago committed by GitHub
parent 71a4cb7933
commit ab3ed80a6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 11
      apps/meteor/app/discussion/client/discussionFromMessageBox.js
  2. 9
      apps/meteor/app/federation-v2/server/Federation.ts
  3. 19
      apps/meteor/app/federation-v2/server/application/AbstractFederationService.ts
  4. 57
      apps/meteor/app/federation-v2/server/application/MessageServiceListener.ts
  5. 61
      apps/meteor/app/federation-v2/server/application/RoomRedactionHandlers.ts
  6. 84
      apps/meteor/app/federation-v2/server/application/RoomServiceListener.ts
  7. 31
      apps/meteor/app/federation-v2/server/application/input/MessageReceiverDto.ts
  8. 170
      apps/meteor/app/federation-v2/server/application/input/RoomReceiverDto.ts
  9. 49
      apps/meteor/app/federation-v2/server/application/sender/MessageSenders.ts
  10. 98
      apps/meteor/app/federation-v2/server/application/sender/MessageServiceSender.ts
  11. 72
      apps/meteor/app/federation-v2/server/application/sender/RoomServiceSender.ts
  12. 6
      apps/meteor/app/federation-v2/server/domain/FederatedRoom.ts
  13. 12
      apps/meteor/app/federation-v2/server/domain/IFederationBridge.ts
  14. 46
      apps/meteor/app/federation-v2/server/index.ts
  15. 65
      apps/meteor/app/federation-v2/server/infrastructure/Factory.ts
  16. 127
      apps/meteor/app/federation-v2/server/infrastructure/matrix/Bridge.ts
  17. 21
      apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/MessageReceiver.ts
  18. 61
      apps/meteor/app/federation-v2/server/infrastructure/matrix/converters/RoomReceiver.ts
  19. 2
      apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/AbstractMatrixEvent.ts
  20. 2
      apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/MatrixEventType.ts
  21. 17
      apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/MessageReacted.ts
  22. 11
      apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/RoomEventRedacted.ts
  23. 42
      apps/meteor/app/federation-v2/server/infrastructure/matrix/definitions/events/RoomMessageSent.ts
  24. 17
      apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/Message.ts
  25. 49
      apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/Room.ts
  26. 6
      apps/meteor/app/federation-v2/server/infrastructure/matrix/handlers/index.ts
  27. 64
      apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/File.ts
  28. 96
      apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/Message.ts
  29. 7
      apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/MessageConverter.ts
  30. 4
      apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/adapters/User.ts
  31. 66
      apps/meteor/app/federation-v2/server/infrastructure/rocket-chat/hooks/index.ts
  32. 4
      apps/meteor/app/file-upload/server/lib/FileUpload.js
  33. 196
      apps/meteor/app/file-upload/server/methods/sendFileMessage.ts
  34. 2
      apps/meteor/app/lib/server/functions/deleteMessage.ts
  35. 2
      apps/meteor/app/reactions/client/init.js
  36. 3
      apps/meteor/app/reactions/server/setReaction.js
  37. 4
      apps/meteor/app/theme/client/imports/components/message-box.css
  38. 40
      apps/meteor/app/ui-message/client/messageBox/messageBox.html
  39. 1
      apps/meteor/app/ui-message/client/messageBox/messageBox.ts
  40. 10
      apps/meteor/app/ui-message/client/messageBox/messageBoxActions.ts
  41. 2
      apps/meteor/app/ui-utils/client/lib/MessageAction.ts
  42. 13
      apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts
  43. 4
      apps/meteor/app/ui/client/lib/fileUpload.ts
  44. 12
      apps/meteor/app/ui/client/views/app/lib/dropzone.ts
  45. 13
      apps/meteor/client/views/room/components/body/useFileUploadDropTarget.ts
  46. 23
      apps/meteor/client/views/room/lib/Toolbox/defaultActions.ts
  47. 16
      apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx
  48. 43
      apps/meteor/ee/app/federation-v2/server/application/input/RoomReceiverDto.ts
  49. 23
      apps/meteor/ee/app/federation-v2/server/application/sender/RoomServiceSender.ts
  50. 14
      apps/meteor/ee/app/federation-v2/server/application/sender/room/DMRoomInternalHooksServiceSender.ts
  51. 18
      apps/meteor/ee/app/federation-v2/server/application/sender/room/RoomInternalHooksServiceSender.ts
  52. 25
      apps/meteor/ee/app/federation-v2/server/application/sender/room/RoomServiceSender.ts
  53. 13
      apps/meteor/ee/app/federation-v2/server/index.ts
  54. 20
      apps/meteor/ee/app/federation-v2/server/infrastructure/Factory.ts
  55. 7
      apps/meteor/ee/app/federation-v2/server/infrastructure/rocket-chat/hooks/index.ts
  56. 5
      apps/meteor/ee/tests/unit/app/federation-v2/server/application/DMRoomInternalHooksServiceSender.spec.ts
  57. 13
      apps/meteor/ee/tests/unit/app/federation-v2/server/application/RoomInternalHooksServiceSender.spec.ts
  58. 95
      apps/meteor/ee/tests/unit/app/federation-v2/server/infrastructure/rocket-chat/hooks/hooks.spec.ts
  59. 5
      apps/meteor/lib/callbacks.ts
  60. 1
      apps/meteor/package.json
  61. 2
      apps/meteor/packages/rocketchat-i18n/i18n/de.i18n.json
  62. 3
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
  63. 77
      apps/meteor/server/models/raw/Messages.ts
  64. 12
      apps/meteor/tests/unit/app/federation-v2/server/unit/Federation.spec.ts
  65. 155
      apps/meteor/tests/unit/app/federation-v2/server/unit/application/MessageServiceListener.spec.ts
  66. 231
      apps/meteor/tests/unit/app/federation-v2/server/unit/application/RoomServiceListener.spec.ts
  67. 91
      apps/meteor/tests/unit/app/federation-v2/server/unit/application/sender/MessageSenders.spec.ts
  68. 286
      apps/meteor/tests/unit/app/federation-v2/server/unit/application/sender/MessageServiceSender.spec.ts
  69. 189
      apps/meteor/tests/unit/app/federation-v2/server/unit/application/sender/RoomServiceSender.spec.ts
  70. 10
      apps/meteor/tests/unit/app/federation-v2/server/unit/domain/FederatedRoom.spec.ts
  71. 10
      apps/meteor/tests/unit/app/federation-v2/server/unit/infrastructure/matrix/Bridge.spec.ts
  72. 104
      apps/meteor/tests/unit/app/federation-v2/server/unit/infrastructure/matrix/converters/RoomReceiver.spec.ts
  73. 55
      apps/meteor/tests/unit/app/federation-v2/server/unit/infrastructure/matrix/handlers/Room.spec.ts
  74. 9
      packages/core-typings/src/IMessage/IMessage.ts
  75. 8
      packages/core-typings/src/IUpload.ts
  76. 8
      packages/model-typings/src/models/IMessagesModel.ts
  77. 10
      yarn.lock

@ -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,

@ -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<typeof RoomMemberActions>[] = [
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);
}
}

@ -20,14 +20,19 @@ export abstract class FederationService {
existsOnlyOnProxyServer = false,
providedName?: string,
): Promise<void> {
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);
}

@ -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<void> {
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);
}
}

@ -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<void>;
}
class DeleteMessageHandler implements IRoomRedactionHandlers {
constructor(
private readonly internalMessageAdapter: RocketChatMessageAdapter,
private readonly message: IMessage,
private readonly federatedUser: FederatedUser,
) {}
public async handle(): Promise<void> {
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<void> {
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<IRoomRedactionHandlers | undefined> => {
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);
}
};

@ -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<void> {
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<void> {
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<void> {
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<void> {
@ -255,4 +316,23 @@ export class FederationRoomServiceListener extends FederationService {
await this.internalRoomAdapter.updateRoomTopic(federatedRoom, federatedUser);
}
public async onRedactEvent(roomRedactEventInput: FederationRoomRedactEventDto): Promise<void> {
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();
}
}

@ -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;
}

@ -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<string, any>) {
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;
}

@ -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<void>;
}
class TextExternalMessageSender implements IExternalMessageSender {
constructor(private readonly bridge: IFederationBridge) {}
public async sendMessage(externalRoomId: string, externalSenderId: string, message: IMessage): Promise<void> {
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<void> {
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);
};

@ -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<void> {
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<void> {
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);
}
}

@ -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<IMessage> {
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<void> {
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<void> {
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,
);
}
}

@ -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 {

@ -23,4 +23,16 @@ export interface IFederationBridge {
leaveRoom(externalRoomId: string, externalUserId: string): Promise<void>;
kickUserFromRoom(externalRoomId: string, externalUserId: string, externalOwnerId: string): Promise<void>;
logFederationStartupInfo(info?: string): void;
getReadStreamForFileFromUrl(externaUserId: string, fileUrl: string): Promise<ReadableStream>;
redactEvent(externalRoomId: string, externalUserId: string, externalEventId: string): Promise<void>;
updateMessage(externalRoomId: string, externalUserId: string, externalEventId: string, newMessageText: string): Promise<void>;
sendMessageReaction(externalRoomId: string, externalUserId: string, externalEventId: string, reaction: string): Promise<string>;
sendMessageFileToRoom(
externalRoomId: string,
externaSenderId: string,
content: Buffer,
fileDetails: { filename: string; fileSize: number; mimeType: string; metadata?: { width?: number; height?: number; format?: string } },
): Promise<void>;
uploadContent(externalSenderId: string, content: Buffer, options?: { name?: string; type?: string }): Promise<string | undefined>;
convertMatrixUrlToHttp(externalUserId: string, matrixUrl: string): string;
}

@ -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<void> => {
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<void> => {
require('./infrastructure/rocket-chat/slash-commands');
};
export const stopFederation = async (): Promise<void> => {
const updateServiceSenderInstance = (federationRoomServiceSenderInstance: FederationRoomServiceSender) => {
federationRoomServiceSender = federationRoomServiceSenderInstance;
};
export const stopFederation = async (federationRoomServiceSenderInstance: FederationRoomServiceSender): Promise<void> => {
updateServiceSenderInstance(federationRoomServiceSenderInstance);
FederationFactory.removeListeners();
await federationBridge.stop();
cancelSettingsObserver();

@ -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 {

@ -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<void> {
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<ReadableStream> {
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<void> {
await this.bridgeInstance.getIntent(externalUserId).matrixClient.redactEvent(externalRoomId, externalEventId);
}
public async sendMessageReaction(
externalRoomId: string,
externalUserId: string,
externalEventId: string,
reaction: string,
): Promise<string> {
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<void> {
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<void> {
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<string | undefined> {
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<void> {
federationBridgeLogger.info('Performing Dynamic Import of matrix-appservice-bridge');

@ -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,
});
}
}

@ -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,
});
}
}

@ -17,6 +17,8 @@ export abstract class AbstractMatrixEvent {
public user_id: string;
public redacts?: string;
public abstract content: IBaseEventContent;
public abstract type: string;

@ -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',
}

@ -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;
}

@ -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;
}

@ -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 {

@ -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<void> {
await this.messageService.onMessageReaction(MatrixMessageReceiverConverter.toMessageReactionDto(externalEvent));
}
}

@ -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<void> {
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<void> {
await this.roomService.onRedactEvent(MatrixRoomReceiverConverter.toRoomRedactEventDto(externalEvent));
}
}

@ -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);
}
}
}

@ -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<IUpload>,
): 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<Buffer> {
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<IUpload | undefined | null> {
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 {};
}
}

@ -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<void> {
sendMessage(user.getInternalReference(), { msg: messageText }, room.getInternalReference());
public async sendMessage(user: FederatedUser, room: FederatedRoom, messageText: string, externalEventId: string): Promise<void> {
sendMessage(user.getInternalReference(), { federation: { eventId: externalEventId }, msg: messageText }, room.getInternalReference());
}
public async editMessage(user: FederatedUser, newMessageText: string, originalMessage: IMessage): Promise<void> {
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<void> {
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<void> {
deleteMessage(message, user.getInternalReference());
}
public async reactToMessage(user: FederatedUser, message: IMessage, reaction: string, externalEventId: string): Promise<void> {
// 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<void> {
// 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<IMessage | null | undefined> {
return (
(user.getUsername() && Messages.findOneByFederationIdAndUsernameOnReactions(federationEventId, user.getUsername() as string)) ||
undefined
);
}
public async getMessageByFederationId(federationEventId: string): Promise<IMessage | null> {
return Messages.findOneByFederationId(federationEventId);
}
public async unsetExternalFederationEventOnMessage(externalEventId: string, message: IMessage, reaction: string): Promise<void> {
await Messages.unsetFederationReactionEventId(externalEventId, message._id, reaction);
}
public async getMessageById(internalMessageId: string): Promise<IMessage | null> {
return Messages.findOneById(internalMessageId);
}
public async setExternalFederationEventOnMessage(
username: string,
message: IMessage,
reaction: string,
externalEventId: string,
): Promise<void> {
await Messages.setFederationReactionEventId(username, message._id, reaction, externalEventId);
}
}

@ -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, '$');
};

@ -72,6 +72,10 @@ export class RocketChatUserAdapter {
return user;
}
public async getInternalUserByUsername(username: string): Promise<IUser | undefined> {
return Users.findOneByUsername(username);
}
public async createFederatedUser(federatedUser: FederatedUser): Promise<void> {
const existingLocalUser = federatedUser.getUsername() && (await Users.findOneByUsername(federatedUser.getUsername() as string));
if (existingLocalUser) {

@ -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>): 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>): 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>): 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>): 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');

@ -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;

@ -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<IUpload>): asserts file is AtLeast<IUpload, '_id' | 'name' | 'type' | 'size'> {
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<IUpload>,
roomId: string,
user: IUser,
): Promise<Record<string, any>> => {
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,

@ -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

@ -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;

@ -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 {

@ -146,10 +146,6 @@
}
}
&__federation_icon {
width: 20px;
}
&__action-menu {
position: relative;

@ -17,13 +17,9 @@
{{/if}}
<label class="rc-message-box__container">
{{#unless isFederatedRoom}}
<span class="rc-message-box__icon emoji-picker-icon {{#unless isEmojiEnabled}}emoji-picker-icon--disabled{{/unless}} js-emoji-picker" aria-haspopup="true">
{{> icon block="rc-input__icon-svg" icon="emoji"}}
</span>
{{else}}
<div class="rc-message-box__federation_icon"></div>
{{/unless}}
<span class="rc-message-box__icon emoji-picker-icon {{#unless isEmojiEnabled}}emoji-picker-icon--disabled{{/unless}} js-emoji-picker" aria-haspopup="true">
{{> icon block="rc-input__icon-svg" icon="emoji"}}
</span>
<textarea aria-label="{{_ 'Message'}}" name="msg" maxlength="{{maxMessageLength}}" placeholder="{{_ 'Message'}}" rows="1" class="rc-message-box__textarea js-input-message"></textarea>
<div class="js-input-message-shadow"></div>
@ -35,22 +31,20 @@
{{# if customAction }}
{{> Template.dynamic template=customAction.template data=customAction.data }}
{{ else }}
{{#unless isFederatedRoom}}
{{#if canSend}}
{{> messageBoxAudioMessage rid=rid tmid=tmid}}
<span class="rc-message-box__action-menu js-action-menu" data-desktop aria-haspopup="true" data-qa-id="menu-more-actions">
{{#if actions}}
<span class="rc-message-box__icon">
{{> icon block="rc-input__icon-svg" icon="plus"}}
</span>
{{/if}}
</span>
{{else}}
<button class="js-join rc-button rc-button--primary rc-message-box__join-button">
{{_ "join"}}
</button>
{{/if}}
{{/unless}}
{{#if canSend}}
{{> messageBoxAudioMessage rid=rid tmid=tmid}}
<span class="rc-message-box__action-menu js-action-menu" data-desktop aria-haspopup="true" data-qa-id="menu-more-actions">
{{#if actions}}
<span class="rc-message-box__icon">
{{> icon block="rc-input__icon-svg" icon="plus"}}
</span>
{{/if}}
</span>
{{else}}
<button class="js-join rc-button rc-button--primary rc-message-box__join-button">
{{_ "join"}}
</button>
{{/if}}
{{/if}}
{{/if}}

@ -279,6 +279,7 @@ Template.messageBox.helpers({
},
actions() {
const actionGroups = messageBox.actions.get();
return Object.values(actionGroups).reduce((actions, actionGroup) => [...actions, ...actionGroup], []);
},
formattingButtons() {

@ -1,6 +1,7 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
import { Tracker } from 'meteor/tracker';
import { isRoomFederated } from '@rocket.chat/core-typings';
import { VRecDialog } from '../../../ui-vrecord/client';
import { messageBox } from '../../../ui-utils/client';
@ -8,6 +9,7 @@ import { fileUpload } from '../../../ui';
import { settings } from '../../../settings/client';
import { imperativeModal } from '../../../../client/lib/imperativeModal';
import ShareLocationModal from '../../../../client/views/room/ShareLocation/ShareLocationModal';
import { Rooms } from '../../../models/client';
messageBox.actions.add('Create_new', 'Video_message', {
id: 'video-message',
@ -69,7 +71,13 @@ const canGetGeolocation = new ReactiveVar(false);
messageBox.actions.add('Share', 'My_location', {
id: 'share-location',
icon: 'map-pin',
condition: () => canGetGeolocation.get(),
condition: () => {
const room = Rooms.findOne(Session.get('openedRoom'));
if (!room) {
return false;
}
return canGetGeolocation.get() && !isRoomFederated(room);
},
async action({ rid, tmid }) {
imperativeModal.open({ component: ShareLocationModal, props: { rid, tmid, onClose: imperativeModal.close } });
},

@ -32,7 +32,7 @@ export const addMessageToList = (messagesList: IMessage[], message: IMessage): I
};
type MessageActionGroup = 'message' | 'menu';
type MessageActionContext = 'message' | 'threads' | 'message-mobile' | 'pinned' | 'direct' | 'starred' | 'mentions' | 'federated';
export type MessageActionContext = 'message' | 'threads' | 'message-mobile' | 'pinned' | 'direct' | 'starred' | 'mentions' | 'federated';
type MessageActionConditionProps = {
message: IMessage;

@ -4,6 +4,7 @@ import { Meteor } from 'meteor/meteor';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { Session } from 'meteor/session';
import type { IMessage } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import { messageArgs } from '../../../../client/lib/utils/messageArgs';
import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator';
@ -150,7 +151,7 @@ Meteor.startup(async function () {
id: 'edit-message',
icon: 'edit',
label: 'Edit',
context: ['message', 'message-mobile', 'threads'],
context: ['message', 'message-mobile', 'threads', 'federated'],
action(_, props) {
const { message = messageArgs(this).msg } = props;
const element = document.getElementById(message.tmid ? `thread-${message._id}` : message._id);
@ -159,10 +160,13 @@ Meteor.startup(async function () {
}
getChatMessagesFrom(message).edit(element);
},
condition({ message, subscription, settings }) {
condition({ message, subscription, settings, room }) {
if (subscription == null) {
return false;
}
if (isRoomFederated(room)) {
return message.u._id === Meteor.userId();
}
const hasPermission = hasAtLeastOnePermission('edit-message', message.rid);
const isEditAllowed = settings.Message_AllowEditing;
const editOwn = message.u && message.u._id === Meteor.userId();
@ -191,7 +195,7 @@ Meteor.startup(async function () {
id: 'delete-message',
icon: 'trash',
label: 'Delete',
context: ['message', 'message-mobile', 'threads'],
context: ['message', 'message-mobile', 'threads', 'federated'],
color: 'alert',
action(_, props) {
const { message = messageArgs(this).msg } = props;
@ -201,6 +205,9 @@ Meteor.startup(async function () {
if (!subscription) {
return false;
}
if (isRoomFederated(room)) {
return message.u._id === Meteor.userId();
}
const isLivechatRoom = roomCoordinator.isLivechatRoom(room.t);
if (isLivechatRoom) {
return false;

@ -2,6 +2,7 @@ import { Tracker } from 'meteor/tracker';
import { Session } from 'meteor/session';
import { Random } from 'meteor/random';
import { Meteor } from 'meteor/meteor';
import { isRoomFederated } from '@rocket.chat/core-typings';
import { settings } from '../../../settings/client';
import { UserAction, USER_ACTIVITIES } from './UserAction';
@ -11,6 +12,7 @@ import FileUploadModal from '../../../../client/views/room/modals/FileUploadModa
import { prependReplies } from '../../../../client/lib/utils/prependReplies';
import { chatMessages } from './ChatMessages';
import { getErrorMessage } from '../../../../client/lib/errorHandling';
import { Rooms } from '../../../models/client';
export type Uploading = {
id: string;
@ -188,6 +190,7 @@ export const fileUpload = async (
const key = ['messagebox', rid, tmid].filter(Boolean).join('_');
const messageBoxText = Meteor._localStorage.getItem(key) || '';
const room = Rooms.findOne({ _id: rid });
const uploadNextFile = (): void => {
const file = files.pop();
@ -201,6 +204,7 @@ export const fileUpload = async (
file: file.file,
fileName: file.name,
fileDescription: messageBoxText,
showDescription: room && !isRoomFederated(room),
onClose: (): void => {
imperativeModal.close();
uploadNextFile();

@ -1,9 +1,8 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import moment from 'moment';
import _ from 'underscore';
import { Users, Rooms } from '../../../../../models/client';
import { Users } from '../../../../../models/client';
import { roomCoordinator } from '../../../../../../client/lib/rooms/roomCoordinator';
import { settings } from '../../../../../settings/client';
import { RoomManager } from '../../../../../ui-utils/client';
@ -56,11 +55,6 @@ export const dropzoneHelpers = {
},
dragAndDropLabel(this: { _id: IRoom['_id']; rid: IRoom['_id'] }): string {
const room = Rooms.findOne({ _id: this.rid });
if (isRoomFederated(room)) {
return 'FileUpload_Disabled_for_federation';
}
if (!userCanDrop(this._id)) {
return 'error-not-allowed';
}
@ -125,12 +119,10 @@ export const dropzoneEvents = {
) {
event.currentTarget.parentNode.classList.remove('over');
const room = Rooms.findOne({ _id: this.rid });
event.stopPropagation();
event.preventDefault();
if (isRoomFederated(room) || !userCanDrop(this._id) || !settings.get('FileUpload_Enabled')) {
if (!userCanDrop(this._id) || !settings.get('FileUpload_Enabled')) {
return false;
}

@ -1,4 +1,4 @@
import { IRoom, isRoomFederated } from '@rocket.chat/core-typings';
import { IRoom } from '@rocket.chat/core-typings';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetting, useTranslation } from '@rocket.chat/ui-contexts';
import React, { ReactNode, useCallback, useMemo } from 'react';
@ -27,7 +27,6 @@ export const useFileUploadDropTarget = (
const t = useTranslation();
const fileUploadEnabled = useSetting('FileUpload_Enabled') as boolean;
const roomFederated = isRoomFederated(room);
const fileUploadAllowedForUser = useReactiveValue(
useCallback(
() => !roomCoordinator.readOnly(room._id, Users.findOne({ _id: Meteor.userId() }, { fields: { username: 1 } })),
@ -61,14 +60,6 @@ export const useFileUploadDropTarget = (
} as const;
}
if (roomFederated) {
return {
enabled: false,
reason: t('FileUpload_Disabled_for_federation'),
...overlayProps,
} as const;
}
if (!fileUploadAllowedForUser) {
return {
enabled: false,
@ -82,7 +73,7 @@ export const useFileUploadDropTarget = (
onFileDrop,
...overlayProps,
} as const;
}, [fileUploadAllowedForUser, fileUploadEnabled, onFileDrop, overlayProps, roomFederated, t]);
}, [fileUploadAllowedForUser, fileUploadEnabled, onFileDrop, overlayProps, t]);
return [triggerProps, allOverlayProps] as const;
};

@ -1,4 +1,3 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import { usePermission } from '@rocket.chat/ui-contexts';
import { useMemo, lazy } from 'react';
@ -58,21 +57,13 @@ addAction('members-list', ({ room }) => {
);
});
addAction('uploaded-files-list', ({ room }) => {
const federated = isRoomFederated(room);
return {
groups: ['channel', 'group', 'direct', 'direct_multiple', 'live', 'team'],
id: 'uploaded-files-list',
title: 'Files',
icon: 'clip',
...(federated && {
'disabled': true,
'data-tooltip': 'Files_unavailable_for_federation',
}),
template: lazy(() => import('../../contextualBar/RoomFiles')),
order: 7,
};
addAction('uploaded-files-list', {
groups: ['channel', 'group', 'direct', 'direct_multiple', 'live', 'team'],
id: 'uploaded-files-list',
title: 'Files',
icon: 'clip',
template: lazy(() => import('../../contextualBar/RoomFiles')),
order: 7,
});
addAction('keyboard-shortcut-list', {

@ -12,6 +12,7 @@ type FileUploadModalProps = {
fileName: string;
fileDescription?: string;
invalidContentType: boolean;
showDescription?: boolean;
};
const FileUploadModal = ({
@ -21,6 +22,7 @@ const FileUploadModal = ({
fileDescription,
onSubmit,
invalidContentType,
showDescription = true,
}: FileUploadModalProps): ReactElement => {
const [name, setName] = useState<string>(fileName);
const [description, setDescription] = useState<string>(fileDescription || '');
@ -86,12 +88,14 @@ const FileUploadModal = ({
</Field.Row>
{!name && <Field.Error>{t('error-the-field-is-required', { field: t('Name') })}</Field.Error>}
</Field>
<Field>
<Field.Label>{t('Upload_file_description')}</Field.Label>
<Field.Row>
<TextInput value={description} onChange={handleDescription} placeholder={t('Description')} ref={ref} />
</Field.Row>
</Field>
{showDescription && (
<Field>
<Field.Label>{t('Upload_file_description')}</Field.Label>
<Field.Row>
<TextInput value={description} onChange={handleDescription} placeholder={t('Description')} ref={ref} />
</Field.Row>
</Field>
)}
</FieldGroup>
</Modal.Content>
<Modal.Footer>

@ -1,43 +0,0 @@
import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms';
import type { IFederationReceiverBaseRoomInputDto } from '../../../../../../app/federation-v2/server/application/input/RoomReceiverDto';
import { FederationBaseRoomInputDto } from '../../../../../../app/federation-v2/server/application/input/RoomReceiverDto';
export interface IFederationCreateInputDto extends IFederationReceiverBaseRoomInputDto {
roomType: RoomType;
}
export interface IFederationRoomNameChangeInputDto extends IFederationReceiverBaseRoomInputDto {
normalizedRoomName: string;
}
export interface IFederationRoomChangeTopicInputDto extends IFederationReceiverBaseRoomInputDto {
roomTopic: string;
}
export class FederationRoomChangeJoinRulesDto extends FederationBaseRoomInputDto {
constructor({ roomType, externalRoomId, normalizedRoomId }: IFederationCreateInputDto) {
super({ externalRoomId, normalizedRoomId });
this.roomType = roomType;
}
roomType: RoomType;
}
export class FederationRoomChangeNameDto extends FederationBaseRoomInputDto {
constructor({ externalRoomId, normalizedRoomId, normalizedRoomName }: IFederationRoomNameChangeInputDto) {
super({ externalRoomId, normalizedRoomId });
this.normalizedRoomName = normalizedRoomName;
}
normalizedRoomName: string;
}
export class FederationRoomChangeTopicDto extends FederationBaseRoomInputDto {
constructor({ externalRoomId, normalizedRoomId, roomTopic }: IFederationRoomChangeTopicInputDto) {
super({ externalRoomId, normalizedRoomId });
this.roomTopic = roomTopic;
}
roomTopic: string;
}

@ -1,23 +0,0 @@
import { FederationService } from '../../../../../../app/federation-v2/server/application/AbstractFederationService';
import type { RocketChatSettingsAdapter } from '../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings';
import type { IFederationBridgeEE } from '../../domain/IFederationBridge';
import type { RocketChatRoomAdapterEE } from '../../infrastructure/rocket-chat/adapters/Room';
import type { RocketChatUserAdapterEE } from '../../infrastructure/rocket-chat/adapters/User';
import type { FederationCreateDirectMessageDto } from '../input/RoomSenderDto';
export class FederationRoomServiceSenderEE extends FederationService {
constructor(
protected internalRoomAdapter: RocketChatRoomAdapterEE,
protected internalUserAdapter: RocketChatUserAdapterEE,
protected internalSettingsAdapter: RocketChatSettingsAdapter,
protected bridge: IFederationBridgeEE,
) {
super(bridge, internalUserAdapter, internalSettingsAdapter);
}
public async createLocalDirectMessageRoom(dmRoomCreateInput: FederationCreateDirectMessageDto): Promise<void> {
const { internalInviterId, invitees } = dmRoomCreateInput;
await this.internalRoomAdapter.createLocalDirectMessageRoom(invitees, internalInviterId);
}
}

@ -1,14 +1,14 @@
import type { RocketChatSettingsAdapter } from '../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings';
import { FederatedUserEE } from '../../domain/FederatedUser';
import type { IFederationBridgeEE } from '../../domain/IFederationBridge';
import type { RocketChatRoomAdapterEE } from '../../infrastructure/rocket-chat/adapters/Room';
import type { RocketChatUserAdapterEE } from '../../infrastructure/rocket-chat/adapters/User';
import type { RocketChatSettingsAdapter } from '../../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings';
import { FederatedUserEE } from '../../../domain/FederatedUser';
import type { IFederationBridgeEE } from '../../../domain/IFederationBridge';
import type { RocketChatRoomAdapterEE } from '../../../infrastructure/rocket-chat/adapters/Room';
import type { RocketChatUserAdapterEE } from '../../../infrastructure/rocket-chat/adapters/User';
import type {
FederationBeforeDirectMessageRoomCreationDto,
FederationOnDirectMessageRoomCreationDto,
FederationRoomInviteUserDto,
} from '../input/RoomSenderDto';
import { FederationServiceEE } from './AbstractFederationService';
} from '../../input/RoomSenderDto';
import { FederationServiceEE } from '../AbstractFederationService';
export class FederationDMRoomInternalHooksServiceSender extends FederationServiceEE {
constructor(

@ -1,23 +1,25 @@
import type { RocketChatSettingsAdapter } from '../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings';
import { FederatedRoomEE } from '../../domain/FederatedRoom';
import { FederatedUserEE } from '../../domain/FederatedUser';
import type { IFederationBridgeEE } from '../../domain/IFederationBridge';
import type { RocketChatRoomAdapterEE } from '../../infrastructure/rocket-chat/adapters/Room';
import type { RocketChatUserAdapterEE } from '../../infrastructure/rocket-chat/adapters/User';
import type { RocketChatMessageAdapter } from '../../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Message';
import type { RocketChatSettingsAdapter } from '../../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings';
import { FederatedRoomEE } from '../../../domain/FederatedRoom';
import { FederatedUserEE } from '../../../domain/FederatedUser';
import type { IFederationBridgeEE } from '../../../domain/IFederationBridge';
import type { RocketChatRoomAdapterEE } from '../../../infrastructure/rocket-chat/adapters/Room';
import type { RocketChatUserAdapterEE } from '../../../infrastructure/rocket-chat/adapters/User';
import type {
FederationBeforeAddUserToARoomDto,
FederationOnRoomCreationDto,
FederationOnUsersAddedToARoomDto,
FederationRoomInviteUserDto,
FederationSetupRoomDto,
} from '../input/RoomSenderDto';
import { FederationServiceEE } from './AbstractFederationService';
} from '../../input/RoomSenderDto';
import { FederationServiceEE } from '../AbstractFederationService';
export class FederationRoomInternalHooksServiceSender extends FederationServiceEE {
constructor(
protected internalRoomAdapter: RocketChatRoomAdapterEE,
protected internalUserAdapter: RocketChatUserAdapterEE,
protected internalSettingsAdapter: RocketChatSettingsAdapter,
protected internalMessageAdapter: RocketChatMessageAdapter,
protected bridge: IFederationBridgeEE,
) {
super(bridge, internalUserAdapter, internalSettingsAdapter);

@ -0,0 +1,25 @@
import { FederationRoomServiceSender } from '../../../../../../../app/federation-v2/server/application/sender/RoomServiceSender';
import type { RocketChatFileAdapter } from '../../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/File';
import type { RocketChatSettingsAdapter } from '../../../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings';
import type { IFederationBridgeEE } from '../../../domain/IFederationBridge';
import type { RocketChatRoomAdapterEE } from '../../../infrastructure/rocket-chat/adapters/Room';
import type { RocketChatUserAdapterEE } from '../../../infrastructure/rocket-chat/adapters/User';
import type { FederationCreateDirectMessageDto } from '../../input/RoomSenderDto';
export class FederationRoomServiceSenderEE extends FederationRoomServiceSender {
constructor(
protected internalRoomAdapter: RocketChatRoomAdapterEE,
protected internalUserAdapter: RocketChatUserAdapterEE,
protected internalSettingsAdapter: RocketChatSettingsAdapter,
protected internalFileAdapter: RocketChatFileAdapter,
protected bridge: IFederationBridgeEE,
) {
super(internalRoomAdapter, internalUserAdapter, internalSettingsAdapter, internalFileAdapter, bridge);
}
public async createLocalDirectMessageRoom(dmRoomCreateInput: FederationCreateDirectMessageDto): Promise<void> {
const { internalInviterId, invitees } = dmRoomCreateInput;
await this.internalRoomAdapter.createLocalDirectMessageRoom(invitees, internalInviterId);
}
}

@ -1,4 +1,11 @@
import { runFederation, stopFederation, rocketSettingsAdapter, federationQueueInstance } from '../../../../app/federation-v2/server';
import {
runFederation,
stopFederation,
rocketSettingsAdapter,
federationQueueInstance,
rocketMessageAdapter,
rocketFileAdapter,
} from '../../../../app/federation-v2/server';
import { onToggledFeature } from '../../license/server/license';
import { FederationFactoryEE } from './infrastructure/Factory';
@ -10,6 +17,7 @@ export const federationRoomServiceSenderEE = FederationFactoryEE.buildRoomServic
rocketRoomAdapterEE,
rocketUserAdapterEE,
rocketSettingsAdapter,
rocketFileAdapter,
federationBridgeEE,
);
@ -17,6 +25,7 @@ export const federationRoomInternalHooksServiceSenderEE = FederationFactoryEE.bu
rocketRoomAdapterEE,
rocketUserAdapterEE,
rocketSettingsAdapter,
rocketMessageAdapter,
federationBridgeEE,
);
@ -36,7 +45,7 @@ let cancelSettingsObserverEE: () => void;
onToggledFeature('federation', {
up: async () => {
await stopFederation();
await stopFederation(federationRoomServiceSenderEE);
cancelSettingsObserverEE = rocketSettingsAdapter.onFederationEnabledStatusChanged(
federationBridgeEE.onFederationAvailabilityChanged.bind(federationBridgeEE),
);

@ -2,9 +2,9 @@ import type { IRoom, IUser } from '@rocket.chat/core-typings';
import type { InMemoryQueue } from '../../../../../app/federation-v2/server/infrastructure/queue/InMemoryQueue';
import type { RocketChatSettingsAdapter } from '../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Settings';
import { FederationDMRoomInternalHooksServiceSender } from '../application/sender/DMRoomInternalHooksServiceSender';
import { FederationRoomInternalHooksServiceSender } from '../application/sender/RoomInternalHooksServiceSender';
import { FederationRoomServiceSenderEE } from '../application/sender/RoomServiceSender';
import { FederationDMRoomInternalHooksServiceSender } from '../application/sender/room/DMRoomInternalHooksServiceSender';
import { FederationRoomInternalHooksServiceSender } from '../application/sender/room/RoomInternalHooksServiceSender';
import { FederationRoomServiceSenderEE } from '../application/sender/room/RoomServiceSender';
import type { IFederationBridgeEE } from '../domain/IFederationBridge';
import { MatrixBridgeEE } from './matrix/Bridge';
import { RocketChatNotificationAdapter } from './rocket-chat/adapters/Notification';
@ -12,24 +12,34 @@ import { RocketChatRoomAdapterEE } from './rocket-chat/adapters/Room';
import { RocketChatUserAdapterEE } from './rocket-chat/adapters/User';
import { FederationRoomSenderConverterEE } from './rocket-chat/converters/RoomSender';
import { FederationHooksEE } from './rocket-chat/hooks';
import type { RocketChatMessageAdapter } from '../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/Message';
import type { RocketChatFileAdapter } from '../../../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/File';
export class FederationFactoryEE {
public static buildRoomServiceSender(
rocketRoomAdapter: RocketChatRoomAdapterEE,
rocketUserAdapter: RocketChatUserAdapterEE,
rocketSettingsAdapter: RocketChatSettingsAdapter,
rocketFiledapter: RocketChatFileAdapter,
bridge: IFederationBridgeEE,
): FederationRoomServiceSenderEE {
return new FederationRoomServiceSenderEE(rocketRoomAdapter, rocketUserAdapter, rocketSettingsAdapter, bridge);
return new FederationRoomServiceSenderEE(rocketRoomAdapter, rocketUserAdapter, rocketSettingsAdapter, rocketFiledapter, bridge);
}
public static buildRoomInternalHooksServiceSender(
rocketRoomAdapter: RocketChatRoomAdapterEE,
rocketUserAdapter: RocketChatUserAdapterEE,
rocketSettingsAdapter: RocketChatSettingsAdapter,
rocketMessageAdapter: RocketChatMessageAdapter,
bridge: IFederationBridgeEE,
): FederationRoomInternalHooksServiceSender {
return new FederationRoomInternalHooksServiceSender(rocketRoomAdapter, rocketUserAdapter, rocketSettingsAdapter, bridge);
return new FederationRoomInternalHooksServiceSender(
rocketRoomAdapter,
rocketUserAdapter,
rocketSettingsAdapter,
rocketMessageAdapter,
bridge,
);
}
public static buildDMRoomInternalHooksServiceSender(

@ -1,4 +1,5 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../../../../lib/callbacks';
@ -7,7 +8,7 @@ export class FederationHooksEE {
callbacks.add(
'federation.afterCreateFederatedRoom',
(room: IRoom, { owner, originalMemberList }): void => {
if (!room.federated) {
if (!room || !isRoomFederated(room)) {
return;
}
Promise.await(callback(room, owner, originalMemberList));
@ -21,7 +22,7 @@ export class FederationHooksEE {
callbacks.add(
'afterAddedToRoom',
(params: { user: IUser; inviter: IUser }, room: IRoom): void => {
if (!room.federated) {
if (!room || !isRoomFederated(room)) {
return;
}
Promise.await(callback(room, params.inviter, [params.user]));
@ -54,7 +55,7 @@ export class FederationHooksEE {
callbacks.add(
'federation.beforeAddUserAToRoom',
(params: { user: IUser | string }, room: IRoom): void => {
if (!room.federated) {
if (!room || !isRoomFederated(room)) {
return;
}
Promise.await(callback(params.user, room));

@ -26,7 +26,7 @@ const { FederatedUserEE } = proxyquire.noCallThru().load('../../../../../../app/
});
const { FederationDMRoomInternalHooksServiceSender } = proxyquire
.noCallThru()
.load('../../../../../../app/federation-v2/server/application/sender/DMRoomInternalHooksServiceSender', {
.load('../../../../../../app/federation-v2/server/application/sender/room/DMRoomInternalHooksServiceSender', {
mongodb: {
'ObjectId': class ObjectId {
toHexString(): string {
@ -49,6 +49,7 @@ describe('FederationEE - Application - FederationDMRoomInternalHooksServiceSende
getInternalUserById: sinon.stub(),
getFederatedUserByInternalUsername: sinon.stub(),
createLocalUser: sinon.stub(),
getInternalUserByUsername: sinon.stub(),
};
const settingsAdapter = {
getHomeServerDomain: sinon.stub().returns('localDomain'),
@ -79,6 +80,7 @@ describe('FederationEE - Application - FederationDMRoomInternalHooksServiceSende
userAdapter.createFederatedUser.reset();
userAdapter.getFederatedUserByInternalUsername.reset();
userAdapter.createLocalUser.reset();
userAdapter.getInternalUserByUsername.reset();
bridge.extractHomeserverOrigin.reset();
bridge.createUser.reset();
bridge.createDirectMessageRoom.reset();
@ -121,7 +123,6 @@ describe('FederationEE - Application - FederationDMRoomInternalHooksServiceSende
username: 'username',
existsOnlyOnProxyServer: true,
});
console.log({ inviter });
expect(bridge.createUser.calledWith('username', 'name', 'localDomain')).to.be.true;
expect(userAdapter.createFederatedUser.calledWith(inviter)).to.be.true;
});

@ -26,7 +26,7 @@ const { FederatedUserEE } = proxyquire.noCallThru().load('../../../../../../app/
});
const { FederationRoomInternalHooksServiceSender } = proxyquire
.noCallThru()
.load('../../../../../../app/federation-v2/server/application/sender/RoomInternalHooksServiceSender', {
.load('../../../../../../app/federation-v2/server/application/sender/room/RoomInternalHooksServiceSender', {
mongodb: {
'ObjectId': class ObjectId {
toHexString(): string {
@ -50,10 +50,12 @@ describe('FederationEE - Application - FederationRoomInternalHooksServiceSender'
getInternalUserById: sinon.stub(),
getFederatedUserByInternalUsername: sinon.stub(),
createLocalUser: sinon.stub(),
getInternalUserByUsername: sinon.stub(),
};
const settingsAdapter = {
getHomeServerDomain: sinon.stub().returns('localDomain'),
};
const messageAdapter = {};
const bridge = {
getUserProfileInformation: sinon.stub().resolves({}),
extractHomeserverOrigin: sinon.stub(),
@ -74,7 +76,13 @@ describe('FederationEE - Application - FederationRoomInternalHooksServiceSender'
];
beforeEach(() => {
service = new FederationRoomInternalHooksServiceSender(roomAdapter as any, userAdapter as any, settingsAdapter as any, bridge as any);
service = new FederationRoomInternalHooksServiceSender(
roomAdapter as any,
userAdapter as any,
settingsAdapter as any,
messageAdapter as any,
bridge as any,
);
});
afterEach(() => {
@ -86,6 +94,7 @@ describe('FederationEE - Application - FederationRoomInternalHooksServiceSender'
userAdapter.createFederatedUser.reset();
userAdapter.getFederatedUserByInternalUsername.reset();
userAdapter.createLocalUser.reset();
userAdapter.getInternalUserByUsername.reset();
bridge.extractHomeserverOrigin.reset();
bridge.createUser.reset();
bridge.createRoom.reset();

@ -1,49 +1,50 @@
// /* eslint-disable */
// import proxyquire from 'proxyquire';
// import { expect } from 'chai';
// import sinon from 'sinon';
/* eslint-disable */
import proxyquire from 'proxyquire';
import { expect } from 'chai';
import sinon from 'sinon';
// const remove = sinon.stub();
// proxyquire.noCallThru().load('../../../../../../../../../lib/callbacks', {
// 'meteor/meteor': {},
// 'meteor/random': {
// Random: {
// id: () => 1,
// },
// },
// callbacks: {
// remove,
// },
// });
const remove = sinon.stub();
const { FederationHooksEE } = proxyquire.noCallThru().load('../../../../../../../../app/federation-v2/server/infrastructure/rocket-chat/hooks', {
'meteor/meteor': {
'@global': true,
},
'meteor/random': {
Random: {
id: () => 1,
},
'@global': true,
},
'../../../../../../../lib/callbacks': {
callbacks: { remove },
},
});
// import { FederationHooksEE } from '../../../../../../../../app/federation-v2/server/infrastructure/rocket-chat/hooks';
// describe.only('FederationEE - Infrastructure - RocketChat - Hooks', () => {
// describe('#removeAll()', () => {
// it('should remove the specific validation for EE environments', () => {
// FederationHooksEE.removeAll();
// expect(remove.callCount).to.be.equal(7);
// expect(
// remove.getCall(0).calledWith('beforeCreateDirectRoom', 'federation-v2-before-create-direct-message-room'),
// ).to.be.equal(true);
// expect(
// remove.getCall(1).calledWith('afterCreateDirectRoom', 'federation-v2-after-create-direct-message-room'),
// ).to.be.equal(true);
// expect(
// remove.getCall(2).calledWith('afterAddedToRoom', 'federation-v2-after-add-users-to-a-room'),
// ).to.be.equal(true);
// expect(
// remove.getCall(3).calledWith('federation.afterCreateFederatedRoom', 'federation-v2-after-create-room'),
// ).to.be.equal(true);
// expect(
// remove.getCall(4).calledWith('federation.beforeAddUserAToRoom', 'federation-v2-before-add-user-to-the-room'),
// ).to.be.equal(true);
// expect(
// remove.getCall(5).calledWith('afterRoomNameChange', 'federation-v2-after-room-name-changed'),
// ).to.be.equal(true);
// expect(
// remove.getCall(6).calledWith('afterRoomTopicChange', 'federation-v2-after-room-topic-changed'),
// ).to.be.equal(true);
// });
// });
// });
describe('FederationEE - Infrastructure - RocketChat - Hooks', () => {
describe('#removeAll()', () => {
it('should remove the specific validation for EE environments', () => {
FederationHooksEE.removeAll();
expect(remove.callCount).to.be.equal(7);
expect(
remove.getCall(0).calledWith('beforeCreateDirectRoom', 'federation-v2-before-create-direct-message-room'),
).to.be.equal(true);
expect(
remove.getCall(1).calledWith('afterCreateDirectRoom', 'federation-v2-after-create-direct-message-room'),
).to.be.equal(true);
expect(
remove.getCall(2).calledWith('afterAddedToRoom', 'federation-v2-after-add-users-to-a-room'),
).to.be.equal(true);
expect(
remove.getCall(3).calledWith('federation.afterCreateFederatedRoom', 'federation-v2-after-create-room'),
).to.be.equal(true);
expect(
remove.getCall(4).calledWith('federation.beforeAddUserAToRoom', 'federation-v2-before-add-user-to-the-room'),
).to.be.equal(true);
expect(
remove.getCall(5).calledWith('afterRoomNameChange', 'federation-v2-after-room-name-changed'),
).to.be.equal(true);
expect(
remove.getCall(6).calledWith('afterRoomTopicChange', 'federation-v2-after-room-topic-changed'),
).to.be.equal(true);
});
});
});

@ -66,6 +66,11 @@ type EventLikeCallbackSignatures = {
'federation.afterCreateFederatedRoom': (room: IRoom, second: { owner: IUser; originalMemberList: string[] }) => void;
'beforeCreateDirectRoom': (members: IUser[]) => void;
'federation.beforeCreateDirectMessage': (members: IUser[]) => void;
'afterSetReaction': (message: IMessage, { user, reaction }: { user: IUser; reaction: string; shouldReact: boolean }) => void;
'afterUnsetReaction': (
message: IMessage,
{ user, reaction }: { user: IUser; reaction: string; shouldReact: boolean; oldMessage: IMessage },
) => void;
'federation.beforeAddUserAToRoom': (params: { user: IUser | string; inviter: IUser }, room: IRoom) => void;
'onJoinVideoConference': (callId: VideoConference['_id'], userId?: IUser['_id']) => Promise<void>;
};

@ -98,6 +98,7 @@
"@types/cssom": "^0.4.1",
"@types/dompurify": "^2.3.3",
"@types/ejson": "^2.2.0",
"@types/emojione": "^2.2.6",
"@types/express": "^4.17.13",
"@types/express-rate-limit": "^5.1.3",
"@types/fibers": "^3.1.1",

@ -2095,8 +2095,6 @@
"files": "Dateien",
"Files": "Dateien",
"Files_only": "Entferne nur die angehängten Dateien, behalte Nachrichten",
"Files_unavailable_for_federation": "Dateien sind für Verbundräume nicht verfügbar",
"files_pruned": "Dateien gelöscht",
"FileSize_Bytes": "__fileSize__ Bytes",
"FileSize_KB": "__fileSize__ KB",
"FileSize_MB": "__fileSize__ MB",

@ -2139,8 +2139,6 @@
"files": "files",
"Files": "Files",
"Files_only": "Only remove the attached files, keep messages",
"Files_unavailable_for_federation": "Files are unavailable for Federated rooms",
"files_pruned": "files pruned",
"FileSize_Bytes": "__fileSize__ Bytes",
"FileSize_KB": "__fileSize__ KB",
"FileSize_MB": "__fileSize__ MB",
@ -2148,7 +2146,6 @@
"FileUpload_Description": "Configure file upload and storage.",
"FileUpload_Cannot_preview_file": "Cannot preview file",
"FileUpload_Disabled": "File uploads are disabled.",
"FileUpload_Disabled_for_federation": "File uploads are disabled for Federated rooms.",
"FileUpload_Enable_json_web_token_for_files": "Enable Json Web Tokens protection to file uploads",
"FileUpload_Enable_json_web_token_for_files_description": "Appends a JWT to uploaded files urls",
"FileUpload_Enabled": "File Uploads Enabled",

@ -1,10 +1,21 @@
import type { ILivechatDepartment, IMessage, IRoom, IUser, MessageTypesValues, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { FindPaginated, IMessagesModel } from '@rocket.chat/model-typings';
import type { PaginatedRequest } from '@rocket.chat/rest-typings';
import type { AggregationCursor, Collection, CountDocumentsOptions, AggregateOptions, FindCursor, Db, Filter, FindOptions } from 'mongodb';
import type {
AggregationCursor,
Collection,
CountDocumentsOptions,
AggregateOptions,
FindCursor,
Db,
Filter,
FindOptions,
IndexDescription,
} from 'mongodb';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { BaseRaw } from './BaseRaw';
import { escapeExternalFederationEventId } from '../../../app/federation-v2/server/infrastructure/rocket-chat/adapters/MessageConverter';
// @ts-ignore Circular reference on field 'attachments'
export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
@ -12,6 +23,10 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
super(db, 'message', trash);
}
protected modelIndexes(): IndexDescription[] {
return [{ key: { 'federation.eventId': 1 }, sparse: true }];
}
findVisibleByMentionAndRoomId(
username: IUser['username'],
rid: IRoom['_id'],
@ -338,4 +353,64 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
return this.find(query, options);
}
async setFederationReactionEventId(username: string, _id: string, reaction: string, federationEventId: string): Promise<void> {
await this.updateOne(
{ _id },
{
$set: {
[`reactions.${reaction}.federationReactionEventIds.${escapeExternalFederationEventId(federationEventId)}`]: username,
},
},
);
}
async unsetFederationReactionEventId(federationEventId: string, _id: string, reaction: string): Promise<void> {
await this.updateOne(
{ _id },
{
$unset: {
[`reactions.${reaction}.federationReactionEventIds.${escapeExternalFederationEventId(federationEventId)}`]: 1,
},
},
);
}
async findOneByFederationId(federationEventId: string): Promise<IMessage | null> {
return this.findOne({ 'federation.eventId': federationEventId });
}
async findOneByFederationIdAndUsernameOnReactions(federationEventId: string, username: string): Promise<IMessage | null> {
return (
await this.col
.aggregate([
{
$match: {
t: { $ne: 'rm' },
},
},
{
$project: {
document: '$$ROOT',
reactions: { $objectToArray: '$reactions' },
},
},
{
$unwind: {
path: '$reactions',
},
},
{
$match: {
$and: [
{ 'reactions.v.usernames': { $in: [username] } },
{ [`reactions.v.federationReactionEventIds.${escapeExternalFederationEventId(federationEventId)}`]: username },
],
},
},
{ $replaceRoot: { newRoot: '$document' } },
])
.toArray()
)[0] as IMessage;
}
}

@ -39,4 +39,16 @@ describe('Federation[Server] - Federation', () => {
expect(Federation.isAFederatedUsername('user:domain.com')).to.be.false;
});
});
describe('#escapeExternalFederationId()', () => {
it('should replace all "$" with "__sign__"', () => {
expect(Federation.escapeExternalFederationEventId('$stri$ng')).to.be.equal('__sign__stri__sign__ng');
});
});
describe('#unescapeExternalFederationEventId()', () => {
it('should replace all "__sign__" with "$"', () => {
expect(Federation.unescapeExternalFederationEventId('__sign__stri__sign__ng')).to.be.equal('$stri$ng');
});
});
});

@ -0,0 +1,155 @@
/* eslint-disable import/first */
import { expect } from 'chai';
import sinon from 'sinon';
import { RoomType } from '@rocket.chat/apps-engine/definition/rooms';
import proxyquire from 'proxyquire';
const { FederatedUser } = proxyquire.noCallThru().load('../../../../../../../app/federation-v2/server/domain/FederatedUser', {
mongodb: {
'ObjectId': class ObjectId {
toHexString(): string {
return 'hexString';
}
},
'@global': true,
},
});
const { FederatedRoom } = proxyquire.noCallThru().load('../../../../../../../app/federation-v2/server/domain/FederatedRoom', {
mongodb: {
'ObjectId': class ObjectId {
toHexString(): string {
return 'hexString';
}
},
'@global': true,
},
});
import { FederationMessageServiceListener } from '../../../../../../../app/federation-v2/server/application/MessageServiceListener';
describe('Federation - Application - FederationMessageServiceListener', () => {
let service: FederationMessageServiceListener;
const roomAdapter = {
getFederatedRoomByExternalId: sinon.stub(),
};
const userAdapter = {
getFederatedUserByExternalId: sinon.stub(),
};
const messageAdapter = {
getMessageByFederationId: sinon.stub(),
reactToMessage: sinon.stub(),
};
const settingsAdapter = {
getHomeServerDomain: sinon.stub().returns('localDomain'),
};
beforeEach(() => {
service = new FederationMessageServiceListener(
roomAdapter as any,
userAdapter as any,
messageAdapter as any,
settingsAdapter as any,
{} as any,
);
});
afterEach(() => {
roomAdapter.getFederatedRoomByExternalId.reset();
userAdapter.getFederatedUserByExternalId.reset();
messageAdapter.getMessageByFederationId.reset();
messageAdapter.reactToMessage.reset();
});
describe('#onMessageReaction()', () => {
const user = FederatedUser.createInstance('externalInviterId', {
name: 'normalizedInviterId',
username: 'normalizedInviterId',
existsOnlyOnProxyServer: false,
});
const room = FederatedRoom.createInstance('externalRoomId', 'normalizedRoomId', user, RoomType.CHANNEL, 'externalRoomName');
it('should NOT react to the message if the room does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(undefined);
await service.onMessageReaction({
externalReactedEventId: 'externalReactedEventId',
} as any);
expect(messageAdapter.reactToMessage.called).to.be.false;
});
it('should NOT react to the message if the user does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(undefined);
await service.onMessageReaction({
externalReactedEventId: 'externalReactedEventId',
} as any);
expect(messageAdapter.reactToMessage.called).to.be.false;
});
it('should NOT react to the message if the message does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
messageAdapter.getMessageByFederationId.resolves(undefined);
await service.onMessageReaction({
externalReactedEventId: 'externalReactedEventId',
} as any);
expect(messageAdapter.reactToMessage.called).to.be.false;
});
it('should NOT react to the message if it is not a Matrix federation one', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
messageAdapter.getMessageByFederationId.resolves({ msg: 'newMessageText' });
await service.onMessageReaction({
externalReactedEventId: 'externalReactedEventId',
} as any);
expect(messageAdapter.reactToMessage.called).to.be.false;
});
it('should NOT react to the message if the user already reacted to it', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
messageAdapter.getMessageByFederationId.resolves({
msg: 'newMessageText',
federation: { eventId: 'eventId' },
reactions: {
':emoji:': {
usernames: ['normalizedInviterId'],
},
},
});
await service.onMessageReaction({
externalReactedEventId: 'externalReactedEventId',
emoji: ':emoji:',
} as any);
expect(messageAdapter.reactToMessage.called).to.be.false;
});
it('should react to the message', async () => {
const message = {
msg: 'newMessageText',
federation: { eventId: 'eventId' },
reactions: {
':emoji:': {
usernames: [],
},
},
};
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
messageAdapter.getMessageByFederationId.resolves(message);
await service.onMessageReaction({
externalEventId: 'externalEventId',
externalReactedEventId: 'externalReactedEventId',
emoji: ':emoji:',
} as any);
expect(messageAdapter.reactToMessage.calledWith(user, message, ':emoji:', 'externalEventId')).to.be.true;
});
});
});

@ -62,17 +62,28 @@ describe('Federation - Application - FederationRoomServiceListener', () => {
const userAdapter = {
getFederatedUserByExternalId: sinon.stub(),
createFederatedUser: sinon.stub(),
getInternalUserByUsername: sinon.stub(),
};
const messageAdapter = {
sendMessage: sinon.stub(),
sendFileMessage: sinon.stub(),
deleteMessage: sinon.stub(),
getMessageByFederationId: sinon.stub(),
editMessage: sinon.stub(),
findOneByFederationIdOnReactions: sinon.stub(),
unreactToMessage: sinon.stub(),
};
const settingsAdapter = {
getHomeServerDomain: sinon.stub().returns('localDomain'),
};
const fileAdapter = {
uploadFile: sinon.stub(),
};
const bridge = {
getUserProfileInformation: sinon.stub().resolves({}),
extractHomeserverOrigin: sinon.stub().returns('localDomain'),
joinRoom: sinon.stub(),
getReadStreamForFileFromUrl: sinon.stub(),
};
beforeEach(() => {
@ -81,6 +92,7 @@ describe('Federation - Application - FederationRoomServiceListener', () => {
userAdapter as any,
messageAdapter as any,
settingsAdapter as any,
fileAdapter as any,
bridge as any,
);
});
@ -101,9 +113,18 @@ describe('Federation - Application - FederationRoomServiceListener', () => {
roomAdapter.addUserToRoom.reset();
userAdapter.getFederatedUserByExternalId.reset();
userAdapter.createFederatedUser.reset();
userAdapter.getInternalUserByUsername.reset();
messageAdapter.sendMessage.reset();
messageAdapter.sendFileMessage.reset();
messageAdapter.deleteMessage.reset();
messageAdapter.getMessageByFederationId.reset();
messageAdapter.editMessage.reset();
messageAdapter.unreactToMessage.reset();
messageAdapter.findOneByFederationIdOnReactions.reset();
bridge.extractHomeserverOrigin.reset();
bridge.joinRoom.reset();
bridge.getReadStreamForFileFromUrl.reset();
fileAdapter.uploadFile.reset();
});
describe('#onCreateRoom()', () => {
@ -526,6 +547,53 @@ describe('Federation - Application - FederationRoomServiceListener', () => {
});
});
describe('#onExternalFileMessageReceived()', () => {
const user = FederatedUser.createInstance('externalInviterId', {
name: 'normalizedInviterId',
username: 'normalizedInviterId',
existsOnlyOnProxyServer: false,
});
const room = FederatedRoom.createInstance('externalRoomId', 'normalizedRoomId', user, RoomType.CHANNEL, 'externalRoomName');
it('should NOT send a message if the room does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(undefined);
await service.onExternalFileMessageReceived({
messageText: 'text',
} as any);
expect(messageAdapter.sendFileMessage.called).to.be.false;
});
it('should NOT send a message if the sender does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves({} as any);
userAdapter.getFederatedUserByExternalId.resolves(undefined);
await service.onExternalFileMessageReceived({
messageText: 'text',
} as any);
expect(messageAdapter.sendFileMessage.called).to.be.false;
});
it('should send a message if the room and the sender already exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
bridge.getReadStreamForFileFromUrl.resolves();
const files = [{ id: 'fileId', name: 'filename' }];
const attachments = ['attachment', 'attachment2'];
fileAdapter.uploadFile.resolves({ files, attachments } as any);
await service.onExternalFileMessageReceived({
messageBody: {
filename: 'filename',
size: 12,
mimetype: 'mimetype',
url: 'url',
},
} as any);
expect(messageAdapter.sendFileMessage.calledWith(user, room, files, attachments)).to.be.true;
});
});
describe('#onExternalMessageReceived()', () => {
it('should NOT send a message if the room does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(undefined);
@ -667,4 +735,167 @@ describe('Federation - Application - FederationRoomServiceListener', () => {
expect(roomAdapter.updateRoomTopic.calledWith(room, user)).to.be.true;
});
});
describe('#onRedactEvent()', () => {
const user = FederatedUser.createInstance('externalInviterId', {
name: 'normalizedInviterId',
username: 'normalizedInviterId',
existsOnlyOnProxyServer: false,
});
const room = FederatedRoom.createInstance('externalRoomId', 'normalizedRoomId', user, RoomType.CHANNEL, 'externalRoomName');
it('should NOT delete the message if the room does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(undefined);
await service.onRedactEvent({
redactsEvent: 'redactsEvent',
} as any);
expect(messageAdapter.deleteMessage.called).to.be.false;
expect(messageAdapter.unreactToMessage.called).to.be.false;
});
it('should NOT delete the message if the sender does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(undefined);
await service.onRedactEvent({
redactsEvent: 'redactsEvent',
} as any);
expect(messageAdapter.deleteMessage.called).to.be.false;
expect(messageAdapter.unreactToMessage.called).to.be.false;
});
it('should NOT delete the message if the message does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
messageAdapter.getMessageByFederationId.resolves(undefined);
await service.onRedactEvent({
redactsEvent: 'redactsEvent',
} as any);
expect(messageAdapter.deleteMessage.called).to.be.false;
expect(messageAdapter.unreactToMessage.called).to.be.false;
});
it('should delete the message if its a raw text redact handler', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
messageAdapter.getMessageByFederationId.resolves({ msg: 'msg' });
messageAdapter.findOneByFederationIdOnReactions.resolves(undefined);
await service.onRedactEvent({
redactsEvent: 'redactsEvent',
} as any);
expect(messageAdapter.deleteMessage.calledWith({ msg: 'msg' }, user)).to.be.true;
expect(messageAdapter.unreactToMessage.called).to.be.false;
});
it('should NOT unreact if the message was not reacted before by the user', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
messageAdapter.getMessageByFederationId.resolves(undefined);
messageAdapter.findOneByFederationIdOnReactions.resolves({
msg: 'msg',
reactions: {
reaction: {
federationReactionEventIds: {},
usernames: [],
},
},
});
await service.onRedactEvent({
redactsEvent: 'redactsEvent',
} as any);
expect(messageAdapter.deleteMessage.called).to.be.false;
expect(messageAdapter.unreactToMessage.called).to.be.false;
});
it('should unreact if the message was reacted before by the user', async () => {
const message = {
msg: 'msg',
reactions: {
reaction: {
federationReactionEventIds: {
redactsEvent: user.getUsername(),
},
usernames: [user.getUsername()],
},
},
};
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
messageAdapter.getMessageByFederationId.resolves(undefined);
messageAdapter.findOneByFederationIdOnReactions.resolves(message);
await service.onRedactEvent({
redactsEvent: 'redactsEvent',
} as any);
expect(messageAdapter.deleteMessage.called).to.be.false;
expect(messageAdapter.unreactToMessage.calledWith(user, message, 'reaction', 'redactsEvent')).to.be.true;
});
});
describe('#onExternalMessageEditedReceived()', () => {
const user = FederatedUser.createInstance('externalInviterId', {
name: 'normalizedInviterId',
username: 'normalizedInviterId',
existsOnlyOnProxyServer: false,
});
const room = FederatedRoom.createInstance('externalRoomId', 'normalizedRoomId', user, RoomType.CHANNEL, 'externalRoomName');
it('should NOT update the message if the room does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(undefined);
await service.onExternalMessageEditedReceived({
editsEvent: 'editsEvent',
} as any);
expect(messageAdapter.editMessage.called).to.be.false;
});
it('should NOT update the message if the sender does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(undefined);
await service.onExternalMessageEditedReceived({
editsEvent: 'editsEvent',
} as any);
expect(messageAdapter.editMessage.called).to.be.false;
});
it('should NOT update the message if the message does not exists', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
messageAdapter.getMessageByFederationId.resolves(undefined);
await service.onExternalMessageEditedReceived({
editsEvent: 'editsEvent',
} as any);
expect(messageAdapter.editMessage.called).to.be.false;
});
it('should NOT update the message if the content of the message is equal of the oldest one', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
messageAdapter.getMessageByFederationId.resolves({ msg: 'newMessageText' });
await service.onExternalMessageEditedReceived({
editsEvent: 'editsEvent',
newMessageText: 'newMessageText',
} as any);
expect(messageAdapter.editMessage.called).to.be.false;
});
it('should update the message', async () => {
roomAdapter.getFederatedRoomByExternalId.resolves(room);
userAdapter.getFederatedUserByExternalId.resolves(user);
messageAdapter.getMessageByFederationId.resolves({ msg: 'differentOne' });
await service.onExternalMessageEditedReceived({
editsEvent: 'editsEvent',
newMessageText: 'newMessageText',
} as any);
expect(messageAdapter.editMessage.calledWith(user, 'newMessageText', { msg: 'differentOne' })).to.be.true;
});
});
});

@ -0,0 +1,91 @@
/* eslint-disable import/first */
import { expect } from 'chai';
import sinon from 'sinon';
import { getExternalMessageSender } from '../../../../../../../../app/federation-v2/server/application/sender/MessageSenders';
describe('Federation - Application - Message Senders', () => {
const bridge = {
sendMessage: sinon.stub(),
sendMessageFileToRoom: sinon.stub(),
};
const fileAdapter = {
getBufferFromFileRecord: sinon.stub(),
getFileRecordById: sinon.stub(),
extractMetadataFromFile: sinon.stub(),
};
afterEach(() => {
bridge.sendMessage.reset();
bridge.sendMessageFileToRoom.reset();
fileAdapter.getBufferFromFileRecord.reset();
fileAdapter.getFileRecordById.reset();
fileAdapter.extractMetadataFromFile.reset();
});
describe('TextExternalMessageSender', () => {
const roomId = 'roomId';
const senderId = 'senderId';
const message = { msg: 'text' } as any;
describe('#sendMessage()', () => {
it('should send a message through the bridge', async () => {
await getExternalMessageSender({} as any, bridge as any, fileAdapter as any).sendMessage(roomId, senderId, message);
expect(bridge.sendMessage.calledWith(roomId, senderId, message.msg)).to.be.true;
});
});
});
describe('FileExternalMessageSender', () => {
const roomId = 'roomId';
const senderId = 'senderId';
const message = { msg: 'text', files: [{ _id: 'fileId' }] } as any;
describe('#sendMessage()', () => {
it('should not upload the file to the bridge if the file does not exists', async () => {
fileAdapter.getFileRecordById.resolves(undefined);
await getExternalMessageSender(message, bridge as any, fileAdapter as any).sendMessage(roomId, senderId, message);
expect(bridge.sendMessageFileToRoom.called).to.be.false;
expect(fileAdapter.getBufferFromFileRecord.called).to.be.false;
});
it('should not upload the file to the bridge if the file size does not exists', async () => {
fileAdapter.getFileRecordById.resolves({});
await getExternalMessageSender(message, bridge as any, fileAdapter as any).sendMessage(roomId, senderId, message);
expect(bridge.sendMessageFileToRoom.called).to.be.false;
expect(fileAdapter.getBufferFromFileRecord.called).to.be.false;
});
it('should not upload the file to the bridge if the file type does not exists', async () => {
fileAdapter.getFileRecordById.resolves({ size: 12 });
await getExternalMessageSender(message, bridge as any, fileAdapter as any).sendMessage(roomId, senderId, message);
expect(bridge.sendMessageFileToRoom.called).to.be.false;
expect(fileAdapter.getBufferFromFileRecord.called).to.be.false;
});
it('should send a message (upload the file) through the bridge', async () => {
fileAdapter.getFileRecordById.resolves({ name: 'filename', size: 12, type: 'image/png' });
fileAdapter.getBufferFromFileRecord.resolves({ buffer: 'buffer' });
await getExternalMessageSender(message, bridge as any, fileAdapter as any).sendMessage(roomId, senderId, message);
expect(fileAdapter.getBufferFromFileRecord.calledWith({ name: 'filename', size: 12, type: 'image/png' })).to.be.true;
expect(
bridge.sendMessageFileToRoom.calledWith(
roomId,
senderId,
{ buffer: 'buffer' },
{
filename: 'filename',
fileSize: 12,
mimeType: 'image/png',
metadata: {
width: undefined,
height: undefined,
format: undefined,
},
},
),
).to.be.true;
});
});
});
});

@ -0,0 +1,286 @@
import { RoomType } from '@rocket.chat/apps-engine/definition/rooms';
import { expect } from 'chai';
import sinon from 'sinon';
import proxyquire from 'proxyquire';
import { FederationMessageServiceSender } from '../../../../../../../../app/federation-v2/server/application/sender/MessageServiceSender';
const { FederatedUser } = proxyquire.noCallThru().load('../../../../../../../../app/federation-v2/server/domain/FederatedUser', {
mongodb: {
'ObjectId': class ObjectId {
toHexString(): string {
return 'hexString';
}
},
'@global': true,
},
});
const { FederatedRoom } = proxyquire.noCallThru().load('../../../../../../../../app/federation-v2/server/domain/FederatedRoom', {
mongodb: {
'ObjectId': class ObjectId {
toHexString(): string {
return 'hexString';
}
},
'@global': true,
},
});
describe('Federation - Application - FederationMessageServiceSender', () => {
let service: FederationMessageServiceSender;
const roomAdapter = {
getFederatedRoomByInternalId: sinon.stub(),
};
const userAdapter = {
getFederatedUserByInternalId: sinon.stub(),
};
const settingsAdapter = {
getHomeServerDomain: sinon.stub().returns('localDomain'),
};
const messageAdapter = {
setExternalFederationEventOnMessage: sinon.stub(),
unsetExternalFederationEventOnMessage: sinon.stub(),
};
const bridge = {
extractHomeserverOrigin: sinon.stub(),
sendMessageReaction: sinon.stub(),
redactEvent: sinon.stub(),
};
beforeEach(() => {
service = new FederationMessageServiceSender(
roomAdapter as any,
userAdapter as any,
settingsAdapter as any,
messageAdapter as any,
bridge as any,
);
});
afterEach(() => {
roomAdapter.getFederatedRoomByInternalId.reset();
userAdapter.getFederatedUserByInternalId.reset();
bridge.extractHomeserverOrigin.reset();
messageAdapter.setExternalFederationEventOnMessage.reset();
messageAdapter.unsetExternalFederationEventOnMessage.reset();
bridge.sendMessageReaction.reset();
bridge.redactEvent.reset();
});
describe('#sendExternalMessageReaction()', () => {
const user = FederatedUser.createInstance('externalInviterId', {
name: 'normalizedInviterId',
username: 'normalizedInviterId',
existsOnlyOnProxyServer: true,
});
const room = FederatedRoom.createInstance('externalRoomId', 'normalizedRoomId', user, RoomType.CHANNEL, 'externalRoomName');
it('should not send the reaction if the internal message does not exists', async () => {
await service.sendExternalMessageReaction(undefined as any, {} as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
expect(userAdapter.getFederatedUserByInternalId.called).to.be.false;
});
it('should not send the reaction if the internal user does not exists', async () => {
await service.sendExternalMessageReaction({} as any, undefined as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
expect(userAdapter.getFederatedUserByInternalId.called).to.be.false;
});
it('should not send the reaction if the internal user id does not exists', async () => {
await service.sendExternalMessageReaction({} as any, {} as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
expect(userAdapter.getFederatedUserByInternalId.called).to.be.false;
});
it('should not send the reaction if the internal message room id does not exists', async () => {
await service.sendExternalMessageReaction({} as any, { _id: 'id' } as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
expect(userAdapter.getFederatedUserByInternalId.called).to.be.false;
});
it('should not send the reaction the user does not exists', async () => {
userAdapter.getFederatedUserByInternalId.resolves(undefined);
await service.sendExternalMessageReaction({ rid: 'roomId' } as any, { _id: 'id' } as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
});
it('should not send the reaction the room does not exists', async () => {
userAdapter.getFederatedUserByInternalId.resolves(user);
roomAdapter.getFederatedRoomByInternalId.resolves(undefined);
await service.sendExternalMessageReaction({ rid: 'roomId' } as any, { _id: 'id' } as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
});
it('should not send the reaction the the message is not from matrix federation', async () => {
userAdapter.getFederatedUserByInternalId.resolves(user);
roomAdapter.getFederatedRoomByInternalId.resolves(room);
await service.sendExternalMessageReaction({ rid: 'roomId' } as any, { _id: 'id' } as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
});
it('should not send the reaction if the user is not from the same home server', async () => {
bridge.extractHomeserverOrigin.returns('externalDomain');
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.sendExternalMessageReaction(
{ rid: 'roomId', federation: { eventId: 'eventId' } } as any,
{ _id: 'id' } as any,
'reaction',
);
expect(bridge.sendMessageReaction.called).to.be.false;
});
it('should send the reaction', async () => {
bridge.extractHomeserverOrigin.returns('localDomain');
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
bridge.sendMessageReaction.resolves('returnedEventId');
await service.sendExternalMessageReaction(
{ rid: 'roomId', federation: { eventId: 'eventId' } } as any,
{ _id: 'id' } as any,
'reaction',
);
expect(bridge.sendMessageReaction.calledWith(room.getExternalId(), user.getExternalId(), 'eventId', 'reaction')).to.be.true;
expect(
messageAdapter.setExternalFederationEventOnMessage.calledWith(
user.getUsername(),
{ rid: 'roomId', federation: { eventId: 'eventId' } },
'reaction',
'returnedEventId',
),
).to.be.true;
});
});
describe('#sendExternalMessageUnReaction()', () => {
const user = FederatedUser.createInstance('externalInviterId', {
name: 'normalizedInviterId',
username: 'normalizedInviterId',
existsOnlyOnProxyServer: true,
});
const room = FederatedRoom.createInstance('externalRoomId', 'normalizedRoomId', user, RoomType.CHANNEL, 'externalRoomName');
it('should not send the unreaction if the internal message does not exists', async () => {
await service.sendExternalMessageUnReaction(undefined as any, {} as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
expect(userAdapter.getFederatedUserByInternalId.called).to.be.false;
});
it('should not send the unreaction if the internal user does not exists', async () => {
await service.sendExternalMessageUnReaction({} as any, undefined as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
expect(userAdapter.getFederatedUserByInternalId.called).to.be.false;
});
it('should not send the unreaction if the internal user id does not exists', async () => {
await service.sendExternalMessageUnReaction({} as any, {} as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
expect(userAdapter.getFederatedUserByInternalId.called).to.be.false;
});
it('should not send the unreaction if the internal message room id does not exists', async () => {
await service.sendExternalMessageUnReaction({} as any, { _id: 'id' } as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
expect(userAdapter.getFederatedUserByInternalId.called).to.be.false;
});
it('should not send the unreaction the user does not exists', async () => {
userAdapter.getFederatedUserByInternalId.resolves(undefined);
await service.sendExternalMessageUnReaction({ rid: 'roomId' } as any, { _id: 'id' } as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
});
it('should not send the unreaction the room does not exists', async () => {
userAdapter.getFederatedUserByInternalId.resolves(user);
roomAdapter.getFederatedRoomByInternalId.resolves(undefined);
await service.sendExternalMessageUnReaction({ rid: 'roomId' } as any, { _id: 'id' } as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
});
it('should not send the unreaction the the message is not from matrix federation', async () => {
userAdapter.getFederatedUserByInternalId.resolves(user);
roomAdapter.getFederatedRoomByInternalId.resolves(room);
await service.sendExternalMessageUnReaction({ rid: 'roomId' } as any, { _id: 'id' } as any, 'reaction');
expect(bridge.sendMessageReaction.called).to.be.false;
});
it('should not send the unreaction if the user is not from the same home server', async () => {
bridge.extractHomeserverOrigin.onCall(0).returns('localDomain');
bridge.extractHomeserverOrigin.onCall(1).returns('externalDomain');
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.sendExternalMessageUnReaction(
{ rid: 'roomId', federation: { eventId: 'eventId' } } as any,
{ _id: 'id' } as any,
'reaction',
);
expect(bridge.sendMessageReaction.called).to.be.false;
});
it('should not send the unreaction if the user is not from the same home server', async () => {
bridge.extractHomeserverOrigin.returns('externalDomain');
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.sendExternalMessageUnReaction(
{ rid: 'roomId', federation: { eventId: 'eventId' } } as any,
{ _id: 'id' } as any,
'reaction',
);
expect(bridge.sendMessageReaction.called).to.be.false;
});
it('should not send the unreaction if there is no existing reaction', async () => {
bridge.extractHomeserverOrigin.returns('localDomain');
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.sendExternalMessageUnReaction(
{ rid: 'roomId', federation: { eventId: 'eventId' } } as any,
{ _id: 'id' } as any,
'reaction',
);
expect(bridge.sendMessageReaction.called).to.be.false;
});
it('should send the unreaction', async () => {
const message = {
rid: 'roomId',
federation: { eventId: 'eventId' },
reactions: {
reaction: {
federationReactionEventIds: {
eventId: user.getUsername(),
},
},
},
} as any;
bridge.extractHomeserverOrigin.returns('localDomain');
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.sendExternalMessageUnReaction(message, { _id: 'id', username: user.getUsername() } as any, 'reaction');
expect(bridge.redactEvent.calledWith(room.getExternalId(), user.getExternalId(), 'eventId')).to.be.true;
expect(messageAdapter.unsetExternalFederationEventOnMessage.calledWith('eventId', message, 'reaction')).to.be.true;
});
});
});

@ -3,10 +3,11 @@ import { expect } from 'chai';
import sinon from 'sinon';
import proxyquire from 'proxyquire';
const stub = sinon.stub();
const { FederationRoomServiceSender } = proxyquire
.noCallThru()
.load('../../../../../../../../app/federation-v2/server/application/sender/RoomServiceSender', {
mongodb: {
'mongodb': {
'ObjectId': class ObjectId {
toHexString(): string {
return 'hexString';
@ -14,6 +15,9 @@ const { FederationRoomServiceSender } = proxyquire
},
'@global': true,
},
'./MessageSenders': {
getExternalMessageSender: () => ({ sendMessage: stub }),
},
});
const { FederatedUser } = proxyquire.noCallThru().load('../../../../../../../../app/federation-v2/server/domain/FederatedUser', {
@ -54,10 +58,12 @@ describe('Federation - Application - FederationRoomServiceSender', () => {
createFederatedUser: sinon.stub(),
getInternalUserById: sinon.stub(),
getFederatedUserByInternalUsername: sinon.stub(),
getInternalUserByUsername: sinon.stub(),
};
const settingsAdapter = {
getHomeServerDomain: sinon.stub().returns('localDomain'),
};
const fileAdapter = {};
const bridge = {
getUserProfileInformation: sinon.stub().resolves({}),
extractHomeserverOrigin: sinon.stub(),
@ -68,10 +74,18 @@ describe('Federation - Application - FederationRoomServiceSender', () => {
joinRoom: sinon.stub(),
leaveRoom: sinon.stub(),
kickUserFromRoom: sinon.stub(),
redactEvent: sinon.stub(),
updateMessage: sinon.stub(),
};
beforeEach(() => {
service = new FederationRoomServiceSender(roomAdapter as any, userAdapter as any, settingsAdapter as any, bridge as any);
service = new FederationRoomServiceSender(
roomAdapter as any,
userAdapter as any,
settingsAdapter as any,
fileAdapter as any,
bridge as any,
);
});
afterEach(() => {
@ -84,6 +98,7 @@ describe('Federation - Application - FederationRoomServiceSender', () => {
userAdapter.getInternalUserById.reset();
userAdapter.createFederatedUser.reset();
userAdapter.getFederatedUserByInternalUsername.reset();
userAdapter.getInternalUserByUsername.reset();
bridge.extractHomeserverOrigin.reset();
bridge.sendMessage.reset();
bridge.createUser.reset();
@ -92,6 +107,8 @@ describe('Federation - Application - FederationRoomServiceSender', () => {
bridge.joinRoom.reset();
bridge.leaveRoom.reset();
bridge.kickUserFromRoom.reset();
bridge.redactEvent.reset();
bridge.updateMessage.reset();
});
describe('#createDirectMessageRoomAndInviteUser()', () => {
@ -405,7 +422,173 @@ describe('Federation - Application - FederationRoomServiceSender', () => {
roomAdapter.getFederatedRoomByInternalId.resolves(room);
await service.sendExternalMessage({ message: { msg: 'text' } } as any);
expect(bridge.sendMessage.calledWith(room.getExternalId(), user.getExternalId(), 'text')).to.be.true;
expect(stub.calledWith(room.getExternalId(), user.getExternalId(), { msg: 'text' })).to.be.true;
});
});
describe('#afterMessageDeleted()', () => {
const user = FederatedUser.createInstance('externalInviterId', {
name: 'normalizedInviterId',
username: 'normalizedInviterId',
existsOnlyOnProxyServer: true,
});
const room = FederatedRoom.createInstance('externalRoomId', 'normalizedRoomId', user, RoomType.CHANNEL, 'externalRoomName');
it('should not delete the message remotely if the room does not exists', async () => {
roomAdapter.getFederatedRoomByInternalId.resolves(undefined);
await service.afterMessageDeleted({ msg: 'msg', u: { _id: 'id' } } as any, 'internalRoomId');
expect(bridge.redactEvent.called).to.be.false;
});
it('should not delete the message remotely if the user does not exists', async () => {
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(undefined);
await service.afterMessageDeleted({ msg: 'msg', u: { _id: 'id' } } as any, 'internalRoomId');
expect(bridge.redactEvent.called).to.be.false;
});
it('should not delete the message remotely if the message is not an external one', async () => {
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.afterMessageDeleted({ msg: 'msg', u: { _id: 'id' } } as any, 'internalRoomId');
expect(bridge.redactEvent.called).to.be.false;
});
it('should not delete the message remotely if the message was already deleted (it was just updated to keep the chat history)', async () => {
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.afterMessageDeleted(
{
msg: 'msg',
federation: { eventId: 'id' },
editedAt: new Date(),
editedBy: 'id',
t: 'rm',
u: { _id: 'id' },
} as any,
'internalRoomId',
);
expect(bridge.redactEvent.called).to.be.false;
});
it('should not delete the message remotely if the user is not from the same home server', async () => {
bridge.extractHomeserverOrigin.returns('externalDomain');
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.afterMessageDeleted(
{
msg: 'msg',
federationEventId: 'id',
u: { _id: 'id' },
} as any,
'internalRoomId',
);
expect(bridge.redactEvent.called).to.be.false;
});
it('should delete the message remotely', async () => {
bridge.extractHomeserverOrigin.returns('localDomain');
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.afterMessageDeleted(
{
msg: 'msg',
federation: { eventId: 'federationEventId' },
u: { _id: 'id' },
} as any,
'internalRoomId',
);
expect(bridge.redactEvent.calledWith(room.getExternalId(), user.getExternalId(), 'federationEventId')).to.be.true;
});
});
describe('#afterMessageUpdated()', () => {
const user = FederatedUser.createInstance('externalInviterId', {
name: 'normalizedInviterId',
username: 'normalizedInviterId',
existsOnlyOnProxyServer: true,
});
const room = FederatedRoom.createInstance('externalRoomId', 'normalizedRoomId', user, RoomType.CHANNEL, 'externalRoomName');
it('should not update the message remotely if the room does not exists', async () => {
roomAdapter.getFederatedRoomByInternalId.resolves(undefined);
await service.afterMessageUpdated({ msg: 'msg' } as any, 'internalRoomId', 'internalUserId');
expect(bridge.updateMessage.called).to.be.false;
});
it('should not update the message remotely if the user does not exists', async () => {
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(undefined);
await service.afterMessageUpdated({ msg: 'msg' } as any, 'internalRoomId', 'internalUserId');
expect(bridge.updateMessage.called).to.be.false;
});
it('should not update the message remotely if the message is not an external one', async () => {
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.afterMessageUpdated({ msg: 'msg' } as any, 'internalRoomId', 'internalUserId');
expect(bridge.updateMessage.called).to.be.false;
});
it('should not update the message remotely if it was updated not by the sender', async () => {
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.afterMessageUpdated(
{ msg: 'msg', federation: { eventId: 'federationEventId' }, u: { _id: 'sender' } } as any,
'internalRoomId',
'internalUserId',
);
expect(bridge.updateMessage.called).to.be.false;
});
it('should not update the message remotely if the user is not from the same home server', async () => {
bridge.extractHomeserverOrigin.returns('externalDomain');
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.afterMessageUpdated(
{
msg: 'msg',
editedAt: new Date(),
editedBy: 'id',
federation: { eventId: 'federationEventId' },
u: { _id: 'internalUserId' },
} as any,
'internalRoomId',
'internalUserId',
);
expect(bridge.updateMessage.called).to.be.false;
});
it('should update the message remotely', async () => {
bridge.extractHomeserverOrigin.returns('localDomain');
roomAdapter.getFederatedRoomByInternalId.resolves(room);
userAdapter.getFederatedUserByInternalId.resolves(user);
await service.afterMessageUpdated(
{
msg: 'msg',
editedAt: new Date(),
editedBy: 'id',
federation: { eventId: 'federationEventId' },
u: { _id: 'internalUserId' },
} as any,
'internalRoomId',
'internalUserId',
);
expect(bridge.updateMessage.calledWith(room.getExternalId(), user.getExternalId(), 'federationEventId', 'msg')).to.be.true;
});
});
});

@ -222,6 +222,16 @@ describe('Federation - Domain - FederatedRoom', () => {
expect(federatedRoom.shouldUpdateRoomTopic('new topic')).to.be.equal(false);
});
});
describe('#shouldUpdateMessage()', () => {
it('should return true if the old message is different from the new one', () => {
expect(MyClass.shouldUpdateMessage('new message', { msg: 'different' })).to.be.equal(true);
});
it('should return false if the old message is EQUAL from the new one', () => {
expect(MyClass.shouldUpdateMessage('new message', { msg: 'new message' })).to.be.equal(false);
});
});
});
describe('FederatedRoom', () => {

@ -1,10 +1,16 @@
/* eslint-disable import/first */
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import { MatrixBridge } from '../../../../../../../../app/federation-v2/server/infrastructure/matrix/Bridge';
const { MatrixBridge } = proxyquire.noCallThru().load('../../../../../../../../app/federation-v2/server/infrastructure/matrix/Bridge', {
'meteor/fetch': {
'@global': true,
},
});
describe('Federation - Infrastructure - Matrix - Bridge', () => {
const defaultProxyDomain = 'server.com';
const bridge = new MatrixBridge('', '', defaultProxyDomain, '', 3030, {} as any, () => {}); // eslint-disable-line
const bridge = new MatrixBridge('', '', defaultProxyDomain, '', 3030, {} as any, () => { }); // eslint-disable-line
describe('#isUserIdFromTheSameHomeserver()', () => {
it('should return true if the userId is from the same homeserver', () => {

@ -9,6 +9,7 @@ import {
FederationRoomChangeTopicDto,
FederationRoomChangeNameDto,
FederationRoomChangeJoinRulesDto,
FederationRoomRedactEventDto,
} from '../../../../../../../../../app/federation-v2/server/application/input/RoomReceiverDto';
import { MatrixEventType } from '../../../../../../../../../app/federation-v2/server/infrastructure/matrix/definitions/MatrixEventType';
import { EVENT_ORIGIN } from '../../../../../../../../../app/federation-v2/server/domain/IFederationBridge';
@ -19,6 +20,7 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
describe('#toRoomCreateDto()', () => {
const event = {
content: { was_internally_programatically_created: true, name: 'roomName', internalRoomId: 'internalRoomId' },
event_id: 'eventId',
room_id: '!roomId:matrix.org',
sender: '@marcos.defendi:matrix.org',
};
@ -72,6 +74,7 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
it('should convert the event properly', () => {
const result = MatrixRoomReceiverConverter.toRoomCreateDto(event as any);
expect(result).to.be.eql({
externalEventId: 'eventId',
externalRoomId: '!roomId:matrix.org',
normalizedRoomId: 'roomId',
externalInviterId: '@marcos.defendi:matrix.org',
@ -86,6 +89,7 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
describe('#toChangeRoomMembershipDto()', () => {
const event = {
event_id: 'eventId',
content: { name: 'roomName' },
room_id: '!roomId:matrix.org',
sender: '@marcos.defendi:matrix.org',
@ -195,6 +199,7 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
it('should convert the event properly', () => {
const result = MatrixRoomReceiverConverter.toChangeRoomMembershipDto(event as any, 'domain');
expect(result).to.be.eql({
externalEventId: 'eventId',
externalRoomId: '!roomId:matrix.org',
normalizedRoomId: 'roomId',
externalInviterId: '@marcos.defendi:matrix.org',
@ -213,29 +218,33 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
describe('#toSendRoomMessageDto()', () => {
const event = {
event_id: 'eventId',
content: { body: 'msg' },
room_id: '!roomId:matrix.org',
sender: '@marcos.defendi:matrix.org',
};
it('should return an instance of FederationRoomReceiveExternalMessageDto', () => {
expect(MatrixRoomReceiverConverter.toSendRoomMessageDto({} as any)).to.be.instanceOf(FederationRoomReceiveExternalMessageDto);
expect(MatrixRoomReceiverConverter.toSendRoomMessageDto({ content: {} } as any)).to.be.instanceOf(
FederationRoomReceiveExternalMessageDto,
);
});
it('should return the basic room properties correctly (normalizedRoomId without any "!" and only the part before the ":") if any', () => {
const result = MatrixRoomReceiverConverter.toSendRoomMessageDto({ room_id: event.room_id } as any);
const result = MatrixRoomReceiverConverter.toSendRoomMessageDto({ room_id: event.room_id, content: {} } as any);
expect(result.externalRoomId).to.be.equal('!roomId:matrix.org');
expect(result.normalizedRoomId).to.be.equal('roomId');
});
it('should convert the sender id to the a rc-format like (without any @ in it)', () => {
const result = MatrixRoomReceiverConverter.toSendRoomMessageDto({ sender: event.sender } as any);
const result = MatrixRoomReceiverConverter.toSendRoomMessageDto({ sender: event.sender, content: {} } as any);
expect(result.normalizedSenderId).to.be.equal('marcos.defendi:matrix.org');
});
it('should convert the event properly', () => {
const result = MatrixRoomReceiverConverter.toSendRoomMessageDto(event as any);
expect(result).to.be.eql({
externalEventId: 'eventId',
externalRoomId: '!roomId:matrix.org',
normalizedRoomId: 'roomId',
externalSenderId: '@marcos.defendi:matrix.org',
@ -247,6 +256,7 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
describe('#toRoomChangeJoinRulesDto()', () => {
const event = {
event_id: 'eventId',
content: { join_rule: MatrixRoomJoinRules.JOIN },
room_id: '!roomId:matrix.org',
sender: '@marcos.defendi:matrix.org',
@ -275,6 +285,7 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
it('should convert the event properly', () => {
const result = MatrixRoomReceiverConverter.toRoomChangeJoinRulesDto(event as any);
expect(result).to.be.eql({
externalEventId: 'eventId',
externalRoomId: '!roomId:matrix.org',
normalizedRoomId: 'roomId',
roomType: RoomType.CHANNEL,
@ -284,6 +295,7 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
describe('#toRoomChangeNameDto()', () => {
const event = {
event_id: 'eventId',
content: { name: '@roomName' },
room_id: '!roomId:matrix.org',
sender: '@marcos.defendi:matrix.org',
@ -307,6 +319,7 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
it('should convert the event properly', () => {
const result = MatrixRoomReceiverConverter.toRoomChangeNameDto(event as any);
expect(result).to.be.eql({
externalEventId: 'eventId',
externalRoomId: '!roomId:matrix.org',
normalizedRoomId: 'roomId',
normalizedRoomName: 'roomName',
@ -317,6 +330,7 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
describe('#toRoomChangeTopicDto()', () => {
const event = {
event_id: 'eventId',
content: { topic: 'room topic' },
room_id: '!roomId:matrix.org',
sender: '@marcos.defendi:matrix.org',
@ -335,6 +349,7 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
it('should convert the event properly', () => {
const result = MatrixRoomReceiverConverter.toRoomChangeTopicDto(event as any);
expect(result).to.be.eql({
externalEventId: 'eventId',
externalRoomId: '!roomId:matrix.org',
normalizedRoomId: 'roomId',
roomTopic: 'room topic',
@ -342,4 +357,87 @@ describe('Federation - Infrastructure - Matrix - MatrixRoomReceiverConverter', (
});
});
});
describe('#toSendRoomFileMessageDto()', () => {
const event = {
event_id: 'eventId',
content: { body: 'filename', url: 'url', info: { mimetype: 'mime', size: 12 } },
room_id: '!roomId:matrix.org',
sender: '@marcos.defendi:matrix.org',
};
it('should throw an error if the url is not present in the file event', () => {
expect(() => MatrixRoomReceiverConverter.toSendRoomFileMessageDto({ content: {} } as any)).to.throw(
Error,
'Missing url in the file message',
);
});
it('should throw an error if the mimetype is not present in the file event', () => {
expect(() => MatrixRoomReceiverConverter.toSendRoomFileMessageDto({ content: { url: 'url' } } as any)).to.throw(
Error,
'Missing mimetype in the file message',
);
});
it('should throw an error if the size is not present in the file event', () => {
expect(() =>
MatrixRoomReceiverConverter.toSendRoomFileMessageDto({ content: { url: 'url', info: { mimetype: 'mime' } } } as any),
).to.throw(Error, 'Missing size in the file message');
});
it('should return the basic room properties correctly (normalizedRoomId without any "!" and only the part before the ":") if any', () => {
const result = MatrixRoomReceiverConverter.toSendRoomFileMessageDto({ room_id: event.room_id, content: event.content } as any);
expect(result.externalRoomId).to.be.equal('!roomId:matrix.org');
expect(result.normalizedRoomId).to.be.equal('roomId');
});
it('should convert the event properly', () => {
const result = MatrixRoomReceiverConverter.toSendRoomFileMessageDto(event as any);
expect(result).to.be.eql({
externalEventId: 'eventId',
externalRoomId: '!roomId:matrix.org',
normalizedRoomId: 'roomId',
externalSenderId: '@marcos.defendi:matrix.org',
normalizedSenderId: 'marcos.defendi:matrix.org',
messageBody: {
filename: event.content.body,
url: event.content.url,
mimetype: event.content.info.mimetype,
size: event.content.info.size,
messageText: event.content.body,
},
});
});
});
describe('#toRoomRedactEventDto()', () => {
const event = {
event_id: 'eventId',
redacts: '$eventId',
room_id: '!roomId:matrix.org',
sender: '@marcos.defendi:matrix.org',
};
it('should return an instance of FederationRoomRedactEventDto', () => {
expect(MatrixRoomReceiverConverter.toRoomRedactEventDto({} as any)).to.be.instanceOf(FederationRoomRedactEventDto);
});
it('should return the basic room properties correctly (normalizedRoomId without any "!" and only the part before the ":") if any', () => {
const result = MatrixRoomReceiverConverter.toRoomRedactEventDto({ room_id: event.room_id } as any);
expect(result.externalRoomId).to.be.equal('!roomId:matrix.org');
expect(result.normalizedRoomId).to.be.equal('roomId');
});
it('should convert the event properly', () => {
const result = MatrixRoomReceiverConverter.toRoomRedactEventDto(event as any);
expect(result).to.be.eql({
externalEventId: 'eventId',
externalRoomId: '!roomId:matrix.org',
normalizedRoomId: 'roomId',
redactsEvent: '$eventId',
externalSenderId: '@marcos.defendi:matrix.org',
});
});
});
});

@ -0,0 +1,55 @@
import { expect } from 'chai';
import sinon from 'sinon';
import {
MatrixEnumRelatesToRelType,
MatrixEnumSendMessageType,
} from '../../../../../../../../../app/federation-v2/server/infrastructure/matrix/definitions/events/RoomMessageSent';
import { MatrixRoomMessageSentHandler } from '../../../../../../../../../app/federation-v2/server/infrastructure/matrix/handlers/Room';
describe('Federation - Infrastructure - handlers - Room - MatrixRoomMessageSentHandler ', () => {
const normalMessageStub = sinon.stub();
const editedMessageStub = sinon.stub();
const fileMessageStub = sinon.stub();
const roomService = {
onExternalMessageReceived: normalMessageStub,
onExternalFileMessageReceived: fileMessageStub,
onExternalMessageEditedReceived: editedMessageStub,
};
const handler = new MatrixRoomMessageSentHandler(roomService as any);
describe('#handle()', () => {
const handlers: Record<string, any> = {
[MatrixEnumSendMessageType.TEXT]: normalMessageStub,
[MatrixEnumSendMessageType.AUDIO]: fileMessageStub,
[MatrixEnumSendMessageType.FILE]: fileMessageStub,
[MatrixEnumSendMessageType.IMAGE]: fileMessageStub,
[MatrixEnumSendMessageType.NOTICE]: normalMessageStub,
[MatrixEnumSendMessageType.VIDEO]: fileMessageStub,
[MatrixEnumSendMessageType.EMOTE]: normalMessageStub,
};
Object.keys(handlers).forEach((type) => {
it(`should call the correct handler for ${type}`, async () => {
await handler.handle({ content: { msgtype: type, url: 'url', info: { mimetype: 'mime', size: 12 } } } as any);
expect(handlers[type].called).to.be.true;
});
});
it('should call the default handler if no handler is found', async () => {
await handler.handle({ content: { msgtype: 'unknown', url: 'url', info: { mimetype: 'mime', size: 12 } } } as any);
expect(normalMessageStub.called).to.be.true;
});
it('should call the edit message method when it is an edition event', async () => {
await handler.handle({
content: {
'msgtype': MatrixEnumSendMessageType.TEXT,
'm.new_content': {},
'm.relates_to': { rel_type: MatrixEnumRelatesToRelType.REPLACE },
},
} as any);
expect(editedMessageStub.called).to.be.true;
});
});
});

@ -163,7 +163,7 @@ export interface IMessage extends IRocketChatRecord {
attachments?: MessageAttachment[];
reactions?: {
[key: string]: { names?: (string | undefined)[]; usernames: string[] };
[key: string]: { names?: (string | undefined)[]; usernames: string[]; federationReactionEventIds?: Record<string, string> };
};
private?: boolean;
@ -181,6 +181,9 @@ export interface IMessage extends IRocketChatRecord {
html?: string;
// Messages sent from visitors have this field
token?: string;
federation?: {
eventId: string;
};
}
export type MessageSystem = {
@ -193,6 +196,10 @@ export interface IEditedMessage extends IMessage {
}
export const isEditedMessage = (message: IMessage): message is IEditedMessage => 'editedAt' in message && 'editedBy' in message;
export const isDeletedMessage = (message: IMessage): message is IEditedMessage =>
'editedAt' in message && 'editedBy' in message && message.t === 'rm';
export const isMessageFromMatrixFederation = (message: IMessage): boolean =>
'federation' in message && Boolean(message.federation?.eventId);
export interface ITranslatedMessage extends IMessage {
translations: { [key: string]: string } & { original?: string };

@ -2,6 +2,7 @@ import type { IRocketChatRecord } from './IRocketChatRecord';
export interface IUpload extends IRocketChatRecord {
typeGroup?: string;
description?: string;
type?: string;
name: string;
aliases?: string;
@ -12,4 +13,11 @@ export interface IUpload extends IRocketChatRecord {
userId?: string;
progress?: number;
etag?: string;
size?: number;
identify?: {
size: {
width: number;
height: number;
};
};
}

@ -55,4 +55,12 @@ export interface IMessagesModel extends IBaseModel<IMessage> {
countByType(type: IMessage['t'], options: CountDocumentsOptions): Promise<number>;
findPaginatedPinnedByRoom(roomId: IMessage['rid'], options: FindOptions<IMessage>): FindPaginated<FindCursor<IMessage>>;
setFederationReactionEventId(username: string, _id: string, reaction: string, federationEventId: string): Promise<void>;
unsetFederationReactionEventId(federationEventId: string, _id: string, reaction: string): Promise<void>;
findOneByFederationIdAndUsernameOnReactions(federationEventId: string, username: string): Promise<IMessage | null>;
findOneByFederationId(federationEventId: string): Promise<IMessage | null>;
}

@ -6203,6 +6203,7 @@ __metadata:
"@types/cssom": ^0.4.1
"@types/dompurify": ^2.3.3
"@types/ejson": ^2.2.0
"@types/emojione": ^2.2.6
"@types/express": ^4.17.13
"@types/express-rate-limit": ^5.1.3
"@types/fibers": ^3.1.1
@ -9738,6 +9739,13 @@ __metadata:
languageName: node
linkType: hard
"@types/emojione@npm:^2.2.6":
version: 2.2.6
resolution: "@types/emojione@npm:2.2.6"
checksum: 5a049b57ea99eb88359b257256cdb74f048f8cfddc58a7ea9a50ba5d937f9414025c0616205b67f63512fcbbba44471901209d8f3c7fb5a9741fb6d3038eb864
languageName: node
linkType: hard
"@types/eslint-scope@npm:^3.7.0, @types/eslint-scope@npm:^3.7.3":
version: 3.7.4
resolution: "@types/eslint-scope@npm:3.7.4"
@ -24414,7 +24422,7 @@ __metadata:
optional: true
bin:
lessc: ./bin/lessc
checksum: 61568b56b5289fdcfe3d51baf3c13e7db7140022c0a37ef0ae343169f0de927a4b4f4272bc10c20101796e8ee79e934e024051321bba93b3ae071f734309bd98
checksum: c9b8c0e865427112c48a9cac36f14964e130577743c29d56a6d93b5812b70846b04ccaa364acf1e8d75cee3855215ec0a2d8d9de569c80e774f10b6245f39b7d
languageName: node
linkType: hard

Loading…
Cancel
Save