3 const debug = require('debug')('gitter:app:matrix-bridge:gitter-bridge');
4 const assert = require('assert');
5 const StatusError = require('statuserror');
6 const mongoUtils = require('gitter-web-persistence-utils/lib/mongo-utils');
7 const appEvents = require('gitter-web-appevents');
8 const userService = require('gitter-web-users');
9 const chatService = require('gitter-web-chats');
10 const troupeService = require('gitter-web-rooms/lib/troupe-service');
11 const env = require('gitter-web-env');
12 const logger = env.logger;
13 const stats = env.stats;
14 const config = env.config;
15 const errorReporter = env.errorReporter;
17 const store = require('./store');
18 const MatrixUtils = require('./matrix-utils');
19 const transformGitterTextIntoMatrixMessage = require('./transform-gitter-text-into-matrix-message');
20 const checkIfDatesSame = require('./check-if-dates-same');
21 const isGitterRoomIdAllowedToBridge = require('./is-gitter-room-id-allowed-to-bridge');
22 const discoverMatrixDmUri = require('./discover-matrix-dm-uri');
23 const getMxidForGitterUser = require('./get-mxid-for-gitter-user');
28 // The backing user we are sending messages with on the Gitter side
29 gitterBridgeBackingUsername = config.get('matrix:bridge:gitterBridgeBackingUsername')
32 this.matrixBridge = matrixBridge;
33 this.matrixUtils = new MatrixUtils(matrixBridge);
34 this._gitterBridgeBackingUsername = gitterBridgeBackingUsername;
36 appEvents.onDataChange2(data => {
37 this.onDataChange(data);
42 // eslint-disable-next-line complexity, max-statements
43 async onDataChange(data) {
45 debug('onDataChange', data);
46 stats.eventHF('gitter_bridge.event_received');
47 // Ignore data without a URL or model
48 if (!data.url || !data.model) {
49 throw new StatusError(
51 'Gitter data from onDataChange2(data) did not include URL or model'
55 if (data.type === 'chatMessage') {
56 const [, gitterRoomId] = data.url.match(/\/rooms\/([a-f0-9]+)\/chatMessages/) || [];
57 if (gitterRoomId && data.operation === 'create') {
58 await this.handleChatMessageCreateEvent(gitterRoomId, data.model);
59 } else if (gitterRoomId && data.operation === 'update') {
60 await this.handleChatMessageEditEvent(gitterRoomId, data.model);
61 } else if (gitterRoomId && data.operation === 'remove') {
62 await this.handleChatMessageRemoveEvent(gitterRoomId, data.model);
66 if (data.type === 'room') {
67 const [, gitterRoomId] = data.url.match(/\/rooms\/([a-f0-9]+)/) || [];
68 if (gitterRoomId && (data.operation === 'patch' || data.operation === 'update')) {
69 await this.handleRoomUpdateEvent(gitterRoomId, data.model);
70 } else if (gitterRoomId && data.operation === 'remove') {
71 await this.handleRoomRemoveEvent(gitterRoomId, data.model);
75 if (data.type === 'user') {
76 const [, gitterRoomId] = data.url.match(/\/rooms\/([a-f0-9]+)\/users/) || [];
77 if (gitterRoomId && data.operation === 'create') {
78 await this.handleUserJoiningRoom(gitterRoomId, data.model);
79 } else if (gitterRoomId && data.operation === 'remove') {
80 await this.handleUserLeavingRoom(gitterRoomId, data.model);
84 if (data.type === 'ban') {
85 const [, gitterRoomId] = data.url.match(/\/rooms\/([a-f0-9]+)\/bans/) || [];
86 if ((gitterRoomId && data.operation === 'create') || data.operation === 'remove') {
87 await this.handleRoomBanEvent(gitterRoomId, data.model, data.operation);
91 // TODO: Handle user data change and update Matrix user
93 stats.eventHF('gitter_bridge.event.success');
96 `Error while processing Gitter bridge event (url=${data && data.url}, id=${data &&
98 data.model.id}): ${err}`,
104 stats.eventHF('gitter_bridge.event.fail');
107 { operation: 'gitterBridge.onDataChange', data: data },
108 { module: 'gitter-to-matrix-bridge' }
115 // Helper to invite the Matrix user back to the DM room when a new message comes in.
116 // Returns true if the user was invited, false if failed to invite, null if no inviting needed
117 async inviteMatrixUserToDmRoomIfNeeded(gitterRoomId, matrixRoomId) {
121 gitterRoom = await troupeService.findById(gitterRoomId);
124 // We only need to invite people if this is a Matrix DM
125 const matrixDm = discoverMatrixDmUri(gitterRoom.lcUri);
129 const gitterUserId = matrixDm.gitterUserId;
130 otherPersonMxid = matrixDm.virtualUserId;
132 const gitterUserMxid = await this.matrixUtils.getOrCreateMatrixUserByGitterUserId(
135 const intent = this.matrixBridge.getIntent(gitterUserMxid);
137 // Only invite back if they have left
138 const memberContent = await intent.getStateEvent(
145 if (!memberContent || (memberContent && memberContent.membership === 'leave')) {
146 // Invite the Matrix user to the Matrix DM room
147 await intent.invite(matrixRoomId, otherPersonMxid);
152 // Let's allow this to fail as sending the message regardless
153 // of the person being there is still important
155 `Unable to invite Matrix user (${otherPersonMxid}) back to Matrix DM room matrixRoomId=${matrixRoomId} gitterRoomId=${gitterRoomId}`,
161 operation: 'gitterBridge.inviteMatrixUserToDmRoomIfNeeded',
168 { module: 'gitter-to-matrix-bridge' }
173 `Sending notice to gitterRoomId=${gitterRoomId} that we were unable to invite the Matrix user(${otherPersonMxid}) back to the DM room`
176 const gitterBridgeUser = await userService.findByUsername(
177 this._gitterBridgeBackingUsername
179 await chatService.newChatMessageToTroupe(gitterRoom, gitterBridgeUser, {
180 text: `Unable to invite Matrix user back to DM room. They probably won't know about the message you just sent.`
188 // eslint-disable-next-line max-statements
189 async handleChatMessageCreateEvent(gitterRoomId, model) {
190 const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
191 if (!allowedToBridge) {
195 // Supress any echo that comes from Matrix bridge itself creating new messages
196 if (model.virtualUser && model.virtualUser.type === 'matrix') {
200 const matrixRoomId = await this.matrixUtils.getOrCreateMatrixRoomByGitterRoomId(gitterRoomId);
202 if (!model.fromUser) {
203 throw new StatusError(400, 'message.fromUser does not exist');
206 // The Matrix user may have left the Matrix DM room.
207 // So let's invite them back to the room so they can see the new message for them.
209 // Suppress any loop that can come from the bridge sending its own messages
210 // in the room from a result of this action.
211 model.fromUser.username !== this._gitterBridgeBackingUsername
213 await this.inviteMatrixUserToDmRoomIfNeeded(gitterRoomId, matrixRoomId);
216 // Handle threaded conversations
217 let parentMatrixEventId;
218 if (model.parentId) {
219 // Try to reference the last message in thread
220 // Otherwise, will just reference the thread parent
221 const lastMessagesInThread = await chatService.findThreadChatMessages(
230 let lastMessageId = model.parentId;
231 if (lastMessagesInThread.length) {
232 lastMessageId = lastMessagesInThread[0].id;
235 parentMatrixEventId = await store.getMatrixEventIdByGitterMessageId(lastMessageId);
238 // Send the message to the Matrix room
239 const matrixId = await this.matrixUtils.getOrCreateMatrixUserByGitterUserId(model.fromUser.id);
240 const intent = this.matrixBridge.getIntent(matrixId);
242 `Sending message to Matrix room (Gitter gitterRoomId=${gitterRoomId} -> Matrix gitterRoomId=${matrixRoomId}) (via user mxid=${matrixId})`
244 stats.event('gitter_bridge.chat_create', {
246 gitterChatId: model.id,
251 const matrixCompatibleText = transformGitterTextIntoMatrixMessage(model.text, model);
252 const matrixCompatibleHtml = transformGitterTextIntoMatrixMessage(model.html, model);
254 let msgtype = 'm.text';
255 // Check whether it's a `/me` status message
260 const matrixContent = {
261 body: matrixCompatibleText,
262 format: 'org.matrix.custom.html',
263 formatted_body: matrixCompatibleHtml,
267 // Handle threaded conversations
268 if (parentMatrixEventId) {
269 matrixContent['m.relates_to'] = {
271 event_id: parentMatrixEventId
276 const { event_id } = await intent.sendMessage(matrixRoomId, matrixContent);
278 // Store the message so we can reference it in edits and threads/replies
280 `Storing bridged message (Gitter message id=${model.id} -> Matrix matrixRoomId=${matrixRoomId} event_id=${event_id})`
282 await store.storeBridgedMessage(model, matrixRoomId, event_id);
287 async handleChatMessageEditEvent(gitterRoomId, model) {
288 const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
289 if (!allowedToBridge) {
293 // Supress any echo that comes from Matrix bridge itself creating new messages
294 if (model.virtualUser && model.virtualUser.type === 'matrix') {
298 const bridgedMessageEntry = await store.getBridgedMessageEntryByGitterMessageId(model.id);
300 // No matching message on the Matrix side. Let's just ignore the edit as this is some edge case.
301 if (!bridgedMessageEntry || !bridgedMessageEntry.matrixEventId) {
303 `Ignoring message edit from Gitter side(id=${model.id}) because there is no associated Matrix event ID`
305 stats.event('matrix_bridge.ignored_gitter_message_edit', {
306 gitterMessageId: model.id
311 // Check if the message was actually updated.
312 // If there was an `update` data2 event and there was no timestamp change here,
313 // it is probably just an update to `threadMessageCount`, etc which we don't need to propogate
315 // We use this special date comparison function because:
316 // - `bridgedMessageEntry.editedAt` from the database is a `Date` object{} or `null`
317 // - `model.editedAt` from the event is a `string` or `undefined`
318 if (checkIfDatesSame(bridgedMessageEntry.editedAt, model.editedAt)) {
322 const matrixRoomId = await this.matrixUtils.getOrCreateMatrixRoomByGitterRoomId(gitterRoomId);
324 const matrixId = await this.matrixUtils.getOrCreateMatrixUserByGitterUserId(model.fromUser.id);
325 const intent = this.matrixBridge.getIntent(matrixId);
326 stats.event('gitter_bridge.chat_edit', {
328 gitterChatId: model.id,
333 const matrixContent = {
334 body: `* ${model.text}`,
335 format: 'org.matrix.custom.html',
336 formatted_body: `* ${model.html}`,
340 format: 'org.matrix.custom.html',
341 formatted_body: model.html,
345 event_id: bridgedMessageEntry.matrixEventId,
346 rel_type: 'm.replace'
349 await intent.sendMessage(matrixRoomId, matrixContent);
351 // Update the timestamps to compare again next time
352 await store.storeUpdatedBridgedGitterMessage(model);
357 async handleChatMessageRemoveEvent(gitterRoomId, model) {
358 const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
359 if (!allowedToBridge) {
363 // Supress any echo that comes from Matrix bridge itself creating new messages
364 if (model.virtualUser && model.virtualUser.type === 'matrix') {
368 const matrixEventId = await store.getMatrixEventIdByGitterMessageId(model.id);
370 // No matching message on the Matrix side. Let's just ignore the remove as this is some edge case.
371 if (!matrixEventId) {
373 `Ignoring message removal for id=${model.id} from Gitter because there is no associated Matrix event ID`
375 stats.event('matrix_bridge.ignored_gitter_message_remove', {
376 gitterMessageId: model.id
381 const matrixRoomId = await this.matrixUtils.getOrCreateMatrixRoomByGitterRoomId(gitterRoomId);
382 stats.event('gitter_bridge.chat_delete', {
384 gitterChatId: model.id,
388 const intent = this.matrixBridge.getIntent();
391 const event = await intent.getEvent(matrixRoomId, matrixEventId);
392 senderIntent = this.matrixBridge.getIntent(event.sender);
395 `handleChatMessageRemoveEvent(): Using bridging user intent because Matrix API call failed, intent.getEvent(${matrixRoomId}, ${matrixEventId})`
397 // We'll just use the bridge intent if we can't use their own user
398 senderIntent = intent;
401 await senderIntent.matrixClient.redactEvent(matrixRoomId, matrixEventId);
406 async handleRoomUpdateEvent(gitterRoomId /*, model*/) {
407 const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
408 if (!allowedToBridge) {
412 const matrixRoomId = await this.matrixUtils.getOrCreateMatrixRoomByGitterRoomId(gitterRoomId);
414 await this.matrixUtils.ensureCorrectRoomState(matrixRoomId, gitterRoomId);
417 async handleRoomRemoveEvent(gitterRoomId /*, model*/) {
418 const matrixRoomId = await this.matrixUtils.getOrCreateMatrixRoomByGitterRoomId(gitterRoomId);
421 await this.matrixUtils.shutdownMatrixRoom(matrixRoomId);
425 async handleUserJoiningRoom(gitterRoomId, model) {
426 const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
427 if (!allowedToBridge) {
431 const matrixRoomId = await store.getMatrixRoomIdByGitterRoomId(gitterRoomId);
432 // Just ignore the bridging join if the Matrix room hasn't been created yet
437 const gitterUserId = model.id;
438 assert(gitterUserId);
439 const matrixId = await this.matrixUtils.getOrCreateMatrixUserByGitterUserId(gitterUserId);
443 const intent = this.matrixBridge.getIntent(matrixId);
444 await intent.join(matrixRoomId);
446 // Create a new DM room on Matrix if the user is no longer able to join the old again
447 if (err.message === 'Failed to join room') {
448 const gitterRoom = await troupeService.findById(gitterRoomId);
451 const matrixDm = discoverMatrixDmUri(gitterRoom.lcUri);
453 // If it's not a DM, just throw the error that happened
454 // because we can't recover from that problem.
459 `Failed to join Gitter user to Matrix DM room. Creating a new one! gitterUserId=${gitterUserId} gitterRoomId=${gitterRoomId} oldMatrixRoomId=${matrixRoomId}`
462 // Sanity check the user that joined the Gitter DM room is the same one from the URL
463 assert(mongoUtils.objectIDsEqual(matrixDm.gitterUserId, gitterUserId));
465 const gitterUser = await userService.findById(gitterUserId);
468 let newMatrixRoomId = await this.matrixUtils.createMatrixDmRoomByGitterUserAndOtherPersonMxid(
470 matrixDm.virtualUserId
474 `Storing new bridged DM room (Gitter room id=${gitterRoom._id} -> Matrix room_id=${newMatrixRoomId}): ${gitterRoom.lcUri}`
476 await store.storeBridgedRoom(gitterRoom._id, newMatrixRoomId);
483 async handleUserLeavingRoom(gitterRoomId, model) {
484 const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
485 if (!allowedToBridge) {
489 const matrixRoomId = await store.getMatrixRoomIdByGitterRoomId(gitterRoomId);
490 // Just ignore the bridging leave if the Matrix room hasn't been created yet
495 const gitterUserId = model.id;
496 assert(gitterUserId);
497 const matrixId = await this.matrixUtils.getOrCreateMatrixUserByGitterUserId(gitterUserId);
500 const intent = this.matrixBridge.getIntent(matrixId);
501 await intent.leave(matrixRoomId);
504 async handleRoomBanEvent(gitterRoomId, model, operation) {
505 const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
506 if (!allowedToBridge) {
510 const matrixRoomId = await store.getMatrixRoomIdByGitterRoomId(gitterRoomId);
515 stats.event('gitter_bridge.user_ban', {
519 userId: model.userId,
520 virtualUser: model.virtualUser
525 const gitterUser = await userService.findById(model.userId);
528 bannedMxid = getMxidForGitterUser(gitterUser);
529 } else if (model.virtualUser) {
530 bannedMxid = `@${model.virtualUser.externalId}`;
534 const bridgeIntent = this.matrixBridge.getIntent();
535 if (operation === 'create') {
536 logger.info(`Banning ${bannedMxid} from ${matrixRoomId}`);
537 await bridgeIntent.ban(matrixRoomId, bannedMxid, 'Banned on Gitter');
538 } else if (operation === 'remove') {
539 logger.info(`Unbanning ${bannedMxid} from ${matrixRoomId}`);
540 await bridgeIntent.unban(matrixRoomId, bannedMxid);
545 module.exports = GitterBridge;