Bridge room deletions to Matrix
[gitter.git] / modules / matrix-bridge / lib / gitter-bridge.js
blobcdf66a6ada4b988b3fd81b02e2f60e6d8e55a8dd
1 'use strict';
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');
25 class GitterBridge {
26   constructor(
27     matrixBridge,
28     // The backing user we are sending messages with on the Gitter side
29     gitterBridgeBackingUsername = config.get('matrix:bridge:gitterBridgeBackingUsername')
30   ) {
31     assert(matrixBridge);
32     this.matrixBridge = matrixBridge;
33     this.matrixUtils = new MatrixUtils(matrixBridge);
34     this._gitterBridgeBackingUsername = gitterBridgeBackingUsername;
36     appEvents.onDataChange2(data => {
37       this.onDataChange(data);
38       return null;
39     });
40   }
42   // eslint-disable-next-line complexity, max-statements
43   async onDataChange(data) {
44     try {
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(
50           400,
51           'Gitter data from onDataChange2(data) did not include URL or model'
52         );
53       }
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);
63         }
64       }
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);
72         }
73       }
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);
81         }
82       }
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);
88         }
89       }
91       // TODO: Handle user data change and update Matrix user
93       stats.eventHF('gitter_bridge.event.success');
94     } catch (err) {
95       logger.error(
96         `Error while processing Gitter bridge event (url=${data && data.url}, id=${data &&
97           data.model &&
98           data.model.id}): ${err}`,
99         {
100           exception: err,
101           data
102         }
103       );
104       stats.eventHF('gitter_bridge.event.fail');
105       errorReporter(
106         err,
107         { operation: 'gitterBridge.onDataChange', data: data },
108         { module: 'gitter-to-matrix-bridge' }
109       );
110     }
112     return null;
113   }
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) {
118     let otherPersonMxid;
119     let gitterRoom;
120     try {
121       gitterRoom = await troupeService.findById(gitterRoomId);
122       assert(gitterRoom);
124       // We only need to invite people if this is a Matrix DM
125       const matrixDm = discoverMatrixDmUri(gitterRoom.lcUri);
126       if (!matrixDm) {
127         return null;
128       }
129       const gitterUserId = matrixDm.gitterUserId;
130       otherPersonMxid = matrixDm.virtualUserId;
132       const gitterUserMxid = await this.matrixUtils.getOrCreateMatrixUserByGitterUserId(
133         gitterUserId
134       );
135       const intent = this.matrixBridge.getIntent(gitterUserMxid);
137       // Only invite back if they have left
138       const memberContent = await intent.getStateEvent(
139         matrixRoomId,
140         'm.room.member',
141         otherPersonMxid,
142         // returnNull=true
143         true
144       );
145       if (!memberContent || (memberContent && memberContent.membership === 'leave')) {
146         // Invite the Matrix user to the Matrix DM room
147         await intent.invite(matrixRoomId, otherPersonMxid);
148       }
150       return true;
151     } catch (err) {
152       // Let's allow this to fail as sending the message regardless
153       // of the person being there is still important
154       logger.warn(
155         `Unable to invite Matrix user (${otherPersonMxid}) back to Matrix DM room matrixRoomId=${matrixRoomId} gitterRoomId=${gitterRoomId}`,
156         { exception: err }
157       );
158       errorReporter(
159         err,
160         {
161           operation: 'gitterBridge.inviteMatrixUserToDmRoomIfNeeded',
162           data: {
163             gitterRoomId,
164             matrixRoomId,
165             otherPersonMxid
166           }
167         },
168         { module: 'gitter-to-matrix-bridge' }
169       );
171       if (gitterRoom) {
172         logger.info(
173           `Sending notice to gitterRoomId=${gitterRoomId} that we were unable to invite the Matrix user(${otherPersonMxid}) back to the DM room`
174         );
176         const gitterBridgeUser = await userService.findByUsername(
177           this._gitterBridgeBackingUsername
178         );
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.`
181         });
182       }
183     }
185     return false;
186   }
188   // eslint-disable-next-line max-statements
189   async handleChatMessageCreateEvent(gitterRoomId, model) {
190     const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
191     if (!allowedToBridge) {
192       return null;
193     }
195     // Supress any echo that comes from Matrix bridge itself creating new messages
196     if (model.virtualUser && model.virtualUser.type === 'matrix') {
197       return null;
198     }
200     const matrixRoomId = await this.matrixUtils.getOrCreateMatrixRoomByGitterRoomId(gitterRoomId);
202     if (!model.fromUser) {
203       throw new StatusError(400, 'message.fromUser does not exist');
204     }
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.
208     if (
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
212     ) {
213       await this.inviteMatrixUserToDmRoomIfNeeded(gitterRoomId, matrixRoomId);
214     }
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(
222         gitterRoomId,
223         model.parentId,
224         {
225           beforeId: model.id,
226           limit: 1
227         }
228       );
230       let lastMessageId = model.parentId;
231       if (lastMessagesInThread.length) {
232         lastMessageId = lastMessagesInThread[0].id;
233       }
235       parentMatrixEventId = await store.getMatrixEventIdByGitterMessageId(lastMessageId);
236     }
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);
241     logger.info(
242       `Sending message to Matrix room (Gitter gitterRoomId=${gitterRoomId} -> Matrix gitterRoomId=${matrixRoomId}) (via user mxid=${matrixId})`
243     );
244     stats.event('gitter_bridge.chat_create', {
245       gitterRoomId,
246       gitterChatId: model.id,
247       matrixRoomId,
248       mxid: matrixId
249     });
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
256     if (model.status) {
257       msgtype = 'm.emote';
258     }
260     const matrixContent = {
261       body: matrixCompatibleText,
262       format: 'org.matrix.custom.html',
263       formatted_body: matrixCompatibleHtml,
264       msgtype
265     };
267     // Handle threaded conversations
268     if (parentMatrixEventId) {
269       matrixContent['m.relates_to'] = {
270         'm.in_reply_to': {
271           event_id: parentMatrixEventId
272         }
273       };
274     }
276     const { event_id } = await intent.sendMessage(matrixRoomId, matrixContent);
278     // Store the message so we can reference it in edits and threads/replies
279     logger.info(
280       `Storing bridged message (Gitter message id=${model.id} -> Matrix matrixRoomId=${matrixRoomId} event_id=${event_id})`
281     );
282     await store.storeBridgedMessage(model, matrixRoomId, event_id);
284     return null;
285   }
287   async handleChatMessageEditEvent(gitterRoomId, model) {
288     const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
289     if (!allowedToBridge) {
290       return null;
291     }
293     // Supress any echo that comes from Matrix bridge itself creating new messages
294     if (model.virtualUser && model.virtualUser.type === 'matrix') {
295       return null;
296     }
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) {
302       debug(
303         `Ignoring message edit from Gitter side(id=${model.id}) because there is no associated Matrix event ID`
304       );
305       stats.event('matrix_bridge.ignored_gitter_message_edit', {
306         gitterMessageId: model.id
307       });
308       return null;
309     }
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
314     //
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)) {
319       return null;
320     }
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', {
327       gitterRoomId,
328       gitterChatId: model.id,
329       matrixRoomId,
330       mxid: matrixId
331     });
333     const matrixContent = {
334       body: `* ${model.text}`,
335       format: 'org.matrix.custom.html',
336       formatted_body: `* ${model.html}`,
337       msgtype: 'm.text',
338       'm.new_content': {
339         body: model.text,
340         format: 'org.matrix.custom.html',
341         formatted_body: model.html,
342         msgtype: 'm.text'
343       },
344       'm.relates_to': {
345         event_id: bridgedMessageEntry.matrixEventId,
346         rel_type: 'm.replace'
347       }
348     };
349     await intent.sendMessage(matrixRoomId, matrixContent);
351     // Update the timestamps to compare again next time
352     await store.storeUpdatedBridgedGitterMessage(model);
354     return null;
355   }
357   async handleChatMessageRemoveEvent(gitterRoomId, model) {
358     const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
359     if (!allowedToBridge) {
360       return null;
361     }
363     // Supress any echo that comes from Matrix bridge itself creating new messages
364     if (model.virtualUser && model.virtualUser.type === 'matrix') {
365       return null;
366     }
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) {
372       debug(
373         `Ignoring message removal for id=${model.id} from Gitter because there is no associated Matrix event ID`
374       );
375       stats.event('matrix_bridge.ignored_gitter_message_remove', {
376         gitterMessageId: model.id
377       });
378       return null;
379     }
381     const matrixRoomId = await this.matrixUtils.getOrCreateMatrixRoomByGitterRoomId(gitterRoomId);
382     stats.event('gitter_bridge.chat_delete', {
383       gitterRoomId,
384       gitterChatId: model.id,
385       matrixRoomId
386     });
388     const intent = this.matrixBridge.getIntent();
389     let senderIntent;
390     try {
391       const event = await intent.getEvent(matrixRoomId, matrixEventId);
392       senderIntent = this.matrixBridge.getIntent(event.sender);
393     } catch (err) {
394       logger.info(
395         `handleChatMessageRemoveEvent(): Using bridging user intent because Matrix API call failed, intent.getEvent(${matrixRoomId}, ${matrixEventId})`
396       );
397       // We'll just use the bridge intent if we can't use their own user
398       senderIntent = intent;
399     }
401     await senderIntent.matrixClient.redactEvent(matrixRoomId, matrixEventId);
403     return null;
404   }
406   async handleRoomUpdateEvent(gitterRoomId /*, model*/) {
407     const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
408     if (!allowedToBridge) {
409       return null;
410     }
412     const matrixRoomId = await this.matrixUtils.getOrCreateMatrixRoomByGitterRoomId(gitterRoomId);
414     await this.matrixUtils.ensureCorrectRoomState(matrixRoomId, gitterRoomId);
415   }
417   async handleRoomRemoveEvent(gitterRoomId /*, model*/) {
418     const matrixRoomId = await this.matrixUtils.getOrCreateMatrixRoomByGitterRoomId(gitterRoomId);
420     if (matrixRoomId) {
421       await this.matrixUtils.shutdownMatrixRoom(matrixRoomId);
422     }
423   }
425   async handleUserJoiningRoom(gitterRoomId, model) {
426     const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
427     if (!allowedToBridge) {
428       return null;
429     }
431     const matrixRoomId = await store.getMatrixRoomIdByGitterRoomId(gitterRoomId);
432     // Just ignore the bridging join if the Matrix room hasn't been created yet
433     if (!matrixRoomId) {
434       return null;
435     }
437     const gitterUserId = model.id;
438     assert(gitterUserId);
439     const matrixId = await this.matrixUtils.getOrCreateMatrixUserByGitterUserId(gitterUserId);
440     assert(matrixId);
442     try {
443       const intent = this.matrixBridge.getIntent(matrixId);
444       await intent.join(matrixRoomId);
445     } catch (err) {
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);
449         assert(gitterRoom);
451         const matrixDm = discoverMatrixDmUri(gitterRoom.lcUri);
452         if (!matrixDm) {
453           // If it's not a DM, just throw the error that happened
454           // because we can't recover from that problem.
455           throw err;
456         }
458         logger.warn(
459           `Failed to join Gitter user to Matrix DM room. Creating a new one! gitterUserId=${gitterUserId} gitterRoomId=${gitterRoomId} oldMatrixRoomId=${matrixRoomId}`
460         );
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);
466         assert(gitterUser);
468         let newMatrixRoomId = await this.matrixUtils.createMatrixDmRoomByGitterUserAndOtherPersonMxid(
469           gitterUser,
470           matrixDm.virtualUserId
471         );
473         logger.info(
474           `Storing new bridged DM room (Gitter room id=${gitterRoom._id} -> Matrix room_id=${newMatrixRoomId}): ${gitterRoom.lcUri}`
475         );
476         await store.storeBridgedRoom(gitterRoom._id, newMatrixRoomId);
477       } else {
478         throw err;
479       }
480     }
481   }
483   async handleUserLeavingRoom(gitterRoomId, model) {
484     const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
485     if (!allowedToBridge) {
486       return null;
487     }
489     const matrixRoomId = await store.getMatrixRoomIdByGitterRoomId(gitterRoomId);
490     // Just ignore the bridging leave if the Matrix room hasn't been created yet
491     if (!matrixRoomId) {
492       return null;
493     }
495     const gitterUserId = model.id;
496     assert(gitterUserId);
497     const matrixId = await this.matrixUtils.getOrCreateMatrixUserByGitterUserId(gitterUserId);
498     assert(matrixId);
500     const intent = this.matrixBridge.getIntent(matrixId);
501     await intent.leave(matrixRoomId);
502   }
504   async handleRoomBanEvent(gitterRoomId, model, operation) {
505     const allowedToBridge = await isGitterRoomIdAllowedToBridge(gitterRoomId);
506     if (!allowedToBridge) {
507       return null;
508     }
510     const matrixRoomId = await store.getMatrixRoomIdByGitterRoomId(gitterRoomId);
511     if (!matrixRoomId) {
512       return null;
513     }
515     stats.event('gitter_bridge.user_ban', {
516       gitterRoomId,
517       matrixRoomId,
518       operation,
519       userId: model.userId,
520       virtualUser: model.virtualUser
521     });
523     let bannedMxid;
524     if (model.userId) {
525       const gitterUser = await userService.findById(model.userId);
526       assert(gitterUser);
528       bannedMxid = getMxidForGitterUser(gitterUser);
529     } else if (model.virtualUser) {
530       bannedMxid = `@${model.virtualUser.externalId}`;
531     }
532     assert(bannedMxid);
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);
541     }
542   }
545 module.exports = GitterBridge;