├── .gitignore ├── README.md ├── config.sample.json ├── index.js ├── package.json └── src ├── bang-command.js ├── base.js ├── debug.js ├── puppet.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # matrix-puppet-bridge [![#matrix-puppet-bridge:matrix.org](https://img.shields.io/matrix/matrix-puppet-bridge:matrix.org.svg?label=%23matrix-puppet-bridge%3Amatrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#matrix-puppet-bridge:matrix.org) 2 | 3 | **Before you begin, consider the landscape** 4 | 5 | This project was the first in a series of innovative projects that seek to bridge additional, more challenging networks into matrix than was possible at the time. The original project is often not the best choice. This project remains here indefinitely because many of us rely on it and continue to maintain it here and there, but if you are new, please make yourself aware of the others and choose where to invest time learning. If you like Golang you may want to look at Tulir's concepts. If you like TypeScript, check out Soru's concepts. Here are some links to get you started: 6 | - https://github.com/Sorunome/mx-puppet-bridge 7 | - https://github.com/tulir/mautrix-imessage 8 | 9 | Please take the time to learn about these projects before continuing. 10 | 11 | **Now back to our regularly scheduled programming** 12 | 13 | This project provides a base class for a style of Matrix bridge which primarily acts as or "puppets" a specific user (usually yourself) on your homeserver and on a third party service. 14 | 15 | There is a common pattern in this style of bridge, such as duplicate message issues, which are dealt with by this module. 16 | 17 | ## Examples 18 | 19 | These bridges have been built using matrix-puppet-bridge: 20 | 21 | * https://github.com/matrix-hacks/matrix-puppet-imessage 22 | * https://github.com/matrix-hacks/matrix-puppet-groupme 23 | * https://github.com/matrix-hacks/matrix-puppet-facebook 24 | * https://github.com/matrix-hacks/matrix-puppet-slack 25 | * https://github.com/matrix-hacks/matrix-puppet-hangouts 26 | * https://github.com/witchent/matrix-puppet-signal 27 | * https://github.com/matrix-hacks/matrix-puppet-skype 28 | * https://github.com/matrix-hacks/matrix-puppet-mattermost 29 | 30 | ## FAQ 31 | 32 | ### Q: I can receive messages, I can also see messages sent by the third party client in Matrix but I cannot send messages from Matrix. Nothing appears to show up in the logs when I send a message in Matrix and nothing is actually sent. What's going on? 33 | 34 | This is symptomatic of a homeserver that is unable to reach the bridge. If you curl the bridge URL in the yaml file that you have referenced in synapse's homeserver.yaml, does it 200 OK ? Most likely this is misconfigured. If you still have this issue ask us in [#matrix-puppet-bridge:matrix.org](https://riot.im/app/#/room/#matrix-puppet-bridge:matrix.org). 35 | 36 | Some hints: 37 | 38 | * We use node's `server.listen` function to create the server. We do not pass in a hostnae, only the port that you configure. See the default behavior explained here with respect to IPv4 and IPv6: https://nodejs.org/dist/latest-v7.x/docs/api/http.html#http_server_listen_port_hostname_backlog_callback 39 | * Use netstat and curl to see if the homeserver can truly access the bridge. It's probably the most common problem users face. 40 | 41 | ### Q: My access token has changed. How can I quickly update my access token on the bridge? 42 | 43 | Run this in your bridge directory: 44 | 45 | `node -e "new (require('matrix-puppet-bridge').Puppet)('config.json').associate()"` 46 | 47 | ### Q: Is this made to handle several facebook/hangouts/slack users within one bridge? In other words, can I use this for "mass hosting" of many imessage/facebook/hangouts identities with one matrix homeserver? 48 | 49 | No, unfortunately. This is not designed for mass hosting of bridges. 50 | 51 | 1. **Setup Challenges** Several of the protocols we support do not lend themselves well to mass hosting. For example, the iMessage bridge must run on osx, and authentication must be handled by using the login gui of the iMessages app, and there's not a clean way of running multiple iMessages apps and automating them. Beyond that, a couple of other protocols do not support a proper oauth workflow (see facebook which definitely does not unless it's for a 'bot user', and to some extent hangouts doesn't support it either [though unsupported techniques do exist]). 52 | 53 | 2. **Password Leaks** Any kind of "mass hosting" setup that allowed for configuration of these bridges via a "/nickserv" type interface would require sending your facebook/hangouts password to a "man in the middle" (homeserver in this case). This is just not acceptable to the authors of this framework, so you will probably never see it implemented by us. 54 | 55 | 3. **Conversation Leaks** An effort to build a mass-hosted version of this would entail putting not just passwords, but also personal content and conversations on public homeservers (e.g. personal conversations over facebook, or iMessage) that you do not control, therefore we think it is better to go towards a model in which you run your own homeserver, as a prerequisite. That said, sometimes you have a more technical friend and trust them with this, but in that case we believe it's better for that friend to show you how to run your own HS if you want to use these bridges, rather than compromise on the privacy issue 56 | 57 | In summary, this bridge framework is explicitly for bridges that are "personal" in nature. It assumes the user cares a great deal about the privacy of their facebook/hangouts/imessage/etc passwords and messages, and as such desire to run their own homeserver and all their own bridges. 58 | 59 | That said, we are open to proposals in which we can solve 2 and 3, which would allow homeserver sharing. Such a proposal would necessarily span across the matrix ecosystem, so you may want to reference https://matrix.org/docs/spec/ if you haven't already. 60 | 61 | ### Q: What about service X? 62 | 63 | Right now I recommend you look at the examples. Right now the most complex example in terms of creating a client is imessage. The most complex example in terms of needing to make additional calls like looking up user info, check out the facebook one. For a basic middle-ground, check the groupme one. For a really simple one, check the mattermost one. 64 | 65 | ### Q: What's puppetting and why does this use it? 66 | 67 | There are two kinds of puppetting happening here: 68 | #### 3rd party user puppetting 69 | The bridge is logging into hangouts/facebook/slack/etc in such a way that it appears to send messages as you. From the perspective of other hangouts/facebook/slack users, all messages appear to come from your actual user, not from a bot or anything along those lines. 70 | #### Matrix user puppetting 71 | The bridge is logging into your matrix homeserver with your matrix username, and sometimes sends messages *as* your username. Why is this necessary? If you happen to send a message using a native hangouts/facebook/slack/etc client rather than using the bridge, we want to propogate the message you sent over to matrix somehow; This way you can see the entire context of your conversation within matrix, even if some of your messages were sent using a native hangouts/facebook/etc client. This means it has to appear to come from someone that represents you somehow. So why not create a ghost/bot user on the matrix side that represents your "facebook/hangouts self" for this purpose (e.g. "@YourName\_facebook:your-hs.example.org")? This approach has the following limitations: 72 | * If the bridge is not able to log in as you, it is not empowered with the ability to automatically join you to rooms. This means you must still manually accept invites for your matrix user to any newly created bridge rooms. This is especially bad if a brand new new contact messages you on facebook/hangouts/etc -- the bridge creates a new room and you may get a push notification with the room invite but no notification containing their actual message text. You would have to open the matrix client and join the room to see their message. This is an extra manual step and is not convenient compared to what we've become accustomed to with messaging apps. 73 | * If sending from the native hangouts/facebook/etc app, and this gets shown in matrix by a virtual secondary user/bot that represents yourself rather than your *real* self, in matrix's eyes, this is considered a new unread message in the room, so the room state is changed to "unread" -- generally a bold room name in the matrix client. This is not really desirable; Given I'm the one that sent the new message, I've of course already read the message. Ultimately this gets pretty annoying, especially if you tend (like myself) to use these bold unread room states indicators to help quickly catch up on older missed messages. 74 | 75 | For these reasons (and some other minor reasons I won't mention here), we settled on puppetting the matrix user. 76 | 77 | ### Q: How can I prevent long push notification messages for 1 on 1 conversations? 78 | 79 | At this time we recommend modifying sygnal. For example, see this commit: 80 | https://github.com/AndrewJDR/sygnal/commit/3813ef48a1be1b6015953974a13ee4da2b704882 81 | 82 | The prefix seen by sygnal is that which you configure on your class: 83 | 84 | ```javascript 85 | class App extends MatrixPuppetBridgeBase { 86 | getServicePrefix() { 87 | return "__mbp__someservice"; 88 | } 89 | } 90 | ``` 91 | 92 | In the examples above, "__mpb__" was used as the special tag, but it can be anything you want. Keep in mind that getServicePrefix is called for creating rooms and ghost users, and also needs to match your appserver yaml file, so plan for this and expect this to be a source of problems when changing it after having run the bridge for awhile under a different service prefix. 93 | 94 | ### Q: How can I add bang (!) commands to a room, such as !echo 95 | 96 | `matrix-puppet-bridge` comes with a bang command processor. Simply define a method and it will be invoked instead of being forwarded to the third party service: 97 | 98 | ```javascript 99 | class App extends MatrixPuppetBridgeBase { 100 | handleMatrixUserBangCommand(bangCmd, matrixMsgEvent) { 101 | const { bangcommand, command, body } = bangCmd; 102 | const { room_id } = matrixMsgEvent; 103 | const client = this.puppet.getClient(); 104 | const reply = (str) => client.sendNotice(room_id, str); 105 | if ( command === 'help' ) { 106 | reply([ 107 | 'Bang Commands', 108 | '!help .......... display this information', 109 | '!echo ... repeat text back to you', 110 | '!sync .......... synchronize this room with 3rd party service', 111 | ].join('\n')); 112 | } else if ( command === 'echo' ) { 113 | reply(body); 114 | } else if ( command === 'sync' ) { 115 | reply('command not implemented yet: '+bangcommand); 116 | } else { 117 | reply('unrecognized command: '+bangcommand); 118 | } 119 | } 120 | } 121 | ``` 122 | 123 | ### Q: Why am I seeing duplicate messages? 124 | 125 | We use a non-printable suffix to "tag" messages that go over the bridged network and when the message is seen on the return trip, we know to ignore it and not forward it again. This tag may be getting stripped on your network. 126 | 127 | Try using a printable tag, which is unlikely to be stripped, by editing config.json and adding: 128 | 129 | ```json 130 | "deduplicationTag" : " [m]", 131 | "deduplicationTagPattern" : " \\[m\\]" 132 | ``` 133 | 134 | Let us know if this doesn't work on a particular protocol! 135 | For more information, see [this discussion](https://github.com/matrix-hacks/matrix-puppet-facebook/issues/6). 136 | 137 | ### Q: Where can I ask questions? 138 | 139 | You can use GitHub issues on this or any other puppet-bridge projects. 140 | 141 | Alternatively you can join the [![Matrix Puppet Bridge](https://user-images.githubusercontent.com/13843293/52007839-4b2f6580-24c7-11e9-9a6c-14d8fc0d0737.png)](https://matrix.to/#/#matrix-puppet-bridge:matrix.org) room 142 | -------------------------------------------------------------------------------- /config.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "groupme": { 3 | "accessToken": "your-access-token" 4 | }, 5 | "registrationPath": "groupme-registration.yaml", 6 | "port": 8090, 7 | "bridge": { 8 | "homeserverUrl":"https://synapse.keyvan.pw", 9 | "domain": "synapse.keyvan.pw", 10 | "registration": "groupme-registration.yaml" 11 | "eventStore": "data/puppet-events.db", 12 | "userStore": "data/puppet-users.db", 13 | "roomStore": "data/puppet-rooms.db" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Puppet: require('./src/puppet'), 3 | MatrixPuppetBridgeBase: require('./src/base'), 4 | MatrixAppServiceBridge: require('matrix-appservice-bridge'), 5 | MatrixSdk: require('matrix-js-sdk'), 6 | utils: require('./src/utils'), 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matrix-puppet-bridge", 3 | "version": "1.16.2", 4 | "description": "facilitates writing a certain kind of matrix bridge", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"no tests at this time\"; exit 1", 8 | "gendoc": "jsdoc -r src -P package.json -R README.md -d docs" 9 | }, 10 | "keywords": [ 11 | "matrix" 12 | ], 13 | "contributors": [ 14 | "Keyvan Fatehi", 15 | "Andrew Johnson" 16 | ], 17 | "license": "ISC", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/matrix-hacks/matrix-puppet-bridge.git" 21 | }, 22 | "dependencies": { 23 | "bluebird": "^3.7.2", 24 | "concat-stream": "^2.0.0", 25 | "debug": "^4.1.1", 26 | "fluent-ffmpeg": "^2.1.2", 27 | "image-size": "^0.8.3", 28 | "matrix-appservice-bridge": "^1.12.2", 29 | "matrix-js-sdk": "^5.2.0", 30 | "mime-types": "^2.1.27", 31 | "needle": "^2.4.1", 32 | "read": "^1.0.7", 33 | "tempfile": "^3.0.0", 34 | "tmp": "^0.2.0" 35 | }, 36 | "devDependencies": { 37 | "eslint": "^6.8.0", 38 | "jsdoc": "^3.6.4" 39 | }, 40 | "eslintConfig": { 41 | "env": { 42 | "es6": true, 43 | "node": true 44 | }, 45 | "extends": "eslint:recommended", 46 | "rules": { 47 | "no-console": 0, 48 | "indent": [ 49 | "error", 50 | 2, 51 | { 52 | "SwitchCase": 1 53 | } 54 | ], 55 | "linebreak-style": [ 56 | "error", 57 | "unix" 58 | ], 59 | "semi": [ 60 | "error", 61 | "always" 62 | ], 63 | "no-unused-vars": [ 64 | "error", 65 | { 66 | "argsIgnorePattern": "^_" 67 | } 68 | ] 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/bang-command.js: -------------------------------------------------------------------------------- 1 | // Thanks to 2 | // https://github.com/krismuniz/slash-command/blob/4349ea6b60e6451b6b6537ea4551b8c522b4be87/index.js 3 | 'use strict'; 4 | const bangCommand = (s) => { 5 | if ( !s.startsWith('!') ) return null; 6 | let cmds = s.split(' ')[0].match(/\!([\w-=:.@]+)/ig); 7 | let bangcmds = null; 8 | let subcmds = null; 9 | let body = s.trim() || null; 10 | 11 | if (cmds) { 12 | bangcmds = cmds.join(''); 13 | cmds = cmds.map(x => x.replace('!','')); 14 | subcmds = cmds.length > 1 ? cmds.filter(v => v !== cmds[0]) : null; 15 | body = s.split(' ').filter((v, i) => i > 0).join(' ').trim() || null; 16 | } 17 | 18 | return { 19 | bangcommand: bangcmds, 20 | command: cmds ? cmds[0] : null, 21 | subcommands: subcmds, 22 | body: body, 23 | original: s 24 | }; 25 | }; 26 | 27 | module.exports = bangCommand; 28 | -------------------------------------------------------------------------------- /src/base.js: -------------------------------------------------------------------------------- 1 | const debug = require('./debug')('Base'); 2 | const Promise = require('bluebird'); 3 | const { Bridge, RemoteUser } = require('matrix-appservice-bridge'); 4 | const bangCommand = require('./bang-command'); 5 | const urlParse = require('url').parse; 6 | const inspect = require('util').inspect; 7 | const path = require('path'); 8 | const { download, autoTagger, isFilenameTagged, sleep } = require('./utils'); 9 | const fs = require('fs'); 10 | const ffmpeg = require('fluent-ffmpeg'); 11 | const sizeOf = require('image-size'); 12 | const mime = require('mime-types'); 13 | 14 | /** 15 | * Extend your app from this class to get started. 16 | * 17 | * 18 | * @example 19 | * // The following example is from {@link https://github.com/matrix-hacks/matrix-puppet-facebook|the facebook bridge} 20 | const { 21 | MatrixAppServiceBridge: { 22 | Cli, AppServiceRegistration 23 | }, 24 | Puppet, 25 | MatrixPuppetBridgeBase 26 | } = require("matrix-puppet-bridge"); 27 | const FacebookClient = require('./client'); 28 | const config = require('./config.json'); 29 | const path = require('path'); 30 | const puppet = new Puppet(path.join(__dirname, './config.json' )); 31 | const debug = require('debug')('matrix-puppet:facebook'); 32 | 33 | class App extends MatrixPuppetBridgeBase { 34 | getServicePrefix() { 35 | return "facebook"; 36 | } 37 | initThirdPartyClient() { 38 | this.threadInfo = {}; 39 | this.thirdPartyClient = new FacebookClient(this.config.facebook); 40 | this.thirdPartyClient.on('message', (data)=>{ 41 | const { senderID, body, threadID, isGroup } = data; 42 | const isMe = senderID === this.thirdPartyClient.userId; 43 | this.threadInfo[threadID] = { isGroup }; 44 | const payload = { 45 | roomId: threadID, 46 | senderId: isMe ? undefined : senderID, 47 | text: body 48 | }; 49 | debug(payload); 50 | return this.handleThirdPartyRoomMessage(payload); 51 | }); 52 | return this.thirdPartyClient.login(); 53 | } 54 | async getThirdPartyUserDataById(id) { 55 | const userInfo = await this.thirdPartyClient.getUserInfoById(id); 56 | debug('got user data', userInfo); 57 | return { senderName: userInfo.name }; 58 | } 59 | async getThirdPartyRoomDataById(threadId) { 60 | debug('getting third party room data by thread id', threadId); 61 | let label = this.threadInfo[threadId].isGroup ? "Group" : "Friend"; 62 | const data = await this.thirdPartyClient.getThreadInfo(threadId); 63 | let roomData = { 64 | name: data.name, 65 | topic: `Facebook ${label}` 66 | }; 67 | debug('room data', roomData); 68 | return roomData; 69 | } 70 | async sendMessageAsPuppetToThirdPartyRoomWithId(id, text) { 71 | return this.thirdPartyClient.sendMessage(id, text); 72 | } 73 | } 74 | 75 | new Cli({ 76 | port: config.port, 77 | registrationPath: config.registrationPath, 78 | generateRegistration: async(reg, callback) => { 79 | try { 80 | await puppet.associate(); 81 | reg.setId(AppServiceRegistration.generateToken()); 82 | reg.setHomeserverToken(AppServiceRegistration.generateToken()); 83 | reg.setAppServiceToken(AppServiceRegistration.generateToken()); 84 | reg.setSenderLocalpart("facebookbot"); 85 | reg.addRegexPattern("users", "@facebook_.*", true); 86 | callback(reg); 87 | } catch(err) { 88 | console.error(err.message); 89 | process.exit(-1); 90 | } 91 | }, 92 | run: async(port) => { 93 | const app = new App(config, puppet); 94 | try { 95 | await puppet.startClient(); 96 | await app.initThirdPartyClient(); 97 | await app.bridge.run(port, config); 98 | console.log('Matrix-side listening on port %s', port); 99 | } catch(err) { 100 | console.error(err.message); 101 | process.exit(-1); 102 | } 103 | } 104 | }).run(); 105 | */ 106 | class Base { 107 | /** 108 | * The short string to put before the ghost user name. 109 | * e.g. return "groupme" for @groupme_bob:your.host.com 110 | * 111 | * @returns {string} The string to prefix localpart user ids of ghost users 112 | */ 113 | getServicePrefix() { 114 | throw new Error("override me"); 115 | } 116 | /** 117 | * A friendly name for the protocol. 118 | * Use proper capitalization and make it look nice. 119 | * e.g. return "GroupMe" 120 | * 121 | * @returns {string} A friendly name for the bridged protocol. 122 | */ 123 | getServiceName() { 124 | const { warn } = debug(); 125 | warn('getServiceName is not defined, falling back to getServicePrefix'); 126 | return this.getServicePrefix(); 127 | } 128 | 129 | /** 130 | * Return a user id to match against 3rd party user id's in order to know if the message is of self-origin 131 | * 132 | * @returns {string} Your user ID from the perspective of the third party 133 | */ 134 | getPuppetThirdPartyUserId() { 135 | throw new Error('override me'); 136 | } 137 | 138 | /** 139 | * Implement how a text-based message is sent over the third party network 140 | * 141 | * @param {string} _thirdPartyRoomId 142 | * @param {string} _messageText 143 | * @param {object} _matrixEvent 144 | * @returns {Promise} 145 | */ 146 | async sendMessageAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _messageText, _matrixEvent) { 147 | throw new Error('please implement sendMessageAsPuppetToThirdPartyRoomWithId'); 148 | } 149 | 150 | /** 151 | * Implement how an image message is sent over the third party network 152 | * 153 | * @param {string} _thirdPartyRoomId 154 | * @param {object} _messageData 155 | * @param {object} _matrixEvent 156 | * @returns {Promise} 157 | */ 158 | async sendImageMessageAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _data, _matrixEvent) { 159 | throw new Error('please implement sendImageMessageAsPuppetToThirdPartyRoomWithId'); 160 | } 161 | 162 | /** 163 | * Implement how a sticker message is sent over the third party network 164 | * 165 | * @param {string} _thirdPartyRoomId 166 | * @param {object} _messageData 167 | * @param {object} _matrixEvent 168 | * @returns {Promise} 169 | */ 170 | async sendStickerMessageAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _data, _matrixEvent) { 171 | const { warn } = debug(); 172 | warn('sticker handling is not implemented for third party, trying to send it as an image'); 173 | return await this.sendImageMessageAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _data, _matrixEvent); 174 | } 175 | 176 | /** 177 | * Implement how a reaction is sent over the third party network 178 | * 179 | * @param {string} _thirdPartyRoomId 180 | * @param {object} _messageData 181 | * @param {object} _matrixEvent 182 | * @returns {Promise} 183 | */ 184 | async sendReactionAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _matrixEvent) { 185 | const { warn } = debug(); 186 | warn('reaction handling is not implemented for third party, ignoring event'); 187 | } 188 | 189 | /** 190 | * Implement how a audio is sent over the third party network 191 | * 192 | * @param {string} _thirdPartyRoomId 193 | * @param {object} _messageData 194 | * @param {object} _matrixEvent 195 | * @returns {Promise} 196 | */ 197 | async sendAudioAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _data, _matrixEvent) { 198 | const { warn } = debug(); 199 | warn('audio handling is not implemented for third party, trying to send it as a normal file'); 200 | return await this.sendFileMessageAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _data, _matrixEvent); 201 | } 202 | 203 | /** 204 | * Implement how a videos are sent over the third party network 205 | * 206 | * @param {string} _thirdPartyRoomId 207 | * @param {object} _messageData 208 | * @param {object} _matrixEvent 209 | * @returns {Promise} 210 | */ 211 | async sendVideoAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _data, _matrixEvent) { 212 | const { warn } = debug(); 213 | warn('video handling is not implemented for third party, trying to send it as a normal file'); 214 | return await this.sendFileMessageAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _data, _matrixEvent); 215 | } 216 | 217 | /** 218 | * Implement how a file message is sent over the third party network 219 | * 220 | * @param {string} _thirdPartyRoomId 221 | * @param {object} _messageData 222 | * @param {object} _matrixEvent 223 | * @returns {Promise} 224 | */ 225 | async sendFileMessageAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _data, _matrixEvent) { 226 | throw new Error('please implement sendFileMessageAsPuppetToThirdPartyRoomWithId'); 227 | } 228 | 229 | /** 230 | * Implement how a read receipt is sent over the third party network 231 | * 232 | * @param {string} _thirdPartyRoomId 233 | * @returns {Promise} 234 | */ 235 | async sendReadReceiptAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId) { 236 | throw new Error('please implement sendReadReceiptAsPuppetToThirdPartyRoomWithId'); 237 | } 238 | 239 | /** 240 | * Implement how a typing event is sent over the third party network 241 | * 242 | * @param {string} _thirdPartyRoomId 243 | * @param {boolean} _status 244 | * @returns {Promise} 245 | */ 246 | async sendTypingEventAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _status) { 247 | throw new Error('please implement sendTypingEventAsPuppetToThirdPartyRoomWithId'); 248 | } 249 | 250 | /** 251 | * Implement how leaving a room is handled 252 | * 253 | * @param {string} _thirdPartyRoomId 254 | * @param {object} _messageData 255 | * @param {object} _matrixEvent 256 | * @returns {Promise} 257 | */ 258 | async sendLeavingEventAsPuppetToThirdPartyRoomWithId(_thirdPartyRoomId, _matrixEvent) { 259 | const { warn } = debug(); 260 | warn('leaving room is not implemented for third party, ignoring event'); 261 | } 262 | 263 | /** 264 | * Return a postfix for the status room name. 265 | * It should be fairly unique so that it's unlikely to clash with a legitmate user. 266 | * (Let's hope nobody likes the name 'puppetStatusRoom') 267 | * 268 | * If you use the default below, the bridge room's alias will end up being 269 | * something like '#groupme_puppetStatusRoom'. 270 | * 271 | * There should be no need to override this. 272 | * 273 | * @returns {string} Postfix for the status room name. 274 | */ 275 | getStatusRoomPostfix() { 276 | return "puppetStatusRoom"; 277 | } 278 | 279 | /** 280 | * @constructor 281 | * 282 | * @param {object} config Config as a JavaScript object 283 | * @param {object} puppet Instance of Puppet to use 284 | * @param {object} bridge Optional instance of Bridge to use 285 | */ 286 | constructor(config, puppet, bridge) { 287 | const { info } = debug(); 288 | this.allowNullSenderName = false; 289 | this.config = config; 290 | this.puppet = puppet; 291 | this.domain = config.bridge.domain; 292 | this.homeserver = urlParse(config.bridge.homeserverUrl); 293 | this.deduplicationTag = this.config.deduplicationTag || this.defaultDeduplicationTag(); 294 | this.deduplicationTagPattern = this.config.deduplicationTagPattern || this.defaultDeduplicationTagPattern(); 295 | this.deduplicationTagRegex = new RegExp(this.deduplicationTagPattern); 296 | this.bridge = bridge || this.setupBridge(config); 297 | info('initialized'); 298 | 299 | this.puppet.setApp(this) 300 | } 301 | 302 | /** 303 | * Optional async call to get additional data about the third party user, for when this information does not arrive in the original payload 304 | * 305 | * @param {string} thirdPartyRoomId The unique identifier on the third party's side 306 | * @returns {Promise} Resolve with an object like {senderName: 'some name'} 307 | */ 308 | async getThirdPartyUserDataById(_thirdPartyUserId) { 309 | throw new Error("override me and return or resolve a promise with at least {senderName: 'some name'}, otherwise provide it in the original payload and i will never be invoked"); 310 | } 311 | /** 312 | * Optional async call to get additional data about the third party room, for when this information does not arrive in the original payload 313 | * 314 | * @param {string} thirdPartyRoomId The unique identifier on the third party's side 315 | * @returns {Promise} Resolve with an object like { name:string, topic:string } 316 | */ 317 | async getThirdPartyRoomDataById(_thirdPartyRoomId) { 318 | throw new Error("override me"); 319 | } 320 | 321 | /** 322 | * Instantiates a Bridge for you. Called by the constructor if an existing bridge instance was not provided. 323 | * 324 | * @param {object} config bridge configuration (homeserverUrl, domain, registration) 325 | * 326 | * @private 327 | */ 328 | setupBridge(config) { 329 | return new Bridge(Object.assign({}, config.bridge, { 330 | controller: { 331 | onUserQuery: function(queriedUser) { 332 | console.log('got user query', queriedUser); 333 | return {}; // auto provision users w no additional data 334 | }, 335 | onEvent: this.handleMatrixEvent.bind(this), 336 | onAliasQuery: function() { 337 | console.log('on alias query'); 338 | }, 339 | thirdPartyLookup: { 340 | protocols: [this.getServicePrefix()], 341 | getProtocol: function() { 342 | console.log('get proto'); 343 | }, 344 | getLocation: function() { 345 | console.log('get loc'); 346 | }, 347 | getUser: function() { 348 | console.log('get user'); 349 | } 350 | } 351 | } 352 | })); 353 | } 354 | 355 | async _grantPuppetMaxPowerLevel(room_id) { 356 | const { info } = debug(this._grantPuppetMaxPowerLevel.name); 357 | const puppetClient = this.puppet.getClient(); 358 | const puppetUserId = puppetClient.credentials.userId; 359 | 360 | const botIntent = this.getIntentFromApplicationServerBot(); 361 | info("ensuring puppet user has full power over this room", room_id); 362 | let pwrEvent; 363 | try { 364 | const pwrLevel = botIntent.opts.backingStore.getPowerLevelContent(room_id); 365 | 366 | if (pwrLevel) { 367 | pwrEvent = await Promise.resolve(pwrLevel); 368 | await botIntent.opts.backingStore.setPowerLevelContent(room_id, pwrEvent); 369 | 370 | if (pwrEvent.users[puppetUserId] == 100) { 371 | info("puppet already has full control over room:", room_id); 372 | return room_id; 373 | } 374 | 375 | await botIntent.setPowerLevel(room_id, puppetUserId, 100); 376 | info('granted puppet client admin status on the room:', room_id); 377 | 378 | } else { 379 | await Promise.resolve(); 380 | info("attempting to retrieve power levels with puppet user on room_id:", room_id); 381 | pwrEvent = puppetClient.getStateEvent(room_id, "m.room.power_levels", ""); 382 | } 383 | } catch(err) { 384 | info("ignoring failed attempt at retrieving power levels with puppet user on room_id:", room_id); 385 | info("re-attempting to retrieve power levels with bot user on room_id:", room_id); 386 | pwrEvent = botIntent.client.getStateEvent(room_id, "m.room.power_levels", "") 387 | } 388 | 389 | return room_id; 390 | } 391 | 392 | /** 393 | * Async call to get the status room ID 394 | * 395 | * @params {_roomAliasLocalPart} Optional, the room alias local part 396 | * @returns {Promise} Promise resolving the Matrix room ID of the status room 397 | */ 398 | async getStatusRoomId(_roomAliasLocalPart) { 399 | const { info, warn } = debug(this.getStatusRoomId.name); 400 | const roomAliasLocalPart = _roomAliasLocalPart || this.getServicePrefix()+"_"+this.getStatusRoomPostfix(); 401 | const roomAlias = "#"+roomAliasLocalPart+":"+this.domain; 402 | const puppetClient = this.puppet.getClient(); 403 | 404 | const botIntent = this.getIntentFromApplicationServerBot(); 405 | const botClient = botIntent.getClient(); 406 | 407 | info('looking up', roomAlias); 408 | let matrixRoomId; 409 | try { 410 | const { room_id } = await puppetClient.getRoomIdForAlias(roomAlias); 411 | info("found matrix room via alias. room_id:", room_id); 412 | await this._grantPuppetMaxPowerLevel(room_id); 413 | matrixRoomId = room_id; 414 | } catch(_err) { 415 | const name = this.getServiceName() + " Protocol"; 416 | const topic = this.getServiceName() + " Protocol Status Messages"; 417 | info("creating status room !!!!", ">>>>"+roomAliasLocalPart+"<<<<", name, topic); 418 | const { room_id } = await botIntent.createRoom({ 419 | createAsClient: false, 420 | options: { 421 | name, topic, room_alias_name: roomAliasLocalPart 422 | } 423 | }); 424 | info("status room created", room_id, roomAliasLocalPart); 425 | matrixRoomId = room_id; 426 | } 427 | 428 | info("making puppet join protocol status room", matrixRoomId); 429 | try { 430 | await puppetClient.joinRoom(matrixRoomId); 431 | info("puppet joined the protocol status room"); 432 | await this._grantPuppetMaxPowerLevel(matrixRoomId); 433 | } catch(err) { 434 | if (err.message === 'No known servers') { 435 | warn('we cannot use this room anymore because you cannot currently rejoin an empty room (synapse limitation? riot throws this error too). we need to de-alias it now so a new room gets created that we can actually use.'); 436 | await botClient.deleteAlias(roomAlias); 437 | warn('deleted alias... trying again to get or create room.'); 438 | return await this.getStatusRoomId(_roomAliasLocalPart); 439 | } 440 | warn("ignoring error from puppet join room: ", err.message); 441 | } 442 | return matrixRoomId; 443 | } 444 | 445 | /** 446 | * Make a list of third party users join the status room 447 | * 448 | * @param {Object[]} users The list of third party users 449 | * @param {string} users[].name The third party user name 450 | * @param {string} users[].userId The third party user ID 451 | * @param {string} users[].avatar The third party user avatar 452 | * 453 | * @returns {Promise} Promise resolving if all joins success 454 | */ 455 | async joinThirdPartyUsersToStatusRoom(users) { 456 | const { info } = debug(this.getStatusRoomId.name); 457 | 458 | info("Join %s users to the status room", users.length); 459 | const statusRoomId = await this.getStatusRoomId(); 460 | await Promise.each(users, async(user) => { 461 | const ghostIntent = await this.getIntentFromThirdPartySenderId(user.userId, user.name, user.avatar); 462 | return await ghostIntent.join(statusRoomId); 463 | }); 464 | info("Contact list synced"); 465 | } 466 | 467 | /** 468 | * Send a message to the status room 469 | * 470 | * @param {object} options={} Optional options object: fixedWidthOutput:boolean 471 | * @param {string} ...args additional arguments are formatted and send to the room 472 | * 473 | * @returns {Promise} 474 | */ 475 | async sendStatusMsg(options={}, ...args) { 476 | if (typeof options !== 'object') { 477 | throw new Error('sendStatusMsg requires first parameter to be an options object which can be empty.'); 478 | } 479 | if (options.fixedWidthOutput === undefined) 480 | { 481 | options.fixedWidthOutput = true; 482 | } 483 | 484 | const msgText = args.reduce((acc, arg, index)=>{ 485 | const sep = index > 0 ? ' ' : ''; 486 | if (typeof arg === 'object') { 487 | return acc+sep+inspect(arg, {depth:null,showHidden:true}); 488 | } else { 489 | return acc+sep+arg.toString(); 490 | } 491 | }, ''); 492 | 493 | const { warn, info } = debug(this.sendStatusMsg.name); 494 | const statusRoomId = await this.getStatusRoomId(options.roomAliasLocalPart); 495 | const botIntent = this.bridge.getIntent(); 496 | if (botIntent === null) { 497 | warn('cannot send a status message before the bridge is ready'); 498 | return false; 499 | } 500 | let promiseList = []; 501 | 502 | promiseList.push(() => { 503 | info("joining protocol bot to room >>>", statusRoomId, "<<<"); 504 | botIntent.join(statusRoomId); 505 | }); 506 | 507 | // AS Bots don't have display names? Weird... 508 | // PUT https:///_matrix/client/r0/profile/%40hangoutsbot%3Aexample.org/displayname (AS) HTTP 404 Error: {"errcode":"M_UNKNOWN","error":"No row found"} 509 | //promiseList.push(() => botIntent.setDisplayName(this.getServiceName() + " Bot")); 510 | 511 | promiseList.push(() => { 512 | let txt = this.tagMatrixMessage(msgText); // <-- Important! Or we will cause message looping... 513 | if(options.fixedWidthOutput) 514 | { 515 | return botIntent.sendMessage(statusRoomId, { 516 | body: txt, 517 | formatted_body: "
" + txt + "
", 518 | format: "org.matrix.custom.html", 519 | msgtype: "m.notice" 520 | }); 521 | } 522 | return botIntent.sendMessage(statusRoomId, { 523 | body: txt, 524 | msgtype: "m.notice" 525 | }); 526 | }); 527 | 528 | return Promise.mapSeries(promiseList, p => p()); 529 | } 530 | 531 | getGhostUserFromThirdPartySenderId(id) { 532 | return "@"+this.getServicePrefix()+"_"+id+":"+this.domain; 533 | } 534 | getRoomAliasFromThirdPartyRoomId(id) { 535 | return "#"+this.getRoomAliasLocalPartFromThirdPartyRoomId(id)+':'+this.domain; 536 | } 537 | getThirdPartyUserIdFromMatrixGhostId(matrixGhostId) { 538 | const patt = new RegExp(`^@${this.getServicePrefix()}_(.+)$`); 539 | const localpart = matrixGhostId.replace(':'+this.domain, ''); 540 | const matches = localpart.match(patt); 541 | return matches ? matches[1] : null; 542 | } 543 | getThirdPartyRoomIdFromMatrixRoomId(matrixRoomId) { 544 | const { info } = debug(this.getThirdPartyRoomIdFromMatrixRoomId.name); 545 | const patt = new RegExp(`^#${this.getServicePrefix()}_(.+)$`); 546 | const room = this.puppet.getClient().getRoom(matrixRoomId); 547 | if (!room) { 548 | return null; 549 | } 550 | info('reducing array of alases to a 3prid'); 551 | const aliases = [room.getCanonicalAlias()].concat(room.getAliases()).concat(room.getAltAliases()); 552 | return aliases.reduce((result, alias) => { 553 | const localpart = alias.replace(':'+this.domain, ''); 554 | const matches = localpart.match(patt); 555 | return matches ? matches[1] : result; 556 | }, null); 557 | } 558 | getRoomAliasLocalPartFromThirdPartyRoomId(id) { 559 | return this.getServicePrefix()+"_"+id; 560 | } 561 | 562 | /** 563 | * Get a intent for a third party user, and if provided set its display name and its avatar 564 | * 565 | * @param {string} userId The third party user ID 566 | * @param {string} name The third party user name 567 | * @param {string} avatar The third party user avatar 568 | * 569 | * @returns {Promise} A promise resolving to an Intent 570 | */ 571 | async getIntentFromThirdPartySenderId(userId, name, avatar) { 572 | const ghostIntent = this.bridge.getIntent(this.getGhostUserFromThirdPartySenderId(userId)); 573 | 574 | let promiseList = []; 575 | if (name) 576 | promiseList.push(ghostIntent.setDisplayName(name)); 577 | 578 | if (avatar) 579 | promiseList.push(this.setGhostAvatar(ghostIntent, avatar)); 580 | 581 | await Promise.all(promiseList); 582 | return ghostIntent; 583 | } 584 | 585 | getIntentFromApplicationServerBot() { 586 | return this.bridge.getIntent(); 587 | } 588 | 589 | /** 590 | * Returns a Promise resolving {senderName} 591 | * 592 | * Optional code path which is only called if the derived class does not 593 | * provide a senderName when invoking handleThirdPartyRoomMessage 594 | * 595 | * @param {string} thirdPartyUserId 596 | * @returns {Promise} A promise resolving to a {RemoteUser} 597 | */ 598 | async getOrInitRemoteUserStoreDataFromThirdPartyUserId(thirdPartyUserId) { 599 | const { info } = debug(this.getOrInitRemoteUserStoreDataFromThirdPartyUserId.name); 600 | const userStore = this.bridge.getUserStore(); 601 | let rUser = await userStore.getRemoteUser(thirdPartyUserId); 602 | if ( rUser ) { 603 | info("found existing remote user in store", rUser); 604 | return rUser; 605 | } 606 | 607 | info("did not find existing remote user in store, we must create it now"); 608 | const thirdPartyUserData = await this.getThirdPartyUserDataById(thirdPartyUserId); 609 | info("got 3p user data:", thirdPartyUserData); 610 | 611 | rUser = new RemoteUser(thirdPartyUserId, { 612 | senderName: thirdPartyUserData.senderName 613 | }); 614 | await userStore.setRemoteUser(rUser); 615 | return await userStore.getRemoteUser(thirdPartyUserId); 616 | } 617 | 618 | async getOrCreateMatrixRoomFromThirdPartyRoomId(thirdPartyRoomId) { 619 | const { warn, info } = debug(this.getOrCreateMatrixRoomFromThirdPartyRoomId.name); 620 | const roomAlias = this.getRoomAliasFromThirdPartyRoomId(thirdPartyRoomId); 621 | const roomAliasName = this.getRoomAliasLocalPartFromThirdPartyRoomId(thirdPartyRoomId); 622 | info('looking up', thirdPartyRoomId); 623 | const puppetClient = this.puppet.getClient(); 624 | const botIntent = this.getIntentFromApplicationServerBot(); 625 | const botClient = botIntent.getClient(); 626 | const puppetUserId = puppetClient.credentials.userId; 627 | 628 | let createRoom = async () => { 629 | const thirdPartyRoomData = await this.getThirdPartyRoomDataById(thirdPartyRoomId); 630 | info("got 3p room data", thirdPartyRoomData); 631 | const { name, topic, is_direct } = thirdPartyRoomData; 632 | info("creating room", roomAliasName, name, topic); 633 | const { room_id } = await botIntent.createRoom({ 634 | createAsClient: true, // bot won't auto-join the room in this case 635 | options: { 636 | name, topic, is_direct, 637 | invite: [puppetUserId], 638 | room_alias_name: roomAliasName 639 | } 640 | }); 641 | info("room created", room_id, roomAliasName); 642 | 643 | return room_id; 644 | }; 645 | 646 | // If we can not use the old room, we delete the alias and create a new room. 647 | let recreateRoom = async () => { 648 | await botClient.deleteAlias(roomAlias); 649 | warn('deleted alias... trying again to get or create room.'); 650 | let room_id = await createRoom(); 651 | 652 | return room_id; 653 | } 654 | 655 | let matrixRoomId; 656 | try { 657 | const { room_id } = await botClient.getRoomIdForAlias(roomAlias); 658 | info("found matrix room via alias. roomId:", room_id); 659 | matrixRoomId = room_id; 660 | } catch(err) { 661 | info("the room doesn't exist. we need to create it for the first time"); 662 | matrixRoomId = await createRoom(); 663 | } 664 | 665 | try { 666 | const roomsBot = await botClient.getJoinedRooms(); 667 | const hasBotJoined = roomsBot.joined_rooms.includes(matrixRoomId); 668 | 669 | if (!hasBotJoined) { 670 | warn("the found room does not contain the bot, thus we have to create a new room"); 671 | matrixRoomId = await recreateRoom(); 672 | } 673 | } catch(err) { 674 | warn("checking if the bot is in the found room failed:", err.message); 675 | } 676 | 677 | info("Ensuring puppet joined room", puppetUserId, matrixRoomId); 678 | try { 679 | const roomsPuppet = await puppetClient.getJoinedRooms(); 680 | const hasPuppetJoined = roomsPuppet.joined_rooms.includes(matrixRoomId); 681 | if (!hasPuppetJoined) { 682 | await botIntent.invite(matrixRoomId, puppetUserId); 683 | await puppetClient.joinRoom(matrixRoomId); 684 | info("returning room id after join room attempt", matrixRoomId); 685 | await this._grantPuppetMaxPowerLevel(matrixRoomId); 686 | } 687 | } catch(err) { 688 | if (err.message === "No known servers") { 689 | warn('we cannot use this room anymore because you cannot currently rejoin an empty room (synapse limitation? riot throws this error too).'); 690 | matrixRoomId = await recreateRoom(); 691 | } else { 692 | warn("ignoring error from puppet join room: ", err.message); 693 | } 694 | } 695 | 696 | info("setting room as invite-only", matrixRoomId); 697 | try { 698 | await puppetClient.sendStateEvent(matrixRoomId, "m.room.join_rules", {"join_rule": "invite"}); 699 | info("succeeded in setting room as invite-only using puppet client. Room:", matrixRoomId); 700 | } catch(err) { 701 | info("Since setting join rules with puppet client failed, now trying with bot client"); 702 | try { 703 | await botIntent.sendStateEvent(matrixRoomId, "m.room.join_rules", "", {"join_rule": "invite"}); 704 | info("succeeded in setting room as invite-only using bot client. Room:", matrixRoomId); 705 | } catch(err) { 706 | warn("Both puppet and bot client invite only settings failed :( Error:", err.message); 707 | } 708 | } 709 | 710 | info("restore alias when binding was broken", matrixRoomId); 711 | try { 712 | const room = puppetClient.getRoom(matrixRoomId); 713 | const aliases = [room.getCanonicalAlias()].concat(room.getAliases()).concat(room.getAltAliases()); 714 | 715 | if (!aliases.includes(roomAlias)) { 716 | await botIntent.sendStateEvent(matrixRoomId, "m.room.aliases", this.domain, { 717 | aliases: aliases.concat(roomAlias), 718 | }); 719 | } 720 | } catch(err) { 721 | warn("room alias restoring failed:", err.message); 722 | } 723 | 724 | info("Update room avatar", matrixRoomId); 725 | try { 726 | const thirdPartyRoomData = await this.getThirdPartyRoomDataById(thirdPartyRoomId); 727 | const { avatar } = thirdPartyRoomData; 728 | if(avatar) { 729 | await this.setRoomAvatar(matrixRoomId, avatar); 730 | } 731 | } catch(err) { 732 | warn("Updating room avatar failed:", err.message); 733 | } 734 | 735 | 736 | 737 | 738 | this.puppet.saveThirdPartyRoomId(matrixRoomId, thirdPartyRoomId); 739 | return matrixRoomId; 740 | } 741 | 742 | /** 743 | * Get the client object for a user, either third party user or us. 744 | * 745 | * @param {string} roomId The room the user must join ID 746 | * @param {string} senderId The user's ID 747 | * @param {string} senderName The user's name 748 | * @param {string} avatar A resource containing the avatar 749 | * @param {boolean} doNoTryToGetRemoteUsersStoreData Private parameter to prevent infinite loop 750 | * 751 | * @returns {Promise} A Promise resolving to the user's client object 752 | */ 753 | async getUserClient(roomId, senderId, senderName, avatar, doNotTryToGetRemoteUserStoreData) { 754 | const { info } = debug(this.getUserClient.name); 755 | info("get user client for third party user %s (%s)", senderId, senderName); 756 | 757 | // Why is this not just on the base object? 758 | const puppetClient = this.puppet.getClient() 759 | 760 | if (senderId === undefined) { 761 | return this.puppet.getClient(); 762 | } 763 | 764 | if (!senderName && !this.allowNullSenderName) { 765 | if (doNotTryToGetRemoteUserStoreData) 766 | throw new Error('preventing an endless loop'); 767 | 768 | info("no senderName provided with payload, will check store"); 769 | const remoteUser = await this.getOrInitRemoteUserStoreDataFromThirdPartyUserId(senderId); 770 | info("got remote user from store, with a possible client API call in there somewhere", remoteUser); 771 | info("will retry now"); 772 | const senderName = remoteUser.get('senderName'); 773 | return await this.getUserClient(roomId, senderId, senderName, avatar, true); 774 | } 775 | 776 | info("this message was not sent by me"); 777 | const ghostIntent = await this.getIntentFromThirdPartySenderId(senderId, senderName, avatar); 778 | const statusRoomId = await this.getStatusRoomId(); 779 | try { 780 | await ghostIntent.join(statusRoomId); 781 | await puppetClient.invite(roomId, ghostIntent.client.credentials.userId); 782 | await ghostIntent.join(roomId); 783 | } catch { 784 | console.log("got ya"); 785 | } 786 | 787 | return ghostIntent.getClient(); 788 | } 789 | 790 | messageIsFromThirdParty(senderId, messageText, attachedFilePath = '') { 791 | const { info, warn } = debug(this.handleThirdPartyRoomImageMessage.name); 792 | 793 | let isThirdParty = true; 794 | if (senderId === undefined) { 795 | info("this message was sent by me, but did it come from a matrix client or a 3rd party client?"); 796 | info("if it came from a 3rd party client, we want to repeat it as a 'notice' type message"); 797 | info("if it came from a matrix client, then it's already in the client, sending again would dupe"); 798 | info("we use a tag on the end of messages to determine if it came from matrix"); 799 | 800 | if (typeof messageText === 'undefined') { 801 | info("we can't know if this message is from matrix or not, so just ignore it"); 802 | isThirdParty = false; 803 | } 804 | if (this.isTaggedMatrixMessage(messageText) || isFilenameTagged(attachedFilePath || '')) { 805 | info('it is from matrix, so just ignore it.'); 806 | isThirdParty = false; 807 | } 808 | info('it is from 3rd party client'); 809 | } 810 | return isThirdParty; 811 | } 812 | 813 | async videoDimensions(videoFile) { 814 | 815 | let getMetadata = (file) => new Promise((resolve, reject) => { 816 | ffmpeg.ffprobe(file, function(err, metadata) { 817 | if (err) { reject(err); } 818 | else { resolve(metadata); } 819 | }); 820 | }); 821 | 822 | try { 823 | let metadata = await getMetadata(videoFile); 824 | 825 | // video stream isn't necessarily the first one. loop through the streams 826 | // and look for codec_type: 'video' 827 | let videoStream; 828 | for (let i in metadata.streams) { 829 | let stream = metadata.streams[i]; 830 | if (stream.codec_type === "video") { 831 | videoStream = stream; 832 | break; 833 | } 834 | } 835 | 836 | if (videoStream) { 837 | var w = videoStream.width; 838 | var h = videoStream.height; 839 | 840 | if ("rotation" in videoStream) { 841 | let r = videoStream.rotation; 842 | 843 | // Not actually sure what the possible rotation values are. Hopefully this covers it. 844 | if (r === "0" || r === "-0" || r === "180" || r === "-180") { var isRotated = false; } 845 | else { var isRotated = true; } 846 | } 847 | } 848 | 849 | return (isRotated ? { w: h, h: w } : { w: w, h: h }); 850 | } catch { 851 | return {w: 0, h: 0}; 852 | } 853 | } 854 | 855 | // Payload can include a url, path, or buffer. Mimetype is optional 856 | // (we'll attempt to figure it out unless it's set explicitly), but it's best to set it 857 | // when sending a buffer if possible. If the mimetype isn't set and we can't figure it out 858 | // the attachement will be sent as an m.file message. 859 | async handleThirdPartyRoomMessageWithAttachment(payload) { 860 | const { info, warn } = debug(this.handleThirdPartyRoomMessageWithAttachment.name); 861 | info('handling third party room message with attachment', payload); 862 | let { 863 | roomId, 864 | senderName, 865 | senderId, 866 | avatar, 867 | text, 868 | url, path, buffer, 869 | mimetype, 870 | } = payload; 871 | 872 | const matrixRoomId = await this.getOrCreateMatrixRoomFromThirdPartyRoomId(roomId); 873 | const client = await this.getUserClient(matrixRoomId, senderId, senderName, avatar); 874 | 875 | if (!this.messageIsFromThirdParty(senderId, text, url || path)) { 876 | return; 877 | } 878 | 879 | if (!mimetype) mimetype = mime.lookup(url || path); 880 | 881 | let upload = async(buffer, opts) => { 882 | const res = await client.uploadContent(buffer, Object.assign({ 883 | name: text, 884 | type: mimetype, 885 | rawResponse: false 886 | }, opts || {})); 887 | return { 888 | content_uri: res.content_uri || res, 889 | size: buffer.length 890 | }; 891 | }; 892 | 893 | const tag = autoTagger(senderId, this); 894 | 895 | let res; 896 | let randomString = Math.random().toString(36).slice(2, 12); 897 | let localFilePath = '/tmp/matrix_bridge_tempfile_' + randomString; 898 | try { 899 | if ( url ) { 900 | const {buffer, type} = await download.getBufferAndType(url); 901 | fs.writeFileSync(localFilePath, buffer); 902 | res = await upload(buffer, { type: mimetype || type }); 903 | } else if ( path ) { 904 | const buffer = await (Promise.promisify(fs.readFile)(path)); 905 | localFilePath = path; 906 | res = await upload(buffer); 907 | } else if ( buffer ) { 908 | fs.writeFileSync(localFilePath, buffer); 909 | res = await upload(buffer); 910 | } else { 911 | throw new Error('missing url or path'); 912 | } 913 | } catch(err) { 914 | warn('upload error', err); 915 | // If we can't upload the file just send a plain text message with the url or file path. 916 | return await client.sendMessage(matrixRoomId, {body: tag(url || path || text || "Unhandled file, maybe it was to big for the homeserver?"), msgtype: "m.text"}); 917 | } 918 | 919 | const { content_uri, size } = res; 920 | info('uploaded to', content_uri); 921 | let opts = { "mimetype": mimetype, "h": 0, "w": 0, "size": size }; 922 | let messageType = "m.file"; 923 | 924 | if (!mimetype) { 925 | console.log("Couldn't get mimetype for attachment."); 926 | } else { 927 | if (mimetype.includes("image")) { 928 | messageType = "m.image"; 929 | 930 | const dimensions = sizeOf(localFilePath); 931 | opts.h = dimensions.height; 932 | opts.w = dimensions.width; 933 | 934 | } else if (mimetype.includes("video")) { 935 | const dimensions = await this.videoDimensions(localFilePath); 936 | if (dimensions.w > 0 && dimensions.h > 0) { 937 | opts.w = dimensions.w; 938 | opts.h = dimensions.h; 939 | 940 | // Messages get ugly if we send a video without setting dimensions, 941 | // so only send the message as m.video if we can get them. Otherwise just send it as m.file 942 | messageType = "m.video"; 943 | } else { 944 | warn("Couldn't get video dimensions. Is ffmpeg installed?"); 945 | } 946 | } else if (mimetype.includes("audio")) { 947 | messageType = "m.audio"; 948 | } 949 | } 950 | 951 | // don't send a message without a body. It's not allowed: https://matrix.org/docs/spec/client_server/r0.4.0.html#id89 952 | if (!text) { text = mimetype } 953 | 954 | const content = { 955 | msgtype: messageType, 956 | url: content_uri, 957 | info: opts, 958 | body: tag(text), 959 | }; 960 | return client.sendMessage(matrixRoomId, content); 961 | } 962 | 963 | // This is deprecated. Use handleThirdPartyRoomMessageWithAttachment instead 964 | async handleThirdPartyRoomImageMessage(thirdPartyRoomImageMessageData) { 965 | const { info, warn } = debug(this.handleThirdPartyRoomImageMessage.name); 966 | info('handling third party room image message', thirdPartyRoomImageMessageData); 967 | let { 968 | roomId, 969 | senderName, 970 | senderId, 971 | avatar, 972 | text, 973 | url, path, buffer, // either one is fine 974 | h, 975 | w, 976 | mimetype 977 | } = thirdPartyRoomImageMessageData; 978 | 979 | const matrixRoomId = await this.getOrCreateMatrixRoomFromThirdPartyRoomId(roomId); 980 | const client = await this.getUserClient(matrixRoomId, senderId, senderName, avatar); 981 | if (senderId === undefined) { 982 | info("this message was sent by me, but did it come from a matrix client or a 3rd party client?"); 983 | info("if it came from a 3rd party client, we want to repeat it as a 'notice' type message"); 984 | info("if it came from a matrix client, then it's already in the client, sending again would dupe"); 985 | info("we use a tag on the end of messages to determine if it came from matrix"); 986 | 987 | if (typeof text === 'undefined') { 988 | info("we can't know if this message is from matrix or not, so just ignore it"); 989 | return; 990 | } 991 | if (this.isTaggedMatrixMessage(text) || isFilenameTagged(path || url || '')) { 992 | info('it is from matrix, so just ignore it.'); 993 | return; 994 | } 995 | info('it is from 3rd party client'); 996 | } 997 | 998 | let upload = async(buffer, opts) => { 999 | const res = await client.uploadContent(buffer, Object.assign({ 1000 | name: text, 1001 | type: mimetype, 1002 | rawResponse: false 1003 | }, opts || {})); 1004 | return { 1005 | content_uri: res.content_uri || res, 1006 | size: buffer.length 1007 | }; 1008 | }; 1009 | 1010 | const tag = autoTagger(senderId, this); 1011 | 1012 | let res; 1013 | try { 1014 | if ( url ) { 1015 | const {buffer, type} = await download.getBufferAndType(url); 1016 | res = await upload(buffer, { type: mimetype || type }); 1017 | } else if ( path ) { 1018 | const buffer = await (Promise.promisify(fs.readFile)(path)); 1019 | res = await upload(buffer); 1020 | } else if ( buffer ) { 1021 | res = await upload(buffer); 1022 | } else { 1023 | throw new Error('missing url or path'); 1024 | } 1025 | } catch(err) { 1026 | warn('upload error', err); 1027 | 1028 | let opts = { 1029 | body: tag(url || path || text), 1030 | msgtype: "m.text" 1031 | }; 1032 | return await client.sendMessage(matrixRoomId, opts); 1033 | } 1034 | 1035 | const { content_uri, size } = res; 1036 | info('uploaded to', content_uri); 1037 | let msg = tag(text); 1038 | let opts = { mimetype, h, w, size }; 1039 | return await client.sendImageMessage(matrixRoomId, content_uri, opts, msg); 1040 | } 1041 | 1042 | /** 1043 | * Returns a promise 1044 | * quote is expected to either be null or contain: 1045 | * userId: the third party id of the quoted user, if undefined that means we quoted ourself 1046 | * eventId: the matrix id of the quoted event (inconsistency so the handling of events can be left to implementation for now) 1047 | * text: the text that was quoted 1048 | * reactions is also expected to either be null or or contain: 1049 | * roomId: the matrix room idea of the event in which the reaction happened 1050 | * eventId: the matrix id of the event it was reacted to (inconsistency so the handling of events can be left to implementation for now) 1051 | * emoji: the sent emoji 1052 | * 1053 | * 1054 | */ 1055 | async handleThirdPartyRoomMessage(thirdPartyRoomMessageData) { 1056 | let retry = 5; 1057 | let lastError; 1058 | while (retry--) { 1059 | try { 1060 | return await this._handleThirdPartyRoomMessage(thirdPartyRoomMessageData); 1061 | } catch(err) { 1062 | lastError = err; 1063 | } 1064 | await sleep(100); 1065 | } 1066 | return await this.sendStatusMsg({}, 'Error in '+this.handleThirdPartyRoomMessage.name, lastError, thirdPartyRoomMessageData); 1067 | } 1068 | async _handleThirdPartyRoomMessage(thirdPartyRoomMessageData) { 1069 | const { info } = debug(this.handleThirdPartyRoomMessage.name); 1070 | info('handling third party room message', thirdPartyRoomMessageData); 1071 | const { 1072 | roomId, 1073 | senderName, 1074 | senderId, 1075 | avatar, 1076 | text, 1077 | quote, 1078 | reaction, 1079 | html 1080 | } = thirdPartyRoomMessageData; 1081 | 1082 | const matrixRoomId = await this.getOrCreateMatrixRoomFromThirdPartyRoomId(roomId); 1083 | const client = await this.getUserClient(matrixRoomId, senderId, senderName, avatar); 1084 | 1085 | if (!this.messageIsFromThirdParty(senderId, text)) { 1086 | return; 1087 | } 1088 | 1089 | let tag = autoTagger(senderId, this); 1090 | 1091 | if (quote != null) { 1092 | let quotedUser; 1093 | if (quote.userId == undefined) { 1094 | quotedUser = this.puppet.getClient().credentials.userId; 1095 | } 1096 | else { 1097 | const quotedUserIntent = await this.getIntentFromThirdPartySenderId(quote.userId); 1098 | quotedUser = quotedUserIntent.client.credentials.userId; 1099 | } 1100 | const quoteHtml = this.formatTextToQuote(matrixRoomId, quote.eventId, quotedUser, quote.text, text); 1101 | const quoteText = "> <" + quotedUser + "> " + quote.text + "\\n \\n" +text; 1102 | return await client.sendMessage(matrixRoomId, { 1103 | body: tag(quoteText), 1104 | formatted_body: quoteHtml, 1105 | format: "org.matrix.custom.html", 1106 | msgtype: "m.text", 1107 | "m.relates_to": { 1108 | "m.in_reply_to": { 1109 | event_id: quote.eventId, 1110 | } 1111 | }, 1112 | }); 1113 | } 1114 | if (reaction) { 1115 | try { 1116 | return await client.sendEvent(reaction.roomId, "m.reaction", { 1117 | "m.relates_to": { 1118 | event_id: reaction.eventId, 1119 | key: reaction.emoji, 1120 | rel_type: "m.annotation", 1121 | } 1122 | }); 1123 | } 1124 | catch (err) { 1125 | //We catch all errors as reactions are not important 1126 | } 1127 | } 1128 | if (html) { 1129 | return await client.sendMessage(matrixRoomId, { 1130 | body: tag(text), 1131 | formatted_body: html, 1132 | format: "org.matrix.custom.html", 1133 | msgtype: "m.text" 1134 | }); 1135 | } 1136 | return await client.sendMessage(matrixRoomId, { 1137 | body: tag(text), 1138 | msgtype: "m.text" 1139 | }); 1140 | } 1141 | 1142 | //This is a dirty hack that is likely to fail, so should be replaced at one point 1143 | formatTextToQuote(quotedRoomId, quotedEventId, quotedUserId, quotedText, text) { 1144 | return "
In reply to " + quotedUserId + "
" + quotedText + "
" + text; 1145 | } 1146 | 1147 | handleMatrixEvent(req, _context) { 1148 | const { info, warn } = debug(this.handleMatrixEvent.name); 1149 | const data = req.getData(); 1150 | if (data.type === 'm.room.message' || data.type == 'm.sticker' || data.type == 'm.reaction') { 1151 | info('incoming message, sticker or annotation data:', data); 1152 | return this.handleMatrixMessageEvent(data); 1153 | } 1154 | else if (data.type == 'm.room.member') { 1155 | if (data.content.membership == 'leave') { 1156 | info('leaving room:', data.room_id); 1157 | const thirdPartyRoomId = this.getThirdPartyRoomIdFromMatrixRoomId(data.room_id); 1158 | return this.sendLeavingEventAsPuppetToThirdPartyRoomWithId(thirdPartyRoomId, data); 1159 | } 1160 | } 1161 | else { 1162 | return warn('ignored a matrix event', data.type); 1163 | } 1164 | } 1165 | 1166 | async handleMatrixMessageEvent(data) { 1167 | try { 1168 | return await this._handleMatrixMessageEvent(data); 1169 | } catch (err) { 1170 | return await this.sendStatusMsg({}, 'Error in '+this.handleMatrixEvent.name, err, data); 1171 | } 1172 | } 1173 | 1174 | async _handleMatrixMessageEvent(data) { 1175 | const logger = debug(this.handleMatrixMessageEvent.name); 1176 | const { room_id, content: { body, msgtype } } = data; 1177 | 1178 | if (this.isTaggedMatrixMessage(body)) { 1179 | logger.info("ignoring tagged message, it was sent by the bridge"); 1180 | return; 1181 | } 1182 | 1183 | const thirdPartyRoomId = this.getThirdPartyRoomIdFromMatrixRoomId(room_id); 1184 | const isStatusRoom = thirdPartyRoomId === this.getStatusRoomPostfix(); 1185 | 1186 | if (!thirdPartyRoomId) { 1187 | throw new Error('could not determine third party room id!'); 1188 | } 1189 | if (isStatusRoom) { 1190 | logger.info("ignoring incoming message to status room"); 1191 | 1192 | const msg = this.tagMatrixMessage("Commands are currently ignored here"); 1193 | 1194 | // We may wish to process bang commands here at some point, 1195 | // but for now let's just send a message back 1196 | return await this.sendStatusMsg({ fixedWidthOutput: false }, msg); 1197 | } 1198 | const msg = this.tagMatrixMessage(body); 1199 | 1200 | if (msgtype === 'm.text' || msgtype === 'm.notice') { 1201 | if (this.handleMatrixUserBangCommand) { 1202 | const bc = bangCommand(body); 1203 | if (bc) return this.handleMatrixUserBangCommand(bc, data); 1204 | } 1205 | return await this.sendMessageAsPuppetToThirdPartyRoomWithId(thirdPartyRoomId, msg, data); 1206 | } 1207 | if (msgtype === 'm.image') { 1208 | logger.info("picture message from riot"); 1209 | 1210 | let url = this.puppet.getClient().mxcUrlToHttp(data.content.url); 1211 | return await this.sendImageMessageAsPuppetToThirdPartyRoomWithId(thirdPartyRoomId, { 1212 | url, text: msg, 1213 | mimetype: data.content.info.mimetype, 1214 | width: data.content.info.w, 1215 | height: data.content.info.h, 1216 | size: data.content.info.size, 1217 | }, data); 1218 | } 1219 | if (data.type === 'm.sticker') { 1220 | logger.info("sticker upload from client"); 1221 | 1222 | let url = this.puppet.getClient().mxcUrlToHttp(data.content.url); 1223 | return await this.sendStickerMessageAsPuppetToThirdPartyRoomWithId(thirdPartyRoomId, { 1224 | url, text: msg, 1225 | mimetype: data.content.info.mimetype, 1226 | size: data.content.info.size, 1227 | filename: data.content.filename || body || '', 1228 | }, data); 1229 | } 1230 | if (data.type === 'm.reaction') { 1231 | logger.info("reaction from riot"); 1232 | 1233 | return await this.sendReactionAsPuppetToThirdPartyRoomWithId(thirdPartyRoomId, data); 1234 | } 1235 | if (msgtype === 'm.audio') { 1236 | logger.info("audio file from riot"); 1237 | 1238 | let url = this.puppet.getClient().mxcUrlToHttp(data.content.url); 1239 | return await this.sendAudioAsPuppetToThirdPartyRoomWithId(thirdPartyRoomId, { 1240 | url, text: msg, 1241 | mimetype: data.content.info.mimetype, 1242 | size: data.content.info.size, 1243 | filename: body || '', 1244 | }, data); 1245 | } 1246 | if (msgtype === 'm.video') { 1247 | logger.info("video file from riot"); 1248 | 1249 | let url = this.puppet.getClient().mxcUrlToHttp(data.content.url); 1250 | return await this.sendVideoAsPuppetToThirdPartyRoomWithId(thirdPartyRoomId, { 1251 | url, text: msg, 1252 | mimetype: data.content.info.mimetype, 1253 | size: data.content.info.size, 1254 | height: data.content.info.h, 1255 | width: data.content.info.w, 1256 | filename: body || '', 1257 | }, data); 1258 | } 1259 | if (msgtype === 'm.file') { 1260 | logger.info("file upload from riot"); 1261 | 1262 | let url = this.puppet.getClient().mxcUrlToHttp(data.content.url); 1263 | return await this.sendFileMessageAsPuppetToThirdPartyRoomWithId(thirdPartyRoomId, { 1264 | url, text: msg, 1265 | mimetype: data.content.info.mimetype, 1266 | size: data.content.info.size, 1267 | filename: data.content.filename || body || '', 1268 | }, data); 1269 | } 1270 | 1271 | throw new Error('dont know how to handle this msgtype', msgtype); 1272 | } 1273 | 1274 | defaultDeduplicationTag() { 1275 | return " \ufeff"; 1276 | } 1277 | defaultDeduplicationTagPattern() { 1278 | return " \\ufeff$"; 1279 | } 1280 | tagMatrixMessage(text) { 1281 | return text+this.deduplicationTag; 1282 | } 1283 | isTaggedMatrixMessage(text) { 1284 | return this.deduplicationTagRegex.test(text); 1285 | } 1286 | /** 1287 | * Sets the ghost avatar using a regular URL 1288 | * Will check to see if an existing avatar exists, and if so, 1289 | * will check if they are the same and only replace if they differ 1290 | * 1291 | * @param {Intent} ghostIntent represents the ghost user 1292 | * @param {string} avatar a resource on the public web 1293 | * @returns {Promise} 1294 | */ 1295 | async setGhostAvatar(ghostIntent, avatar) { 1296 | const { info } = debug(this.setGhostAvatar.name); 1297 | const client = ghostIntent.getClient(); 1298 | 1299 | const text = "avatar_" + client.credentials.userId + Date.now(); 1300 | 1301 | let upload = async(buffer, opts) => { 1302 | const res = await client.uploadContent(buffer, Object.assign({ 1303 | name: text, 1304 | type: mimetype, 1305 | rawResponse: false 1306 | }, opts || {})); 1307 | return { 1308 | content_uri: res.content_uri || res, 1309 | size: buffer.length 1310 | }; 1311 | }; 1312 | 1313 | info('fetching avatar from', avatar); 1314 | let buffer, mimetype; 1315 | if(typeof avatar == "string") { 1316 | let downloadedData = await download.getBufferAndType(avatar); 1317 | buffer = downloadedData.buffer; 1318 | mimetype = downloadedData.type; 1319 | } else { 1320 | buffer = avatar.buffer; 1321 | mimetype = avatar.type; 1322 | } 1323 | 1324 | const { avatar_url } = await ghostIntent.getProfileInfo(client.credentials.userId, 'avatar_url'); 1325 | if (avatar_url) { 1326 | info('check if avatars differ'); 1327 | let url = this.homeserver.href + "_matrix/media/v1/download/" + avatar_url.slice(6); 1328 | let prev_buffer = await download.getBuffer(url); 1329 | if (Buffer.compare(buffer, prev_buffer) == 0) { //replace avatar only if they differ 1330 | info('refusing to overwrite existing avatar'); 1331 | return null; 1332 | } 1333 | } 1334 | 1335 | let res = await upload(buffer, { type: mimetype }); 1336 | const contentUri = res.content_uri; 1337 | info('uploaded avatar and got back content uri', contentUri); 1338 | return ghostIntent.setAvatarUrl(contentUri); 1339 | } 1340 | 1341 | /** 1342 | * Sets the room avatar using a regular URL 1343 | * Will check to see if an existing avatar exists, and if so, 1344 | * will check if they are the same and only replace if they differ 1345 | * 1346 | * @param {Intent} ghostIntent represents the ghost user 1347 | * @param {string} room_id id of the matrix room to set the avatar for 1348 | * @param {string} avatar a resource on the public web 1349 | * @returns {Promise} 1350 | */ 1351 | async setRoomAvatar(room_id, avatar) { 1352 | const { info } = debug(this.setRoomAvatar.name); 1353 | const botIntent = this.getIntentFromApplicationServerBot(); 1354 | const client = botIntent.getClient(); 1355 | 1356 | const text = "avatar_" + room_id + Date.now(); 1357 | 1358 | let upload = async(buffer, opts) => { 1359 | const res = await client.uploadContent(buffer, Object.assign({ 1360 | name: text, 1361 | type: mimetype, 1362 | rawResponse: false 1363 | }, opts || {})); 1364 | return { 1365 | content_uri: res.content_uri || res, 1366 | size: buffer.length 1367 | }; 1368 | }; 1369 | 1370 | info('fetching avatar from', avatar); 1371 | let buffer, mimetype; 1372 | if(typeof avatar == "string") { 1373 | buffer = await download.getBufferAndType(avatar).buffer; 1374 | mimetype = await download.getBufferAndType(avatar).type; 1375 | } else { 1376 | buffer = avatar.buffer; 1377 | mimetype = avatar.type; 1378 | } 1379 | 1380 | const roomState = await botIntent.roomState(room_id); 1381 | const avatarEvent = roomState.find( obj => obj.type == "m.room.avatar"); 1382 | if (avatarEvent) { 1383 | const avatar_url = avatarEvent.content.url; 1384 | info('check if avatars differ'); 1385 | let url = this.homeserver.href + "_matrix/media/v1/download/" + avatar_url.slice(6); 1386 | let prev_buffer = await download.getBuffer(url); 1387 | if (Buffer.compare(buffer, prev_buffer) == 0) { //replace avatar only if they differ 1388 | info('refusing to overwrite existing avatar'); 1389 | return null; 1390 | } 1391 | } 1392 | 1393 | let res = await upload(buffer, { type: mimetype }); 1394 | const contentUri = res.content_uri; 1395 | info('uploaded avatar and got back content uri', contentUri); 1396 | return botIntent.setRoomAvatar(room_id, contentUri); 1397 | } 1398 | } 1399 | 1400 | module.exports = Base; 1401 | -------------------------------------------------------------------------------- /src/debug.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug'); 2 | const appPrefix = 'matrix-puppet'; 3 | 4 | module.exports = (...filePrefix) => (...rest) => [ 5 | 'info', 'error', 'warn' 6 | ].reduce((acc, key) => Object.assign({}, acc, { 7 | [key]: debug([ 8 | appPrefix, 9 | ...filePrefix, 10 | ...rest, 11 | 'info' 12 | ].join(':')) 13 | }),{}); 14 | -------------------------------------------------------------------------------- /src/puppet.js: -------------------------------------------------------------------------------- 1 | const Promise = require('bluebird'); 2 | const matrixSdk = require("matrix-js-sdk"); 3 | const fs = require('fs'); 4 | const readFile = Promise.promisify(fs.readFile); 5 | const writeFile = Promise.promisify(fs.writeFile); 6 | const read = Promise.promisify(require('read')); 7 | const utils = require('./utils'); 8 | const whyPuppeting = 'https://github.com/kfatehi/matrix-appservice-imessage/commit/8a832051f79a94d7330be9e252eea78f76d774bc'; 9 | 10 | const readConfigFile = async(jsonFile) => { 11 | const buffer = await readFile(jsonFile); 12 | return JSON.parse(buffer); 13 | }; 14 | 15 | /** 16 | * Puppet class 17 | */ 18 | class Puppet { 19 | /** 20 | * Constructs a Puppet 21 | * 22 | * @param {string} jsonFile path to JSON config file 23 | */ 24 | constructor(jsonFile) { 25 | this.jsonFile = jsonFile; 26 | this.id = null; 27 | this.client = null; 28 | this.thirdPartyRooms = {}; 29 | this.app = null; 30 | } 31 | 32 | /** 33 | * Reads the config file, creates a matrix client, connects, and waits for sync 34 | * 35 | * @returns {Promise} Returns a promise resolving the MatrixClient 36 | */ 37 | async startClient() { 38 | const config = await readConfigFile(this.jsonFile); 39 | this.id = config.puppet.id; 40 | this.client = matrixSdk.createClient({ 41 | baseUrl: config.bridge.homeserverUrl, 42 | userId: config.puppet.id, 43 | accessToken: config.puppet.token 44 | }); 45 | this.client.startClient(); 46 | 47 | this.matrixRoomMembers = {}; 48 | 49 | this.client.on("RoomState.members", (event, state, _member) => { 50 | this.matrixRoomMembers[state.roomId] = Object.keys(state.members); 51 | }); 52 | 53 | this.client.on("Room.receipt", (event, room) => { 54 | if (this.app === null) { 55 | return; 56 | } 57 | 58 | if (room.roomId in this.thirdPartyRooms) { 59 | let content = event.getContent(); 60 | for (var eventId in content) { 61 | for (var userId in content[eventId]['m.read']) { 62 | if (userId === this.id) { 63 | console.log("Receive a read event from ourself"); 64 | return this.app.sendReadReceiptAsPuppetToThirdPartyRoomWithId(this.thirdPartyRooms[room.roomId]); 65 | } 66 | } 67 | } 68 | } 69 | }); 70 | 71 | this.client.on("RoomMember.typing", (event, member) => { 72 | if (this.app === null) { 73 | return; 74 | } 75 | 76 | if (member.roomId in this.thirdPartyRooms) { 77 | if (member.userId === this.id) { 78 | console.log("Receive a typing event from ourself"); 79 | return this.app.sendTypingEventAsPuppetToThirdPartyRoomWithId(this.thirdPartyRooms[member.roomId],member.typing); 80 | } 81 | } 82 | }); 83 | 84 | let isSynced = false; 85 | this.client.on('sync', (state) => { 86 | if ( state === 'PREPARED' ) { 87 | console.log('synced'); 88 | isSynced = true; 89 | } 90 | }); 91 | 92 | await utils.until(() => !isSynced); 93 | } 94 | 95 | /** 96 | * Get the list of matrix room members 97 | * 98 | * @param {string} roomId matrix room id 99 | * @returns {Array} List of room members 100 | */ 101 | getMatrixRoomMembers(roomId) { 102 | return this.matrixRoomMembers[roomId] || []; 103 | } 104 | 105 | /** 106 | * Returns the MatrixClient 107 | * 108 | * @returns {MatrixClient} an instance of MatrixClient 109 | */ 110 | getClient() { 111 | return this.client; 112 | } 113 | 114 | /** 115 | * Prompts user for credentials and updates the puppet section of the config 116 | * 117 | * @returns {Promise} 118 | */ 119 | async associate() { 120 | const config = await readConfigFile(this.jsonFile); 121 | console.log([ 122 | 'This bridge performs matrix user puppeting.', 123 | 'This means that the bridge logs in as your user and acts on your behalf', 124 | 'For the rationale, see '+whyPuppeting 125 | ].join('\n')); 126 | console.log("Enter your user's localpart"); 127 | const localpart = await read({ silent: false }); 128 | let id = '@'+localpart+':'+config.bridge.domain; 129 | console.log("Enter password for "+id); 130 | const password = await read({ silent: true, replace: '*' }); 131 | let matrixClient = matrixSdk.createClient(config.bridge.homeserverUrl); 132 | const accessDat = await matrixClient.loginWithPassword(id, password); 133 | console.log("log in success"); 134 | await writeFile(this.jsonFile, JSON.stringify(Object.assign({}, config, { 135 | puppet: { 136 | id, 137 | localpart, 138 | token: accessDat.access_token 139 | } 140 | }), null, 2)); 141 | console.log('Updated config file '+this.jsonFile); 142 | } 143 | 144 | /** 145 | * Save a third party room id 146 | * 147 | * @param {string} matrixRoomId matrix room id 148 | * @param {string} thirdPartyRoomId third party room id 149 | */ 150 | saveThirdPartyRoomId(matrixRoomId, thirdPartyRoomId) { 151 | this.thirdPartyRooms[matrixRoomId] = thirdPartyRoomId; 152 | } 153 | 154 | /** 155 | * Set the App object 156 | * 157 | * @param {MatrixPuppetBridgeBase} app the App object 158 | */ 159 | setApp(app) { 160 | this.app = app; 161 | } 162 | } 163 | 164 | module.exports = Puppet; 165 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const debug = require('./debug')('utils'); 2 | const Promise = require('bluebird'); 3 | const concatStream = require('concat-stream'); 4 | const needle = require('needle'); 5 | const mime = require('mime-types'); 6 | const urlParse = require('url').parse; 7 | const fs = require('fs'); 8 | const tmp = require('tmp'); 9 | 10 | const downloadGetStream = url => needle.get(url, {follow: 10}); 11 | 12 | const downloadGetBuffer = url => { 13 | return new Promise((resolve, reject) => { 14 | downloadGetStream(url).pipe(concatStream(resolve)).on('error', reject); 15 | }); 16 | }; 17 | 18 | const downloadGetBufferAndHeaders = url => { 19 | return new Promise((resolve, reject) => { 20 | let headers = { 21 | 'content-type': 'application/octet-stream' 22 | }; 23 | let stream = downloadGetStream(url); 24 | stream.on('header', (_s, _h) => headers = _h); 25 | stream.pipe(concatStream((buffer)=>{ 26 | resolve({ buffer, headers }); 27 | })).on('error', reject); 28 | }); 29 | }; 30 | 31 | const downloadGetBufferAndType = async(url) => { 32 | const { buffer, headers } = await downloadGetBufferAndHeaders(url); 33 | let type, contentType = headers['content-type']; 34 | if ( contentType ) { 35 | type = contentType; 36 | } else { 37 | type = mime.lookup(urlParse(url).pathname); 38 | } 39 | type = type.split(';')[0]; 40 | return { buffer, type }; 41 | }; 42 | 43 | const FILENAME_TAG = '_mx_'; // goes right before file extension 44 | const FILENAME_TAG_PATTERN = /^.+_mx_\..+$/; // check if tag is right before file extension 45 | 46 | const downloadGetTempfile = async(url, opts={}) => { 47 | let tag = opts.tagFilename ? FILENAME_TAG : ''; 48 | const { buffer, type } = await downloadGetBufferAndType(url); 49 | const ext = mime.extension(type); 50 | const tmpfile = tmp.fileSync({ postfix: tag+'.'+ext }); 51 | fs.writeFileSync(tmpfile.name, buffer); 52 | return { path: tmpfile.name, remove: tmpfile.removeCallback }; 53 | }; 54 | 55 | const isFilenameTagged = (filepath) => !!filepath.match(FILENAME_TAG_PATTERN); 56 | 57 | 58 | const autoTagger = (senderId, self) => (text='') => { 59 | let out; 60 | if (senderId === undefined) { 61 | // tag the message to know it was sent by the bridge 62 | out = self.tagMatrixMessage(text); 63 | } else { 64 | out = text; 65 | } 66 | return out; 67 | }; 68 | 69 | const sleep = (time) => { 70 | return new Promise((res, rej) => { 71 | setTimeout(() => { 72 | res(); 73 | }, time); 74 | }); 75 | }; 76 | 77 | const until = async(check) => { 78 | while (check()) { 79 | await sleep(100); 80 | } 81 | }; 82 | 83 | module.exports = { 84 | download: { 85 | getStream: downloadGetStream, 86 | getBuffer: downloadGetBuffer, 87 | getBufferAndHeaders: downloadGetBufferAndHeaders, 88 | getBufferAndType: downloadGetBufferAndType, 89 | getTempfile: downloadGetTempfile, 90 | }, 91 | autoTagger, 92 | isFilenameTagged, 93 | sleep, 94 | until, 95 | }; 96 | 97 | if (!module.parent) { 98 | module.exports.download.getBufferAndType('https://lh4.googleusercontent.com/--SWFkg5vRpY/AAAAAAAAAAI/AAAAAAAADIU/gGtIbKdVV4c/photo.jpg') 99 | .then(({buffer, type})=>{ 100 | console.log(buffer.length, type); 101 | }); 102 | } 103 | --------------------------------------------------------------------------------