├── .gitignore ├── config └── telegram-config-schema.yaml ├── package.json ├── hash-password.pl ├── index.js ├── README.md ├── lib ├── MatrixIdTemplate.js ├── TelegramUser.js ├── AdminCommand.js ├── MatrixUser.js ├── AdminCommands.js ├── Portal.js ├── Main.js └── TelegramGhost.js └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | node_modules/ 3 | *-registration.yaml 4 | *-store.db 5 | *-config.yaml 6 | -------------------------------------------------------------------------------- /config/telegram-config-schema.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | requires: ["matrix_homeserver", "matrix_user_domain", "username_template", "auth_key_password"] 3 | properties: 4 | matrix_homeserver: 5 | type: string 6 | matrix_user_domain: 7 | type: string 8 | username_template: 9 | type: string 10 | matrix_admin_room: 11 | type: string 12 | admin_console_needs_pling: 13 | type: boolean 14 | auth_key_password: 15 | type: string 16 | enable_metrics: 17 | type: boolean 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matrix-appservice-tg", 3 | "version": "0.0.0", 4 | "description": "Matrix<->Telegram Bridge Application Service", 5 | "main": "index.js", 6 | "dependencies": { 7 | "bluebird": "^3.1.1", 8 | "matrix-appservice-bridge": "matrix-org/matrix-appservice-bridge#5c9b204", 9 | "matrix-js-sdk": "^0.5", 10 | "minimist": "^1.2", 11 | "request-promise": "^3.0.0", 12 | "telegram-mtproto": "3.0.6" 13 | }, 14 | "devDependencies": {}, 15 | "scripts": { 16 | "test": "echo \"Error: no test specified\" && exit 1" 17 | }, 18 | "author": "New Vector Ltd.", 19 | "license": "Apache-2.0", 20 | } 21 | -------------------------------------------------------------------------------- /hash-password.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | # Copyright 2017 Vector Creations Ltd 4 | # Copyright 2017, 2018 New Vector Ltd 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | 18 | use strict; 19 | use warnings; 20 | 21 | use IO::Termios; 22 | use Digest::SHA qw( sha256 ); 23 | 24 | my $STDIN = IO::Termios->new( \*STDIN ); 25 | 26 | my $salt = shift @ARGV; 27 | $salt = pack "H*", $salt; 28 | 29 | my $password = do { 30 | $STDIN->setflag_echo( 0 ); 31 | print "Password: "; 32 | STDOUT->autoflush(1); 33 | my $tmp = <$STDIN>; chomp $tmp; 34 | $STDIN->setflag_echo( 1 ); 35 | print "\n"; 36 | $tmp; 37 | }; 38 | 39 | print "Hash: " . unpack( "H*", sha256( $salt . $password . $salt ) ) . "\n"; 40 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Vector Creations Ltd 3 | Copyright 2017, 2018 New Vector Ltd 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | 18 | 19 | var Cli = require("matrix-appservice-bridge").Cli; 20 | var AppServiceRegistration = require("matrix-appservice-bridge").AppServiceRegistration; 21 | 22 | var Main = require("./lib/Main"); 23 | 24 | // TODO(paul) Workaround for prom-client 9 no longer doing this by default 25 | // see also https://github.com/matrix-org/matrix-appservice-bridge/pull/58 26 | require("prom-client").collectDefaultMetrics(); 27 | 28 | new Cli({ 29 | registrationPath: "telegram-registration.yaml", 30 | bridgeConfig: { 31 | schema: "config/telegram-config-schema.yaml", 32 | }, 33 | generateRegistration: function(reg, callback) { 34 | reg.setHomeserverToken(AppServiceRegistration.generateToken()); 35 | reg.setAppServiceToken(AppServiceRegistration.generateToken()); 36 | reg.setSenderLocalpart("telegrambot"); 37 | reg.addRegexPattern("users", "@telegram_.*", true); 38 | // reg.addRegexPattern("aliases", "#telegram_.*", true); 39 | reg.setId("telegram"); 40 | callback(reg); 41 | }, 42 | run: function(port, config) { 43 | console.log("Matrix-side listening on port %s", port); 44 | (new Main(config)).run(port); 45 | }, 46 | }).run(); 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # THIS PROJECT IS ON HOLD 2 | 3 | This project is not actively developed, maintained or supported at the moment. It is not recommended for production use. 4 | 5 | For a working Telegram bridge, please check out https://github.com/tulir/mautrix-telegram 6 | 7 | 8 | # ARCHIVED README 9 | 10 | Installation 11 | ------------ 12 | 13 | ```sh 14 | $ git clone ... 15 | $ cd matrix-appservice-tg 16 | $ npm install 17 | ``` 18 | 19 | 20 | Setup 21 | ----- 22 | 23 | 1. Create a new Matrix room to act as the administration control room. Note 24 | its internal room ID. 25 | 26 | 1. Create a `telegram-config.yaml` file for global configuration. There is a 27 | sample one to begin with in `config/telegram-config-sample.yaml` you may 28 | wish to copy and edit as appropriate. This needs the following keys: 29 | 30 | ```yaml 31 | matrix_homeserver: "http URL pointing at the homeserver" 32 | 33 | matrix_user_domain: "domain part of the homeserver's name. Used for 34 | ghost username generation" 35 | 36 | username_template: "template for virtual users, e.g. telegram_${USER}" 37 | 38 | matrix_admin_room: "the ID of the room created in step 2" 39 | 40 | auth_key_password: "a random string used to obfuscate authentication keys 41 | stored in the user database" 42 | ``` 43 | 44 | 1. Pick/decide on a spare local TCP port number to run the application service 45 | on. This needs to be visible to the homeserver - take care to configure 46 | firewalls correctly if that is on another machine to the bridge. The port 47 | number will be noted as `$PORT` in the remaining instructions. 48 | 49 | 1. Generate the appservice registration file (if the application service runs 50 | on the same server you can use localhost as `$URL`): 51 | 52 | ```sh 53 | $ node index.js --generate-registration -f telegram-registration.yaml -u $URL:$PORT 54 | ``` 55 | 56 | 1. Start the actual application service. You can use forever 57 | 58 | ```sh 59 | $ forever start index.js --config telegram-config.yaml --port $PORT 60 | ``` 61 | 62 | or node 63 | 64 | ```sh 65 | $ node index.js --config telegram-config.yaml --port $PORT 66 | ``` 67 | 68 | 1. Copy the newly-generated `telegram-registration.yaml` file to the homeserver. 69 | Add the registration file to your homeserver config (default `homeserver.yaml`): 70 | 71 | ```yaml 72 | app_service_config_files: 73 | - ... 74 | - "/path/to/telegram-registration.yaml" 75 | ``` 76 | 77 | Don't forget - it has to be a YAML list of strings, not just a single string. 78 | 79 | Restart your homeserver to have it reread the config file and establish a 80 | connection to the bridge. 81 | 82 | 1. Invite the newly-created `@telegrambot:DOMAIN` user into the admin control 83 | room created at step 1. 84 | 85 | The bridge should now be running. 86 | 87 | -------------------------------------------------------------------------------- /lib/MatrixIdTemplate.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Vector Creations Ltd 3 | Copyright 2017, 2018 New Vector Ltd 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | 18 | "use strict"; 19 | 20 | function findFields(str) { 21 | var fields = []; 22 | 23 | // Scan the template looking for all the field names 24 | var re = /\${([^}]+)}/g; 25 | var result; 26 | while ((result = re.exec(str)) != null) { 27 | var field = result[1]; 28 | 29 | if (fields.indexOf(field) !== -1) { 30 | throw new Error("Template field " + field + " appears multiple times"); 31 | } 32 | fields.push(field); 33 | } 34 | 35 | return fields; 36 | } 37 | 38 | function escapeRegExp(string) { 39 | // https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions 40 | return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 41 | } 42 | 43 | /** 44 | * Constructs a new MatrixIdTemplate that will parse and match ID strings of 45 | * the given template on the given homeserver domain 46 | * @constructor 47 | * @param {string} sigil The sigil character to prefix on full IDs. Usually "@" 48 | * for user IDs, or "#" for room aliases. 49 | * @param {string} str The localpart template string. This should contain 50 | * embedded variables in the form `${NAME}`. 51 | * @param {string} domain The homeserver domain name, for constructing or 52 | * matching full ID forms. 53 | */ 54 | function MatrixIdTemplate(sigil, str, domain) { 55 | this._sigil = sigil; 56 | this._str = str; 57 | this._domain = domain; 58 | this._fields = findFields(str); 59 | 60 | var re = str.replace(/\${[^}]+}/g, "(.*?)"); 61 | 62 | this._localpartRe = new RegExp("^" + re + "$"); 63 | this._idRe = new RegExp( 64 | "^" + escapeRegExp(sigil) + re + ":" + escapeRegExp(domain) + "$" 65 | ); 66 | } 67 | 68 | /** 69 | * Returns true if the template uses a variable of the given name. 70 | * @return {Boolean} 71 | */ 72 | MatrixIdTemplate.prototype.hasField = function(name) { 73 | return this._fields.indexOf(name) !== -1; 74 | }; 75 | 76 | function execRe(str, re, fields) { 77 | var result = re.exec(str); 78 | if (!result) return null; 79 | 80 | var values = {}; 81 | for (var idx = 0; idx < fields.length; idx++) { 82 | values[fields[idx]] = result[idx+1]; 83 | } 84 | 85 | return values; 86 | } 87 | 88 | /** 89 | * Attempts to match a localpart string, returning fields parsed from it, or 90 | * null if it does not match. 91 | * @param {string} str The localpart string to match. 92 | * @return {Object|null} 93 | */ 94 | MatrixIdTemplate.prototype.matchLocalpart = function(str) { 95 | return execRe(str, this._localpartRe, this._fields); 96 | }; 97 | 98 | /** 99 | * Attempts to match a full ID string, returning fields parsed from it, or 100 | * null if it does not match. 101 | * @param {string} str The full ID string to match. 102 | * @return {Object|null} 103 | */ 104 | MatrixIdTemplate.prototype.matchId = function(str) { 105 | return execRe(str, this._idRe, this._fields); 106 | }; 107 | 108 | /** 109 | * Returns a localpart string constructed by expanding the template with the 110 | * given fields. 111 | * @param {object} fields The values to expand into the template variables 112 | * @return {string} 113 | */ 114 | MatrixIdTemplate.prototype.expandLocalpart = function(fields) { 115 | var str = this._str; 116 | this._fields.forEach((n) => { 117 | if (!(n in fields)) { 118 | throw new Error("A value for " + n + " was not provided"); 119 | } 120 | 121 | str = str.replace(new RegExp("\\${" + n + "}"), fields[n]); 122 | }); 123 | return str; 124 | }; 125 | 126 | /** 127 | * Returns a new full ID string constructed by expanding the template with the 128 | * given fields. 129 | * @param {object} fields The values to expand into the template variables 130 | * @return {string} 131 | */ 132 | MatrixIdTemplate.prototype.expandId = function(fields) { 133 | return this._sigil + this.expandLocalpart(fields) + ":" + this._domain; 134 | }; 135 | 136 | module.exports = MatrixIdTemplate; 137 | -------------------------------------------------------------------------------- /lib/TelegramUser.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Vector Creations Ltd 3 | Copyright 2017, 2018 New Vector Ltd 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | 18 | "use strict"; 19 | 20 | /* 21 | * Represents a user we have seen from Telegram; i.e. a real Telegram user who 22 | * likely has a Matrix-side ghost 23 | */ 24 | 25 | function TelegramUser(main, opts) { 26 | this._main = main; 27 | this._id = opts.id; 28 | 29 | if (opts.user) { 30 | this.updateFrom(opts.user); 31 | } 32 | } 33 | 34 | TelegramUser.fromEntry = function(main, entry) { 35 | if(entry.type !== "remote") { 36 | throw new Error("Can only make TelegramUser out of entry.type == 'remote'"); 37 | } 38 | 39 | var u = new TelegramUser(main, { 40 | id: entry.id, 41 | }); 42 | 43 | var data = entry.data; 44 | u._first_name = data.first_name; 45 | u._last_name = data.last_name; 46 | u._photo = data.photo; 47 | u._avatar_url = data.avatar_url; 48 | 49 | return u; 50 | }; 51 | 52 | TelegramUser.prototype.toEntry = function() { 53 | return { 54 | type: "remote", 55 | id: this._id, 56 | data: { 57 | first_name: this._first_name, 58 | last_name: this._last_name, 59 | photo: this._photo, 60 | avatar_url: this._avatar_url, 61 | }, 62 | }; 63 | }; 64 | 65 | TelegramUser.prototype.updateFrom = function(user) { 66 | var changed = false; 67 | 68 | if (this._first_name != user.first_name) changed = true; 69 | this._first_name = user.first_name; 70 | 71 | if (this._last_name != user.last_name) changed = true; 72 | this._last_name = user.last_name; 73 | 74 | return changed; 75 | }; 76 | 77 | TelegramUser.prototype.updateAvatarImageFrom = function(user, ghost) { 78 | if (!user.photo) return Promise.resolve(); 79 | 80 | var photo = user.photo.photo_big; 81 | if (this._photo && this._avatar_url && 82 | this._photo.dc_id == photo.dc_id && 83 | this._photo.volume_id == photo.volume_id && 84 | this._photo.local_id == photo.local_id) { 85 | return Promise.resolve(this._avatar_url); 86 | } 87 | 88 | return ghost.getFile(photo).then((file) => { 89 | var name = `${photo.volume_id}_${photo.local_id}.${file.extension}`; 90 | 91 | return this.uploadContent({ 92 | stream: new Buffer(file.bytes), 93 | name: name, 94 | type: file.mimetype, 95 | }); 96 | }).then((response) => { 97 | var content_uri = response.content_uri; 98 | 99 | this._avatar_url = content_uri; 100 | this._photo = { 101 | dc_id: photo.dc_id, 102 | volume_id: photo.volume_id, 103 | local_id: photo.local_id, 104 | }; 105 | 106 | return this._main.putUser(this).then( 107 | () => content_uri 108 | ); 109 | }); 110 | }; 111 | 112 | TelegramUser.prototype.getDisplayname = function() { 113 | return [this._first_name, this._last_name].filter((s) => !!s) 114 | .join(" "); 115 | }; 116 | 117 | TelegramUser.prototype._getIntent = function() { 118 | if (this._intent) return this._intent; 119 | 120 | var intent = this._main.getIntentForTelegramId(this._id); 121 | return this._intent = intent; 122 | }; 123 | 124 | TelegramUser.prototype.getMxid = function() { 125 | return this._getIntent().client.credentials.userId; 126 | }; 127 | 128 | TelegramUser.prototype.sendText = function(matrix_room_id, text) { 129 | return this._getIntent().sendText(matrix_room_id, text); 130 | }; 131 | 132 | TelegramUser.prototype.sendImage = function(matrix_room_id, opts) { 133 | return this._getIntent().sendMessage(matrix_room_id, { 134 | msgtype: "m.image", 135 | url: opts.content_uri, 136 | body: opts.name, 137 | info: opts.info, 138 | }); 139 | }; 140 | 141 | TelegramUser.prototype.sendSelfStateEvent = function(matrix_room_id, type, content) { 142 | return this._getIntent().sendStateEvent(matrix_room_id, type, this.getMxid(), content); 143 | }; 144 | 145 | TelegramUser.prototype.uploadContent = function(opts) { 146 | return this._getIntent().getClient().uploadContent({ 147 | stream: opts.stream, 148 | name: opts.name, 149 | type: opts.type, 150 | }, { 151 | rawResponse: false, 152 | }); 153 | }; 154 | 155 | module.exports = TelegramUser; 156 | -------------------------------------------------------------------------------- /lib/AdminCommand.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Vector Creations Ltd 3 | Copyright 2017, 2018 New Vector Ltd 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | 18 | "use strict"; 19 | 20 | var minimist = require("minimist"); 21 | 22 | function AdminCommand(opts) { 23 | this.desc = opts.desc; 24 | this._func = opts.func; 25 | 26 | this.optspec = {}; 27 | this.optaliases = {}; 28 | 29 | if (opts.opts) { 30 | Object.keys(opts.opts).forEach((name) => { 31 | var def = opts.opts[name]; 32 | 33 | this.optspec[name] = { 34 | desc: def.description, 35 | required: def.required || false, 36 | boolean: def.boolean || false, 37 | } 38 | 39 | if (def.aliases) { 40 | def.aliases.forEach((a) => this.optaliases[a] = name); 41 | } 42 | }); 43 | } 44 | 45 | if (opts.args) { 46 | opts.args.forEach((name) => { 47 | if (!this.optspec[name]) { 48 | throw new Error("AdminCommand does not have an option called '" + name + "'"); 49 | } 50 | 51 | this.optspec[name].required = true; 52 | }); 53 | 54 | this.argspec = opts.args; 55 | } 56 | 57 | this.string_args = Object.keys(this.optspec).filter( 58 | (n) => !this.optspec[n].boolean); 59 | this.boolean_args = Object.keys(this.optspec).filter( 60 | (n) => this.optspec[n].boolean); 61 | } 62 | 63 | AdminCommand.prototype.run = function(main, args, respond) { 64 | var opts = minimist(args, { 65 | string: this.string_args, 66 | boolean: this.boolean_args, 67 | }); 68 | 69 | args = opts._; 70 | delete opts["_"]; 71 | 72 | // Canonicalise aliases 73 | Object.keys(this.optaliases).forEach((a) => { 74 | if (a in opts) { 75 | opts[this.optaliases[a]] = opts[a]; 76 | delete opts[a]; 77 | } 78 | }); 79 | 80 | Object.keys(opts).forEach((n) => { 81 | if (n === "_") return; 82 | 83 | if (!(n in this.optspec)) { 84 | throw Error("Unrecognised argument: " + n); 85 | } 86 | }); 87 | 88 | // Parse the positional arguments first so we can complain about any 89 | // missing ones in order 90 | if (this.argspec) { 91 | // In current implementation, every positional argument is required 92 | var missing = false; 93 | 94 | this.argspec.forEach((name) => { 95 | if (opts[name] !== undefined || 96 | !args.length) { 97 | missing = true; 98 | return; 99 | } 100 | 101 | opts[name] = args.shift(); 102 | }); 103 | 104 | if (missing) { 105 | throw Error("Required arguments: " + this.argspec.join(" ")); 106 | } 107 | } 108 | 109 | var missing = []; 110 | Object.keys(this.optspec).sort().forEach((n) => { 111 | if (this.optspec[n].required && !(n in opts)) missing.push("--" + n); 112 | }); 113 | 114 | if (missing.length) { 115 | throw Error("Missing required options: " + missing.join(", ")); 116 | } 117 | 118 | return this._func(main, opts, args, respond); 119 | }; 120 | 121 | AdminCommand.makeHelpCommand = function(commands) { 122 | return new AdminCommand({ 123 | desc: "display a list of commands", 124 | func: function(main, opts, args, respond) { 125 | if (args.length == 0) { 126 | Object.keys(commands).sort().forEach(function (k) { 127 | var cmd = commands[k]; 128 | respond(k + ": " + cmd.desc); 129 | }); 130 | } 131 | else { 132 | var name = args.shift(); 133 | var cmd = commands[name]; 134 | if (!cmd) { 135 | throw Error("No such command '" + name + "'"); 136 | } 137 | 138 | respond(name + " - " + cmd.desc); 139 | var argspec = cmd.argspec || []; 140 | if(argspec.length) { 141 | respond("Arguments: " + argspec.map( 142 | (n) => "[" + n.toUpperCase() + "]" 143 | ).join(" ")); 144 | } 145 | var optspec = cmd.optspec || {}; 146 | Object.keys(optspec).sort().forEach((n) => { 147 | respond(" --" + n + ": " + optspec[n].desc); 148 | }); 149 | } 150 | }, 151 | }); 152 | }; 153 | 154 | module.exports = AdminCommand; 155 | -------------------------------------------------------------------------------- /lib/MatrixUser.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Vector Creations Ltd 3 | Copyright 2017, 2018 New Vector Ltd 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | 18 | "use strict"; 19 | 20 | var TelegramGhost = require("./TelegramGhost"); 21 | 22 | /* 23 | * Represents a user we have seen from Matrix; i.e. a real Matrix user 24 | */ 25 | 26 | function MatrixUser(main, opts) { 27 | this._main = main; 28 | 29 | this._user_id = opts.user_id; 30 | 31 | this._atime = null; // last activity time in epoch seconds 32 | 33 | // the *encrypted* auth key 34 | this._authKeyBuffer = null; 35 | 36 | this._phoneNumber = null; 37 | this._phoneCodeHash = null; 38 | 39 | this._ghost_data = {}; 40 | } 41 | 42 | MatrixUser.fromEntry = function(main, entry) { 43 | if (entry.type !== "matrix") { 44 | throw new Error("Can only make MatrixUser out of entry.type == 'matrix'"); 45 | } 46 | 47 | var u = new MatrixUser(main, { 48 | user_id: entry.id, 49 | }); 50 | 51 | u._phoneNumber = entry.data.phone_number; 52 | u._phoneCodeHash = entry.data.phone_code_hash; 53 | 54 | if (entry.data.ghost) { 55 | u._ghost_data = entry.data.ghost; 56 | // Create the ghost so it starts the actual telegram client 57 | u.getTelegramGhost(); 58 | } 59 | 60 | return u; 61 | }; 62 | 63 | MatrixUser.prototype.toEntry = function() { 64 | if (this._ghost) { 65 | this._ghost_data = this._ghost.toSubentry(); 66 | } 67 | 68 | return { 69 | type: "matrix", 70 | id: this._user_id, 71 | data: { 72 | phone_number: this._phoneNumber, 73 | phone_code_hash: this._phoneCodeHash, 74 | ghost: this._ghost_data, 75 | }, 76 | }; 77 | }; 78 | 79 | // for TelegramGhost to call, for updating the database from its sub-entry 80 | MatrixUser.prototype._updated = function() { 81 | return this._main.putUser(this); 82 | }; 83 | 84 | MatrixUser.prototype.userId = function() { 85 | return this._user_id; 86 | }; 87 | 88 | MatrixUser.prototype.getTelegramGhost = function() { 89 | // TODO(paul): maybe this ought to indirect via main? 90 | return this._ghost = this._ghost || 91 | TelegramGhost.fromSubentry(this, this._main, this._ghost_data); 92 | }; 93 | 94 | // Helper function for catching Telegram errors 95 | function _unrollError(err) { 96 | var message = err.toPrintable ? err.toPrintable() : err.toString(); 97 | 98 | console.log("Failed:", message); 99 | if (err instanceof Error) { 100 | throw err; 101 | } 102 | else { 103 | throw new Error(message); 104 | } 105 | } 106 | 107 | MatrixUser.prototype.sendCodeToTelegram = function(phone_number) { 108 | if (this._phoneNumber && phone_number !== this._phoneNumber) { 109 | throw new Error("TODO: Already have a phone number"); 110 | } 111 | 112 | return this.getTelegramGhost().sendCode(phone_number).then( 113 | (result) => { 114 | this._phoneNumber = phone_number; 115 | this._phoneCodeHash = result.phone_code_hash; 116 | 117 | return this._main.putUser(this); 118 | }, 119 | (err) => _unrollError(err) 120 | ); 121 | }; 122 | 123 | MatrixUser.prototype.signInToTelegram = function(phone_code) { 124 | var ghost = this.getTelegramGhost(); 125 | 126 | if (!this._phoneNumber) throw new Error("User does not have an associated phone number"); 127 | if (!this._phoneCodeHash) throw new Error("User does not have a pending phone code authentication"); 128 | 129 | return this.getTelegramGhost().signIn( 130 | this._phoneNumber, this._phoneCodeHash, phone_code 131 | ).then( 132 | (result) => { 133 | console.log("Signed in; result:", result); 134 | 135 | // TODO: capture auth key somehow from ghost client 136 | this._phoneCodeHash = null; 137 | 138 | // By now, the user will have an auth key 139 | return this._main.putUser(this) 140 | .then(() => result); 141 | }, 142 | (err) => _unrollError(err) 143 | ); 144 | }; 145 | 146 | MatrixUser.prototype.checkPassword = function(password_hash) { 147 | return this.getTelegramGhost().checkPassword(password_hash).then(() => { 148 | // By now, the user will have a proper user ID field 149 | return this._main.putUser(this); 150 | }); 151 | }; 152 | 153 | MatrixUser.prototype.getATime = function() { 154 | return this._atime; 155 | }; 156 | 157 | MatrixUser.prototype.bumpATime = function() { 158 | this._atime = Date.now() / 1000; 159 | }; 160 | 161 | module.exports = MatrixUser; 162 | -------------------------------------------------------------------------------- /lib/AdminCommands.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Vector Creations Ltd 3 | Copyright 2017, 2018 New Vector Ltd 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | 18 | "use strict"; 19 | 20 | var Promise = require("bluebird"); 21 | 22 | var AdminCommand = require("./AdminCommand"); 23 | 24 | var adminCommands = {}; 25 | 26 | adminCommands.help = AdminCommand.makeHelpCommand(adminCommands); 27 | 28 | adminCommands.auth_send_code = new AdminCommand({ 29 | desc: "Send an authentication code to the user's device", 30 | opts: { 31 | user_id: { 32 | description: "Matrix user ID", 33 | }, 34 | phone_number: { 35 | description: "Phone number" 36 | }, 37 | }, 38 | args: ["user_id", "phone_number"], 39 | 40 | func: function(main, opts, _, respond) { 41 | var phone_number = String(opts.phone_number); 42 | 43 | return main.getOrCreateMatrixUser(opts.user_id).then((user) => { 44 | return user.sendCodeToTelegram(phone_number); 45 | }).then(() => { 46 | respond("Code sent to user's device"); 47 | }); 48 | } 49 | }); 50 | 51 | adminCommands.auth_sign_in = new AdminCommand({ 52 | desc: "Sign in to Telegram using an authentication code", 53 | opts: { 54 | user_id: { 55 | description: "Matrix user ID", 56 | }, 57 | phone_code: { 58 | description: "Phone code sent to the user's device", 59 | }, 60 | }, 61 | args: ["user_id", "phone_code"], 62 | 63 | func: function(main, opts, _, respond) { 64 | var user; 65 | 66 | return main.getOrCreateMatrixUser(opts.user_id).then((_user) => { 67 | user = _user; 68 | 69 | return user.signInToTelegram(String(opts.phone_code)); 70 | }).then((result) => { 71 | if (result) { 72 | // 2FA required - print salt 73 | respond("2FA required: hint:" + result.hint); 74 | respond(" Salt: " + result.current_salt.toString("hex")); 75 | } 76 | else { 77 | respond("User signed in"); 78 | } 79 | }); 80 | } 81 | }); 82 | 83 | adminCommands.auth_check_password = new AdminCommand({ 84 | desc: "Complete the 2FA login process", 85 | opts: { 86 | user_id: { 87 | description: "Matrix user ID", 88 | }, 89 | password_hash: { 90 | description: "Hex encoding of SHA256 salted password hash", 91 | }, 92 | }, 93 | args: ["user_id", "password_hash"], 94 | 95 | func: function(main, opts, _, respond) { 96 | var user; 97 | 98 | return main.getOrCreateMatrixUser(opts.user_id).then((_user) => { 99 | user = _user; 100 | 101 | return user.checkPassword(new Buffer(opts.password_hash, "hex")); 102 | }).then(() => { 103 | respond("User signed in via 2FA"); 104 | }); 105 | } 106 | }); 107 | 108 | adminCommands.user_list_chats = new AdminCommand({ 109 | desc: "List available chats for a user", 110 | opts: { 111 | user_id: { 112 | description: "Matrix user ID", 113 | } 114 | }, 115 | args: ["user_id"], 116 | 117 | func: function(main, opts, _, respond) { 118 | return _getGhostClient(main, opts.user_id).then((client) => { 119 | return client("messages.getDialogs", { 120 | offset_date: 0, 121 | offset_id: 0, 122 | limit: 100, 123 | }); 124 | }).then((ret) => { 125 | var chats_by_id = {}; 126 | ret.chats.forEach((chat) => chats_by_id[chat.id] = chat); 127 | 128 | ret.dialogs.forEach((d) => { 129 | var peer = d.peer; 130 | if (peer._ !== "peerChat") return; 131 | 132 | var chat = chats_by_id[peer.chat_id]; 133 | if (chat.deactivated) return; 134 | 135 | respond(`Chat ${chat.id}: ${chat.title} (${chat.participants_count})`); 136 | }); 137 | }); 138 | } 139 | }); 140 | 141 | adminCommands.user_list_channels = new AdminCommand({ 142 | desc: "List available channels for a user", 143 | opts: { 144 | user_id: { 145 | description: "Matrix user ID", 146 | }, 147 | }, 148 | args: ["user_id"], 149 | 150 | func: function(main, opts, _, respond) { 151 | var client; 152 | 153 | return _getGhostClient(main, opts.user_id).then((_client) => { 154 | client = _client; 155 | 156 | return client("messages.getDialogs", { 157 | offset_date: 0, 158 | offset_id: 0, 159 | limit: 100, 160 | }); 161 | }).then((ret) => { 162 | var chats_by_id = {}; 163 | ret.chats.forEach((chat) => chats_by_id[chat.id] = chat); 164 | 165 | ret.dialogs.forEach((d) => { 166 | var peer = d.peer; 167 | if (peer._ !== "peerChannel") return; 168 | 169 | // Despite being called 'chats', this list also contains 170 | // channels. This is fine because their ID numbers are in 171 | // disjoint ranges. 172 | var channel = chats_by_id[peer.channel_id]; 173 | 174 | respond(`Channel ${channel.id}: ${channel.title} #${channel.access_hash}`); 175 | }); 176 | }); 177 | } 178 | }); 179 | 180 | adminCommands.user_mk_portal = new AdminCommand({ 181 | desc: "Create a new portal room for a given peer", 182 | opts: { 183 | user_id: { 184 | description: "Matrix user ID", 185 | }, 186 | peer_type: { 187 | description: "Peer type (currently only chat supported)", 188 | }, 189 | peer_id: { 190 | description: "ID of the peer", 191 | }, 192 | access_hash: { 193 | description: "Access hash for the peer", 194 | }, 195 | }, 196 | args: ["user_id", "peer_id"], 197 | 198 | func: function(main, opts, _, respond) { 199 | var user; 200 | var access_hash; 201 | 202 | var peer_type = opts.peer_type || "chat"; 203 | switch (peer_type) { 204 | case "user": 205 | case "channel": 206 | if (!opts.access_hash) throw new Error("Require an --access_hash for " + peer_type); 207 | 208 | opts.access_hash = opts.access_hash.replace(/^#/, ""); 209 | access_hash = new Buffer(opts.access_hash); 210 | /* fallthrough */ 211 | case "chat": 212 | break; 213 | 214 | default: 215 | throw new Error("Unrecognised peer type '" + peer_type); 216 | } 217 | 218 | console.log("access_hash:", access_hash); 219 | 220 | return main.getOrCreateMatrixUser(opts.user_id).then((_user) => { 221 | user = _user; 222 | return user.getTelegramGhost(); 223 | }).then((ghost) => { 224 | var peer; 225 | switch (peer_type) { 226 | case "user": 227 | throw new Error("TODO"); 228 | case "chat": 229 | peer = ghost.newChatPeer(opts.peer_id); 230 | break; 231 | case "channel": 232 | peer = ghost.newChannelPeer(opts.peer_id, access_hash); 233 | break; 234 | } 235 | return main.getOrCreatePortal(user, peer); 236 | }).then((portal) => { 237 | return portal.provisionMatrixRoom().then(() => portal); 238 | }).then((portal) => { 239 | respond("Portal room ID is " + portal.getMatrixRoomId()); 240 | }); 241 | } 242 | }); 243 | 244 | adminCommands.user_fix_portal = new AdminCommand({ 245 | desc: "Check and fix metadata for a portal room", 246 | opts: { 247 | user_id: { 248 | description: "Matrix user ID", 249 | }, 250 | room_id: { 251 | description: "Matrix room ID of an existing portal", 252 | }, 253 | }, 254 | args: ["user_id", "room_id"], 255 | 256 | func: function(main, opts, _, respond) { 257 | return Promise.all([ 258 | main.getOrCreateMatrixUser(opts.user_id), 259 | main.findPortalByMatrixId(opts.room_id), 260 | ]).spread((user, portal) => { 261 | if (!portal) throw new Error("No such portal room"); 262 | if (portal._matrix_user_id !== user.userId()) throw new Error("This portal does not belong to this user"); 263 | 264 | return portal.fixMatrixRoom(); 265 | }); 266 | } 267 | }); 268 | 269 | adminCommands.leave = new AdminCommand({ 270 | desc: "leave a (stale) matrix room", 271 | opts: { 272 | room_id: { 273 | description: "Matrix room ID of the stale room", 274 | }, 275 | }, 276 | args: ["room_id"], 277 | func: function(main, opts, _, respond) { 278 | var room_id = opts.room_id; 279 | // TODO: safety test 280 | 281 | // TODO: consider some sort of warning about the count of ghost users 282 | // to be removed if it's large... 283 | return main.listGhostUsers(room_id).then((user_ids) => { 284 | respond("Draining " + user_ids.length + " ghosts from " + room_id); 285 | 286 | return Promise.each(user_ids, (user_id) => { 287 | return main._bridge.getIntent(user_id).leave(room_id); 288 | }); 289 | }).then(() => { 290 | return main.getBotIntent().leave(room_id); 291 | }).then(() => { 292 | respond("Drained and left " + room_id); 293 | }); 294 | }, 295 | }); 296 | 297 | // These are temporary debugging / testing commands that implement a few bits 298 | // of telegram client ability 299 | 300 | function _getGhostClient(main, user_id) { 301 | return main.getOrCreateMatrixUser(user_id).then((user) => { 302 | // gutwrench 303 | return user.getTelegramGhost()._getClient(); 304 | }); 305 | } 306 | 307 | module.exports = adminCommands; 308 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /lib/Portal.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Vector Creations Ltd 3 | Copyright 2017, 2018 New Vector Ltd 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | 18 | "use strict"; 19 | 20 | var Promise = require("bluebird"); 21 | 22 | var rp = require("request-promise"); 23 | 24 | var TelegramGhost = require("./TelegramGhost"); 25 | var META_FROM_MIMETYPE = TelegramGhost.META_FROM_MIMETYPE; 26 | 27 | function Portal(main, opts) { 28 | this._main = main; 29 | 30 | this._matrix_room_id = opts.matrix_room_id; 31 | this._matrix_user_id = opts.matrix_user_id || opts.matrix_user.userId(); 32 | this._matrix_user = opts.matrix_user; 33 | this._peer = opts.peer; 34 | } 35 | 36 | Portal.fromEntry = function(main, entry) { 37 | if (entry.type !== "portal") { 38 | throw new Error("Can only make Portal out of entry.type == 'portal'"); 39 | } 40 | 41 | return new Portal(main, { 42 | matrix_room_id: entry.data.matrix_room_id, 43 | matrix_user_id: entry.data.matrix_user_id, 44 | peer: TelegramGhost.Peer.fromSubentry(entry.data.peer), 45 | }); 46 | } 47 | 48 | Portal.prototype.toEntry = function() { 49 | var key = this.getKey(); 50 | 51 | return { 52 | type: "portal", 53 | id: key, 54 | data: { 55 | matrix_user_id: this._matrix_user_id, 56 | matrix_room_id: this._matrix_room_id, 57 | peer: this._peer.toSubentry(), 58 | }, 59 | }; 60 | }; 61 | 62 | Portal.prototype.getKey = function() { 63 | return [this._matrix_user_id, this._peer.getKey()].join(" "); 64 | }; 65 | 66 | Portal.prototype.getMatrixRoomId = function() { 67 | return this._matrix_room_id; 68 | }; 69 | 70 | Portal.prototype.getMatrixUser = function() { 71 | if (this._matrix_user) return Promise.resolve(this._matrix_user); 72 | return this._main.getOrCreateMatrixUser(this._matrix_user_id).then((user) => { 73 | this._matrix_user = user; 74 | return user; 75 | }); 76 | }; 77 | 78 | Portal.prototype.getTelegramGhost = function() { 79 | return this.getMatrixUser().then((user) => user.getTelegramGhost()); 80 | }; 81 | 82 | Portal.prototype.provisionMatrixRoom = function() { 83 | // Create the room. 84 | // Invite the MatrixUser to it 85 | 86 | if (this._matrix_room_id) return Promise.resolve(); 87 | 88 | var bot = this._main.getBotIntent(); 89 | 90 | var chat_info; 91 | return this.getTelegramGhost().then((ghost) => { 92 | return ghost.getChatInfo(this._peer); 93 | }).then((_info) => { 94 | chat_info = _info; 95 | 96 | return bot.createRoom({ 97 | options: { 98 | // Don't give it an alias 99 | name: chat_info.title, 100 | visibility: "private", 101 | } 102 | }); 103 | }).then((result) => { 104 | this._matrix_room_id = result.room_id; 105 | this._main._portalsByMatrixId[this._matrix_room_id] = this; 106 | 107 | // TODO: set room avatar image 108 | 109 | return this._main.putRoom(this); 110 | }).then(() => { 111 | return this._fixParticipants(chat_info.participants, true); 112 | }).then(() => { 113 | return bot.invite(this._matrix_room_id, this._matrix_user_id); 114 | }); 115 | }; 116 | 117 | Portal.prototype.fixMatrixRoom = function() { 118 | var bot = this._main.getBotIntent(); 119 | var room_id = this._matrix_room_id; 120 | 121 | return this.getTelegramGhost().then((ghost) => { 122 | return ghost.getChatInfo(this._peer); 123 | }).then((info) => { 124 | return Promise.all([ 125 | bot.setRoomName(room_id, info.title), 126 | this._fixParticipants(info.participants), 127 | ]) 128 | }); 129 | }; 130 | 131 | function _maybe_invite(bot_intent, room_id, user_id, callback) { 132 | return callback().then( 133 | (result) => result, 134 | (err) => { 135 | if (!err.errcode || 136 | err.errcode !== "M_FORBIDDEN") throw err; 137 | 138 | // Invite then retry one more time 139 | return bot_intent.invite(room_id, user_id).then(() => { 140 | return callback(); 141 | }); 142 | } 143 | ); 144 | } 145 | 146 | Portal.prototype._fixParticipants = function(participants, invite_first) { 147 | var main = this._main; 148 | var room_id = this._matrix_room_id; 149 | 150 | var bot_intent = main.getBotIntent(); 151 | 152 | return Promise.all( 153 | participants.map((p) => { 154 | return main.getTelegramUserFor({ 155 | user: p, 156 | create: true, 157 | }).then((user) => { 158 | return this.getTelegramGhost().then((ghost) => { 159 | return user.updateAvatarImageFrom(p, ghost); 160 | }).then((avatar_url) => { 161 | return _maybe_invite(bot_intent, room_id, user.getMxid(), () => { 162 | var content = { 163 | membership: "join", 164 | displayname: user.getDisplayname(), 165 | }; 166 | if (avatar_url) { 167 | content.avatar_url = avatar_url; 168 | } 169 | 170 | return user.sendSelfStateEvent(room_id, "m.room.member", content); 171 | }); 172 | }); 173 | }); 174 | }) 175 | ); 176 | }; 177 | 178 | Portal.prototype.onMatrixEvent = function(ev) { 179 | switch(ev.type) { 180 | case "m.room.message": 181 | var content = ev.content; 182 | 183 | return this.getTelegramGhost().then((ghost) => { 184 | switch (content.msgtype) { 185 | case "m.text": 186 | return ghost.sendMessage(this._peer, ev.content.body); 187 | 188 | case "m.image": 189 | return this._handleMatrixImage(ghost, content); 190 | } 191 | 192 | console.log(`TODO: incoming message type ${content.msgtype}`); 193 | }); 194 | 195 | default: 196 | console.log("Incoming event", ev, "to", this); 197 | break; 198 | } 199 | 200 | return Promise.resolve(); 201 | }; 202 | 203 | Portal.prototype._handleMatrixImage = function(ghost, content) { 204 | return rp({ 205 | method: "GET", 206 | uri: this._main.getUrlForMxc(content.url), 207 | resolveWithFullResponse: true, 208 | encoding: null, 209 | }).then((response) => { 210 | var extension = META_FROM_MIMETYPE[response.headers['content-type']].extension; 211 | 212 | return ghost.uploadFile(response.body, "photo." + extension).then((file) => { 213 | var inputMedia = { 214 | _: "inputMediaUploadedPhoto", 215 | file: file, 216 | caption: "", 217 | }; 218 | 219 | return ghost.sendMedia(this._peer, inputMedia); 220 | }); 221 | }); 222 | }; 223 | 224 | Portal.prototype.onTelegramUpdate = function(update, hints) { 225 | var user; 226 | 227 | var from_id = hints.from_id; 228 | 229 | switch(update._) { 230 | case "updateNewMessage": 231 | case "updateNewChannelMessage": 232 | update = update.message; 233 | /* fallthrough */ 234 | case "updateShortChatMessage": 235 | from_id = from_id || update.from_id; 236 | 237 | console.log(` | user ${from_id} sent message`); 238 | return this._main.getTelegramUserFor({user_id: from_id}).then((user) => { 239 | return this._onTelegramMessageFrom(update, user); 240 | }); 241 | 242 | case "updateChatUserTyping": 243 | console.log(` | user ${update.user_id} is typing`); 244 | // ignore for now 245 | return Promise.resolve(); 246 | 247 | case "updateReadChannelInbox": 248 | // another session read up to here 249 | return Promise.resolve(); 250 | 251 | default: 252 | console.log(`Unrecognised UPDATE ${update._}:`, update); 253 | break; 254 | } 255 | 256 | return Promise.resolve(); 257 | }; 258 | 259 | Portal.prototype._onTelegramMessageFrom = function(update, user) { 260 | var media = update.media; 261 | 262 | switch(media ? media._ : null) { 263 | case null: 264 | return user.sendText(this._matrix_room_id, update.message); 265 | 266 | case "messageMediaPhoto": 267 | // Can't currently handle a captioned image in Matrix, so 268 | // we have to upload this as two separate parts; the 269 | // image and its caption 270 | // See also https://github.com/matrix-org/matrix-doc/issues/906 271 | return this._handleTelegramPhoto(user, media).then(() => { 272 | if (update.media.caption) { 273 | return user.sendText(this._matrix_room_id, update.media.caption); 274 | } 275 | }); 276 | 277 | default: 278 | console.log(`Unrecognised UPDATE media type ${media._}`); 279 | break; 280 | } 281 | }; 282 | 283 | Portal.prototype._handleTelegramPhoto = function(user, media) { 284 | // Find the largest size 285 | var largest; 286 | media.photo.sizes.forEach((size) => { 287 | if(!largest || size.w > largest.w) largest = size; 288 | }); 289 | 290 | return this.getTelegramGhost().then((ghost) => { 291 | return ghost.getFile(largest.location); 292 | }).then((file) => { 293 | // We don't get a real filename on Telegram, but the Matrix media repo 294 | // would quite like one. 295 | var name = `${largest.location.volume_id}_${largest.location.local_id}.${file.extension}`; 296 | 297 | return user.uploadContent({ 298 | stream: new Buffer(file.bytes), 299 | name: name, 300 | type: file.mimetype, 301 | }).then((response) => { 302 | return user.sendImage(this._matrix_room_id, { 303 | content_uri: response.content_uri, 304 | name: name, 305 | info: { 306 | mimetype: file.mimetype, 307 | w: largest.w, 308 | h: largest.h, 309 | size: largest.size, 310 | }, 311 | }); 312 | }); 313 | }); 314 | }; 315 | 316 | module.exports = Portal; 317 | -------------------------------------------------------------------------------- /lib/Main.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Vector Creations Ltd 3 | Copyright 2017, 2018 New Vector Ltd 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | 18 | "use strict"; 19 | 20 | var crypto = require("crypto"); 21 | 22 | var Promise = require("bluebird"); 23 | 24 | var bridgeLib = require("matrix-appservice-bridge"); 25 | var Bridge = bridgeLib.Bridge; 26 | var Metrics = bridgeLib.PrometheusMetrics; 27 | 28 | var TelegramUser = require("./TelegramUser"); 29 | var MatrixUser = require("./MatrixUser"); // NB: this is not bridgeLib.MatrixUser ! 30 | var TelegramGhost = require("./TelegramGhost"); 31 | 32 | var Portal = require("./Portal"); 33 | 34 | var AdminCommands = require("./AdminCommands"); 35 | var MatrixIdTemplate = require("./MatrixIdTemplate"); 36 | 37 | function Main(config) { 38 | var self = this; 39 | 40 | this._config = config; 41 | 42 | var bridge = new Bridge({ 43 | homeserverUrl: config.matrix_homeserver, 44 | domain: config.matrix_user_domain, 45 | registration: "telegram-registration.yaml", 46 | controller: { 47 | onUserQuery: function(queriedUser) { 48 | return {}; // auto-provision users with no additonal data 49 | }, 50 | 51 | onEvent: (request, context) => { 52 | var ev = request.getData(); 53 | self.onMatrixEvent(ev); 54 | }, 55 | }, 56 | intentOptions: { 57 | clients: { 58 | dontJoin: true, // we handle membership state ourselves 59 | dontCheckPowerLevel: true, 60 | }, 61 | }, 62 | }); 63 | 64 | this._bridge = bridge; 65 | 66 | // map matrix user ID strings to MatrixUser or TelegramUser instances 67 | this._matrixUsersById = {}; 68 | this._telegramUsersById = {}; 69 | 70 | // map (matrix_user_id, peer_type, peer_id) triples to Portal instances 71 | this._portalsByKey = {}; 72 | // map matrix_room_id to Portal instances 73 | this._portalsByMatrixId = {}; 74 | 75 | this.username_template = new MatrixIdTemplate( 76 | "@", config.username_template, config.matrix_user_domain 77 | ); 78 | if (!this.username_template.hasField("ID")) { 79 | throw new Error("Expected the 'username_template' to contain the string ${ID}"); 80 | } 81 | 82 | if (config.enable_metrics) { 83 | this.initialiseMetrics(); 84 | } 85 | } 86 | 87 | Main.prototype.initialiseMetrics = function() { 88 | var metrics = this._metrics = this._bridge.getPrometheusMetrics(); 89 | 90 | metrics.addCounter({ 91 | name: "remote_api_calls", 92 | help: "Count of the number of remote API calls made", 93 | labels: ["method"], 94 | }); 95 | 96 | metrics.addTimer({ 97 | name: "matrix_request_seconds", 98 | help: "Histogram of processing durations of received Matrix messages", 99 | labels: ["outcome"], 100 | }); 101 | 102 | metrics.addTimer({ 103 | name: "remote_request_seconds", 104 | help: "Histogram of processing durations of received remote messages", 105 | labels: ["outcome"], 106 | }); 107 | }; 108 | 109 | Main.prototype.incRemoteCallCounter = function(type) { 110 | if (!this._metrics) return; 111 | this._metrics.incCounter("remote_api_calls", {method: type}); 112 | }; 113 | 114 | Main.prototype.startTimer = function(name, labels) { 115 | if (!this._metrics) return function() {}; 116 | return this._metrics.startTimer(name, labels); 117 | }; 118 | 119 | Main.prototype.encipherAuthKey = function(value) { 120 | // 'value' will arrive as a hex-encoded string. 121 | // Encrypt it with the private key, and at the same time export it back 122 | // in a more byte-efficient base64 encoding 123 | 124 | var cipher = crypto.createCipher("aes-256-gcm", this._config.auth_key_password); 125 | var ret = cipher.update(value, "hex", "base64"); 126 | ret += cipher.final("base64"); 127 | 128 | // GCM needs both the data buffer and the tag 129 | return [ret, cipher.getAuthTag().toString("base64")]; 130 | } 131 | 132 | Main.prototype.decipherAuthKey = function(value) { 133 | if(!value) return value; 134 | 135 | // 'value' will arrive in base64 encoding, but telegram-mtproto wants it 136 | // back in hex 137 | 138 | var decipher = crypto.createDecipher("aes-256-gcm", this._config.auth_key_password); 139 | decipher.setAuthTag(new Buffer(value[1], "base64")); 140 | var ret = decipher.update(value[0], "base64", "hex"); 141 | ret += decipher.final("hex"); 142 | 143 | return ret; 144 | }; 145 | 146 | Main.prototype.getBotIntent = function() { 147 | return this._bridge.getIntent(); 148 | }; 149 | 150 | Main.prototype.getTelegramUserFor = function(opts) { 151 | var id; 152 | if ("user_id" in opts) id = opts.user_id; 153 | else if("user" in opts) id = opts.user.id; 154 | else 155 | throw new Error("TODO: require 'user_id' or 'user' opt"); 156 | 157 | var u = this._telegramUsersById[id]; 158 | if (u) return Promise.resolve(u); 159 | 160 | return this._bridge.getUserStore().select({ 161 | type: "remote", 162 | id: id, 163 | }).then((entries) => { 164 | // in case of multiple racing database lookups, go with the first 165 | // successful result to avoid multiple objects 166 | u = this._telegramUsersById[id]; 167 | if (u) return Promise.resolve(u); 168 | 169 | if (entries.length) { 170 | var entry = entries[0]; 171 | 172 | u = this._telegramUsersById[id] = TelegramUser.fromEntry(this, entry); 173 | 174 | if (!opts.user || !u.updateFrom(opts.user)) return u; 175 | 176 | return this.putUser(u).then(() => u); 177 | } 178 | 179 | if (!opts.create) { 180 | return null; 181 | } 182 | 183 | u = this._telegramUsersById[id] = new TelegramUser(this, { 184 | id: id, 185 | user: opts.user, 186 | }); 187 | return this.putUser(u).then(() => u); 188 | }); 189 | } 190 | 191 | Main.prototype.getIntentForTelegramId = function(id) { 192 | return this._bridge.getIntentFromLocalpart( 193 | this.username_template.expandLocalpart({ID: id}) 194 | ); 195 | }; 196 | 197 | Main.prototype.getOrCreateMatrixUser = function(id) { 198 | // This is currently a synchronous method but maybe one day it won't be 199 | var u = this._matrixUsersById[id]; 200 | if (u) return Promise.resolve(u); 201 | 202 | return this._bridge.getUserStore().select({ 203 | type: "matrix", 204 | id: id, 205 | }).then((entries) => { 206 | // in case of multiple racing database lookups, go with the first 207 | // successful result to avoid multiple objects 208 | u = this._matrixUsersById[id]; 209 | if (u) return Promise.resolve(u); 210 | 211 | if (entries.length) { 212 | u = MatrixUser.fromEntry(this, entries[0]); 213 | } 214 | else { 215 | u = new MatrixUser(this, {user_id: id}); 216 | } 217 | 218 | this._matrixUsersById[id] = u; 219 | return u; 220 | }); 221 | }; 222 | 223 | Main.prototype.getOrCreatePortal = function(matrix_user, peer) { 224 | return this.getPortal(matrix_user, peer, true); 225 | }; 226 | 227 | Main.prototype.getPortal = function(matrix_user, peer, create) { 228 | var matrix_user_id = matrix_user.userId(); 229 | var key = [matrix_user_id, peer.getKey()].join(" "); 230 | 231 | // Have we got it in memory already? 232 | if (this._portalsByKey[key]) return Promise.resolve(this._portalsByKey[key]); 233 | 234 | // Maybe it's in the database? 235 | return this._bridge.getRoomStore().select({ 236 | type: "portal", 237 | id: key, 238 | }).then((entries) => { 239 | if (this._portalsByKey[key]) return this._portalsByKey[key]; 240 | 241 | if(entries.length) { 242 | var portal = Portal.fromEntry(this, entries[0]); 243 | if (portal.getMatrixRoomId()) { 244 | this._portalsByMatrixId[portal.getMatrixRoomId()] = portal; 245 | } 246 | return this._portalsByKey[key] = portal; 247 | } 248 | 249 | if (!create) return null; 250 | 251 | // Create it 252 | var portal = new Portal(this, { 253 | matrix_user: matrix_user, 254 | peer: peer, 255 | }); 256 | this._portalsByKey[key] = portal; 257 | 258 | return this.putRoom(portal) 259 | .then(() => portal); 260 | }); 261 | }; 262 | 263 | Main.prototype.findPortalByMatrixId = function(matrix_room_id) { 264 | return this._bridge.getRoomStore().select({ 265 | "data.matrix_room_id": matrix_room_id, 266 | }).then((entries) => { 267 | if (!entries.length) return Promise.resolve(); 268 | 269 | var portal = Portal.fromEntry(this, entries[0]); 270 | 271 | this._portalsByKey[portal.getKey()] = portal; 272 | this._portalsByMatrixId[portal.getMatrixRoomId()] = portal; 273 | 274 | return portal; 275 | }); 276 | }; 277 | 278 | Main.prototype.putUser = function(user) { 279 | var entry = user.toEntry(); 280 | return this._bridge.getUserStore().upsert( 281 | {type: entry.type, id: entry.id}, 282 | entry 283 | ); 284 | }; 285 | 286 | Main.prototype.putRoom = function(room) { 287 | var entry = room.toEntry(); 288 | return this._bridge.getRoomStore().upsert( 289 | {type: entry.type, id: entry.id}, 290 | entry 291 | ); 292 | }; 293 | 294 | Main.prototype.getUrlForMxc = function(mxc_url) { 295 | return this._config.matrix_homeserver + "/_matrix/media/v1/download/" + 296 | mxc_url.substring("mxc://".length); 297 | }; 298 | 299 | Main.prototype.listAllUsers = function(matrixId) { 300 | return this.getBotIntent().roomState(matrixId).then((events) => { 301 | // Filter for m.room.member with membership="join" 302 | events = events.filter( 303 | (ev) => ev.type === "m.room.member" && ev.membership === "join" 304 | ); 305 | 306 | return events.map((ev) => ev.state_key); 307 | }); 308 | }; 309 | 310 | Main.prototype.listGhostUsers = function(matrixId) { 311 | return this.listAllUsers(matrixId).then((user_ids) => { 312 | return user_ids.filter((id) => this.username_template.matchId(id)); 313 | }); 314 | }; 315 | 316 | Main.prototype.drainAndLeaveMatrixRoom = function(matrixRoomId) { 317 | return this.listGhostUsers(matrixRoomId).then((user_ids) => { 318 | console.log("Draining " + user_ids.length + " ghosts from " + matrixRoomId); 319 | 320 | return Promise.each(user_ids, (user_id) => { 321 | return this._bridge.getIntent(user_id).leave(matrixRoomId); 322 | }); 323 | }).then(() => { 324 | return this.getBotIntent().leave(matrixRoomId); 325 | }); 326 | }; 327 | 328 | Main.prototype.onMatrixEvent = function(ev) { 329 | var endTimer = this.startTimer("matrix_request_seconds"); 330 | 331 | var myUserId = this._bridge.getBot().getUserId(); 332 | 333 | if (ev.type === "m.room.member" && ev.state_key === myUserId) { 334 | // A membership event about myself 335 | var membership = ev.content.membership; 336 | if (membership === "invite") { 337 | // Automatically accept all invitations 338 | this.getBotIntent().join(ev.room_id).then( 339 | () => endTimer({outcome: "success"}), 340 | (e) => { 341 | console.log("Failed: ", e); 342 | if (e instanceof Error) { 343 | console.log(e.stack); 344 | } 345 | endTimer({outcome: "fail"}); 346 | } 347 | ); 348 | } 349 | else { 350 | // Ignore it 351 | endTimer({outcome: "success"}); 352 | } 353 | return; 354 | } 355 | 356 | if (ev.sender === myUserId || ev.type !== "m.room.message" || !ev.content) { 357 | endTimer({outcome: "success"}); 358 | return; 359 | } 360 | 361 | if (this._config.matrix_admin_room && ev.room_id === this._config.matrix_admin_room) { 362 | this.onMatrixAdminMessage(ev).then( 363 | () => endTimer({outcome: "success"}), 364 | (e) => { 365 | console.log("onMatrixAdminMessage() failed: ", e); 366 | endTimer({outcome: "fail"}); 367 | } 368 | ); 369 | return; 370 | } 371 | 372 | return this.findPortalByMatrixId(ev.room_id).then((room) => { 373 | if (!room) return Promise.resolve(false); 374 | 375 | return room.onMatrixEvent(ev).then( 376 | () => true); 377 | }).then( 378 | (handled) => endTimer({outcome: handled ? "success" : "dropped"}), 379 | (e) => { 380 | console.log("onMatrixMessage() failed: ", e); 381 | endTimer({outcome: "fail"}); 382 | } 383 | ); 384 | }; 385 | 386 | Main.prototype.onMatrixAdminMessage = function(ev) { 387 | var cmd = ev.content.body; 388 | 389 | // Ignore "# comment" lines as chatter between humans sharing the console 390 | if (cmd.match(/^\s*#/)) return Promise.resolve(); 391 | 392 | if (this._config.admin_console_needs_pling) { 393 | if (!cmd.match(/^!/)) return Promise.resolve(); 394 | cmd = cmd.replace(/^!/, ""); 395 | } 396 | 397 | console.log("Admin: " + cmd); 398 | 399 | var response = []; 400 | function respond(message) { 401 | if (!response) { 402 | console.log("Command response too late: " + message); 403 | return; 404 | } 405 | response.push(message); 406 | }; 407 | // Split the command string into optionally-quoted whitespace-separated 408 | // tokens. The quoting preserves whitespace within quoted forms 409 | // TODO(paul): see if there's a "split like a shell does" function we can use 410 | // here instead. 411 | var args = cmd.match(/(?:[^\s"]+|"[^"]*")+/g); 412 | cmd = args.shift(); 413 | 414 | var p; 415 | var c = AdminCommands[cmd]; 416 | if (c) { 417 | p = Promise.try(() => { 418 | return c.run(this, args, respond); 419 | }).catch((e) => { 420 | respond("Command failed: " + e); 421 | console.log("Command failed: " + e); 422 | if (e instanceof Error) { 423 | console.log(e.stack); 424 | } 425 | }); 426 | } 427 | else { 428 | respond("Unrecognised command: " + cmd); 429 | p = Promise.resolve(); 430 | } 431 | 432 | return p.then(() => { 433 | if (!response.length) response.push("Done"); 434 | 435 | var message = (response.length == 1) ? 436 | ev.user_id + ": " + response[0] : 437 | ev.user_id + ":\n" + response.map((s) => " " + s).join("\n"); 438 | 439 | response = null; 440 | return this.getBotIntent().sendText(ev.room_id, message); 441 | }); 442 | }; 443 | 444 | Main.prototype.onTelegramUpdate = function(matrix_user, peer, update, hints) { 445 | return this.getPortal(matrix_user, peer).then((portal) => { 446 | if (!portal) { 447 | console.log(`>> User ${matrix_user.userId()} doesn't appear to have a peer ${peer.getKey()}`); 448 | return Promise.resolve(false); 449 | } 450 | 451 | return portal.onTelegramUpdate(update, hints); 452 | }); 453 | }; 454 | 455 | Main.prototype.run = function(port) { 456 | var bridge = this._bridge; 457 | 458 | bridge.loadDatabases().then(() => { 459 | return this._bridge.getUserStore().select({ 460 | type: "matrix", 461 | }); 462 | }).then((entries) => { 463 | // Simply getting the user instance is enough to 'start' the telegram 464 | // client within it 465 | 466 | // TODO: we should rate-limit these on startup 467 | entries.forEach((entry) => { 468 | this.getOrCreateMatrixUser(entry.id); 469 | }); 470 | }); 471 | 472 | bridge.run(port, this._config); 473 | }; 474 | 475 | module.exports = Main; 476 | -------------------------------------------------------------------------------- /lib/TelegramGhost.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Vector Creations Ltd 3 | Copyright 2017, 2018 New Vector Ltd 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | 18 | "use strict"; 19 | 20 | var Promise = require("bluebird"); 21 | 22 | var MTProto = require("telegram-mtproto").MTProto; 23 | 24 | var os = require("os"); 25 | 26 | // As registered at 27 | // https://my.telegram.org/auth?to=apps 28 | var APP = { 29 | id: 57582, 30 | hash: "7e085c887f71c9f1480c3930547ac159", 31 | version: "0.0.1", 32 | langCode: "en", 33 | deviceModel: os.type().replace("Darwin", "OS_X"), 34 | systemVersion: os.platform() + "/" + os.release(), 35 | }; 36 | 37 | // Maximum wait time for HTTP long-poll 38 | var HTTP_MAXWAIT = 30 * 1000; // 30 seconds 39 | 40 | // Poll time for the updates.getState reset loop 41 | var GETSTATE_INTERVAL = 5 * 1000; // 5 seconds 42 | 43 | // Telegram has its own mimetype-like names for the things we know by MIME names 44 | var META_FROM_FILETYPE = { 45 | "storage.fileGif": { 46 | mimetype: "image/gif", 47 | extension: "gif", 48 | }, 49 | "storage.fileJpeg": { 50 | mimetype: "image/jpeg", 51 | extension: "jpeg", 52 | }, 53 | "storage.filePng": { 54 | mimetype: "image/png", 55 | extension: "png", 56 | }, 57 | }; 58 | 59 | // And the reverse 60 | var META_FROM_MIMETYPE = {}; 61 | Object.keys(META_FROM_FILETYPE).forEach((filetype) => { 62 | var meta = META_FROM_FILETYPE[filetype]; 63 | META_FROM_MIMETYPE[meta.mimetype] = meta; 64 | }); 65 | 66 | function TelegramGhost(opts) { 67 | this._main = opts.main; 68 | 69 | this._matrix_user = opts.matrix_user; 70 | 71 | this._client = null; 72 | 73 | this._user_id = opts.user_id || null; 74 | this._phoneNumber = null; 75 | this._data = opts.data || {}; 76 | 77 | var data = this._data; 78 | if (data.dc && data["dc" + data.dc + "_auth_key"]) this.start(); 79 | } 80 | 81 | TelegramGhost.fromSubentry = function(matrix_user, main, data) { 82 | var user_id = data.user_id; 83 | delete data.user_id; 84 | 85 | return new TelegramGhost({ 86 | main: main, 87 | matrix_user: matrix_user, 88 | 89 | user_id: user_id, 90 | data: data, 91 | }); 92 | }; 93 | 94 | TelegramGhost.prototype.toSubentry = function() { 95 | return Object.assign({ 96 | user_id: this._user_id, 97 | }, this._data); 98 | }; 99 | 100 | TelegramGhost.prototype.start = function() { 101 | this._getClient().then((client) => { 102 | // Cope with both stable (2.x) and unstable devel (3.x) versions of 103 | // telegram-mtproto 104 | 105 | var handleUpdate = (update) => { 106 | var endTimer = this._main.startTimer("remote_request_seconds"); 107 | 108 | var p; 109 | try { 110 | p = this._onTelegramUpdate(update); 111 | } 112 | catch (e) { 113 | console.log("Telegram update failed:", e); 114 | endTimer({outcome: "fail"}); 115 | } 116 | 117 | if(p) { 118 | p.then( 119 | (handled) => endTimer({outcome: handled ? "success" : "dropped"}), 120 | (e) => { 121 | console.log("Telegram update failed:", e); 122 | endTimer({outcome: "fail"}); 123 | } 124 | ); 125 | } 126 | }; 127 | 128 | // 2.x 129 | client.on("update", handleUpdate); 130 | 131 | // 3.x 132 | client.bus && client.bus.untypedMessage.onValue( 133 | (m) => handleUpdate(m.message)); 134 | 135 | // You have to call updates.getState() once on startup for your 136 | // session to be able to receive updates at all 137 | 138 | client("account.updateStatus", {offline: false}).then(() => { 139 | return client("updates.getState", {}); 140 | }).then((state) => { 141 | console.log("Got initial state:", state); 142 | }); 143 | 144 | console.log("STARTed"); 145 | 146 | // The current version of telegram-mtproto seems to suffer a bug 147 | // whereby new outbound API calls establish new sessions, breaking 148 | // the updates association. As a terribly terrible hack, we can 149 | // attempt to fix this by calling updates.getState regularly to keep 150 | // the update pointer here. 151 | setInterval(() => { 152 | client("updates.getState", {}).then((state) => { 153 | // TODO: check the state.pts and state.seq numbers to see if 154 | // there's more updates we need to pull 155 | }); 156 | }, GETSTATE_INTERVAL); 157 | }).catch((err) => { 158 | console.log("Failed to START -", err); 159 | }); 160 | }; 161 | 162 | TelegramGhost.prototype._getClient = function() { 163 | if (this._client) return Promise.resolve(this._client); 164 | 165 | var main = this._main; 166 | 167 | main.incRemoteCallCounter("connect"); 168 | var client = MTProto({ 169 | api: { 170 | layer: 57, 171 | api_id: APP.id, 172 | app_version: APP.version, 173 | lang_code: APP.langCode, 174 | }, 175 | server: { 176 | webogram: true, 177 | dev: false, 178 | }, 179 | app: { 180 | storage: { 181 | get: (key) => { 182 | var value = this._data[key]; 183 | if (key.match(/_auth_key$/)) { 184 | value = main.decipherAuthKey(value); 185 | } 186 | return Promise.resolve(value); 187 | }, 188 | set: (key, value) => { 189 | if (key.match(/_auth_key$/)) { 190 | value = main.encipherAuthKey(value); 191 | } 192 | 193 | if(this._data[key] === value) return Promise.resolve(); 194 | 195 | this._data[key] = value; 196 | return this._matrix_user._updated(); 197 | }, 198 | remove: (...keys) => { 199 | keys.forEach((key) => delete this._data[key]); 200 | return this._matrix_user._updated(); 201 | }, 202 | clear: () => { 203 | this._data = {}; 204 | return this._matrix_user._updated(); 205 | }, 206 | }, 207 | }, 208 | }); 209 | 210 | // client is a function. Wrap it in a little mechanism for automatically 211 | // counting calls to it 212 | 213 | var wrapped_client = (method, ...rest) => { 214 | main.incRemoteCallCounter(method); 215 | return client(method, ...rest); 216 | }; 217 | 218 | wrapped_client.on = client.on; 219 | wrapped_client.bus = client.bus; 220 | 221 | return Promise.resolve(wrapped_client); 222 | }; 223 | 224 | TelegramGhost.prototype.sendCode = function(phone_number) { 225 | var main = this._main; 226 | 227 | return this._getClient().then((client) => { 228 | console.log("> Requesting auth code"); 229 | 230 | return client("auth.sendCode", { 231 | phone_number: phone_number, 232 | current_number: true, 233 | api_id: APP.id, 234 | api_hash: APP.hash, 235 | }, { dcID: 2 }); 236 | }); 237 | }; 238 | 239 | TelegramGhost.prototype._handleAuthorization = function(authorization) { 240 | console.log("auth.signIn succeeded:", authorization); 241 | 242 | this._user_id = authorization.user.id; 243 | this.start(); 244 | 245 | return null; 246 | } 247 | 248 | TelegramGhost.prototype.signIn = function(phone_number, phone_code_hash, phone_code) { 249 | return this._getClient().then((client) => { 250 | this._phoneNumber = phone_number; 251 | 252 | return client("auth.signIn", { 253 | phone_number: phone_number, 254 | phone_code: phone_code, 255 | phone_code_hash: phone_code_hash 256 | }, { dcID: 2 }); 257 | }).then( 258 | // Login succeeded - user is not using 2FA 259 | (result) => this._handleAuthorization(result), 260 | (err) => { 261 | if (err.type !== "SESSION_PASSWORD_NEEDED") { 262 | // Something else went wrong 263 | throw err; 264 | } 265 | 266 | // User is using 2FA - more steps are required 267 | return this._getClient().then((client) => { 268 | return client("account.getPassword", {}).then((result) => { 269 | return { 270 | hint: result.hint, 271 | current_salt: Buffer.from(result.current_salt), // convert from UIint8 array 272 | }; 273 | }); 274 | }); 275 | } 276 | ); 277 | }; 278 | 279 | TelegramGhost.prototype.checkPassword = function(password_hash) { 280 | return this._getClient().then((client) => { 281 | return client("auth.checkPassword", { 282 | password_hash: password_hash 283 | }); 284 | }).then((result) => this._handleAuthorization(result)); 285 | }; 286 | 287 | TelegramGhost.prototype.sendMessage = function(peer, text) { 288 | // TODO: reliable message IDs 289 | 290 | return this._getClient().then((client) => { 291 | return client("messages.sendMessage", { 292 | peer: peer.toInputPeer(), 293 | message: text, 294 | random_id: Math.random() * (1<<30), 295 | /* 296 | null, null, null 297 | */ 298 | }); 299 | }).then((result) => { 300 | // TODO: store result.id somewhere 301 | }); 302 | }; 303 | 304 | TelegramGhost.prototype.sendMedia = function(peer, media) { 305 | // TODO: reliable message IDs 306 | 307 | return this._getClient().then((client) => { 308 | return client("messages.sendMedia", { 309 | peer: peer.toInputPeer(), 310 | media: media, 311 | random_id: Math.random() * (1<<30), 312 | }); 313 | }).then((result) => { 314 | // TODO: store result.id somewhere 315 | }); 316 | }; 317 | 318 | TelegramGhost.prototype.getFile = function(location) { 319 | return this._getClient().then((client) => { 320 | return client("upload.getFile", { 321 | location: { 322 | // Convert a 'fileLocation' into an 'inputFileLocation' 323 | // Telegram why do you make me do this?? 324 | _: "inputFileLocation", 325 | volume_id: location.volume_id, 326 | local_id: location.local_id, 327 | secret: location.secret, 328 | }, 329 | offset: 0, 330 | limit: 100*1024*1024, 331 | }); 332 | }).then((file) => { 333 | // Annotate the extra metadata 334 | var meta = META_FROM_FILETYPE[file.type._]; 335 | if (meta) { 336 | file.mimetype = meta.mimetype; 337 | file.extension = meta.extension; 338 | } 339 | return file; 340 | }); 341 | }; 342 | 343 | TelegramGhost.prototype.uploadFile = function(bytes, name) { 344 | // TODO: For now I'm going to presume all files are small enough to 345 | // upload in a single part 346 | 347 | var id = Math.trunc(Math.random() * (1<<62)); 348 | 349 | return this._getClient().then((client) => { 350 | return client("upload.saveFilePart", { 351 | file_id: id, 352 | file_part: 0, 353 | bytes: bytes, 354 | }).then((result) => { 355 | console.log("Uploading part 0 returned", result); 356 | }); 357 | }).then(() => { 358 | return { 359 | _: "inputFile", 360 | id: id, 361 | parts: 1, 362 | name: name, 363 | md5_checksum: "", // TODO? 364 | }; 365 | }); 366 | }; 367 | 368 | TelegramGhost.prototype.getChatInfo = function(peer) { 369 | if (peer._type === "user") throw new Error("Cannot get chat info on users"); 370 | 371 | var main = this._main; 372 | 373 | return this._getClient().then((client) => { 374 | // For a chat, getFullChat really does that 375 | if (peer._type === "chat" ) { 376 | return Promise.all([ 377 | client("messages.getFullChat", { 378 | chat_id: peer._id, 379 | }), 380 | Promise.resolve(null), 381 | ]); 382 | } 383 | // For a channel, getFullChannel doesn't return participants, so we need to make two calls 384 | if (peer._type === "channel") { 385 | return Promise.all([ 386 | client("channels.getFullChannel", { 387 | channel: peer.toInputChannel(), 388 | }), 389 | client("channels.getParticipants", { 390 | channel: peer.toInputChannel(), 391 | filter: { _: "channelParticipantsRecent" }, 392 | offset: 0, 393 | limit: 1000, 394 | }), 395 | ]); 396 | } 397 | throw new Error("Impossible"); 398 | }).spread((ret, participants) => { 399 | console.log("ChannelInfo was", ret, participants); 400 | 401 | // Assemble all the useful information about the chat 402 | var users = participants ? participants.users : ret.users; 403 | var chat = ret.chats[0]; 404 | 405 | if (!participants) participants = ret.full_chat.participants; 406 | 407 | var users_by_id = {}; 408 | users.forEach((user) => users_by_id[user.id] = user); 409 | 410 | return { 411 | title: chat.title, 412 | participants: participants.participants.map((p) => users_by_id[p.user_id]), 413 | }; 414 | }); 415 | }; 416 | 417 | TelegramGhost.prototype._onTelegramUpdate = function(upd) { 418 | switch(upd._) { 419 | case "updateShort": 420 | return this._onOneUpdate(upd.update).then( 421 | () => true); 422 | 423 | case "updates": 424 | var users = upd.users; 425 | 426 | return Promise.each(upd.updates, 427 | (update) => this._onOneUpdate(update, { 428 | users: users, 429 | }) 430 | ).then(() => true); 431 | 432 | case "updateShortChatMessage": 433 | return this._onOneUpdate(upd).then( 434 | () => true); 435 | 436 | default: 437 | console.log(`TODO: unrecognised updates toplevel type ${upd._}:`, upd); 438 | break; 439 | } 440 | 441 | return Promise.resolve(false); 442 | }; 443 | 444 | TelegramGhost.prototype._onOneUpdate = function(update, hints) { 445 | hints = hints || {}; 446 | 447 | switch(update._) { 448 | // Updates about Users 449 | case "updateUserStatus": 450 | case "updateUserTyping": 451 | // Quiet these for now as they're getting really noisy 452 | //console.log(`UPDATE: user status user_id=${update.user_id} _t=${update.status._typeName}`); 453 | break; 454 | 455 | // Updates about Chats 456 | case "updateChatUserTyping": 457 | case "updateShortChatMessage": 458 | { 459 | console.log(`UPDATE about CHAT ${update.chat_id}`); 460 | var peer = this.newChatPeer(update.chat_id); 461 | 462 | hints.from_id = update.from_id; 463 | 464 | return this._main.onTelegramUpdate(this._matrix_user, peer, update, hints); 465 | } 466 | 467 | // Updates about Channels 468 | // - where the channel ID is part of the 'message' 469 | case "updateNewChannelMessage": 470 | case "updateEditChannelMessage": 471 | { 472 | console.log(`UPDATE about CHANNEL ${update.message.to_id.channel_id}`); 473 | var peer = new Peer("channel", update.message.to_id.channel_id); 474 | 475 | if (update.message.out) { 476 | // Channel messages from myself just have the "out" flag and 477 | // omit the from_id 478 | hints.from_id = this._user_id; 479 | } 480 | else { 481 | hints.from_id = update.message.from_id; 482 | } 483 | 484 | return this._main.onTelegramUpdate(this._matrix_user, peer, update, hints); 485 | } 486 | 487 | // - where the channel ID is toplevel 488 | case "updateReadChannelInbox": 489 | { 490 | console.log(`UPDATE about CHANNEL ${update.channel_id}`); 491 | var peer = new Peer("channel", update.channel_id); 492 | 493 | return this._main.onTelegramUpdate(this._matrix_user, peer, update, hints); 494 | } 495 | 496 | // Updates that could be about Users, Chats or Channels 497 | case "updateNewMessage": 498 | { 499 | console.log(`UPDATE about peer type ${update.message.to_id._}`); 500 | var peer = Peer.fromTelegramPeer(update.message.to_id); 501 | 502 | return this._main.onTelegramUpdate(this._matrix_user, peer, update, hints); 503 | } 504 | 505 | // Updates about myself 506 | case "updateReadHistoryInbox": 507 | case "updateReadHistoryOutbox": 508 | case "updateReadChannelInbox": 509 | case "updateReadChannelOutbox": 510 | // ignore it for now 511 | break; 512 | 513 | default: 514 | console.log(`TODO: unrecognised update type ${update._}:`, update); 515 | break; 516 | } 517 | 518 | return Promise.resolve(); 519 | }; 520 | 521 | // A "Peer" is a little helper object that stores a type (user|chat|channel), 522 | // its ID and the access_hash used to prove this user can talk to it 523 | 524 | TelegramGhost.prototype.newChatPeer = function(id) { 525 | return new Peer("chat", id); 526 | }; 527 | 528 | TelegramGhost.prototype.newChannelPeer = function(id, access_hash) { 529 | return new Peer("channel", id, access_hash); 530 | }; 531 | 532 | function Peer(type, id, access_hash) { 533 | this._type = type; 534 | this._id = id; 535 | this._access_hash = access_hash; 536 | } 537 | 538 | /* Convert from a `telegram-mtproto` Peer instance */ 539 | Peer.fromTelegramPeer = function(peer) { 540 | switch(peer._) { 541 | case "peerChat": 542 | return new Peer("chat", peer.chat_id); 543 | case "peerChannel": 544 | return new Peer("channel", peer.channel_id, peer.access_hash); 545 | default: 546 | throw new Error(`Unrecognised peer type ${peer._}`); 547 | } 548 | }; 549 | 550 | Peer.fromSubentry = function(entry) { 551 | var access_hash = entry.access_hash ? new Buffer(entry.access_hash) : null; 552 | 553 | return new Peer(entry.type, entry.id, access_hash); 554 | }; 555 | 556 | Peer.prototype.toSubentry = function() { 557 | return { 558 | type: this._type, 559 | id: this._id, 560 | access_hash: this._access_hash.toString(), 561 | }; 562 | }; 563 | 564 | Peer.prototype.getKey = function() { 565 | return [this._type, this._id].join(" "); 566 | }; 567 | 568 | Peer.prototype.toInputPeer = function() { 569 | switch(this._type) { 570 | case "chat": 571 | return { 572 | _: "inputPeerChat", 573 | chat_id: this._id, 574 | }; 575 | 576 | case "channel": 577 | return { 578 | _: "inputPeerChannel", 579 | channel_id: this._id, 580 | access_hash: this._access_hash, 581 | }; 582 | 583 | default: 584 | throw new Error(`Cannot .toInputPeer() a peer of type ${this._type}`); 585 | } 586 | }; 587 | 588 | Peer.prototype.toInputChannel = function() { 589 | if (this._type !== "channel") throw new Error(`Cannot .toInputChannel() a peer of type ${this._type}`); 590 | 591 | return { 592 | _: "inputChannel", 593 | channel_id: this._id, 594 | access_hash: this._access_hash, 595 | }; 596 | } 597 | 598 | module.exports = TelegramGhost; 599 | TelegramGhost.Peer = Peer; 600 | TelegramGhost.META_FROM_FILETYPE = META_FROM_FILETYPE; 601 | TelegramGhost.META_FROM_MIMETYPE = META_FROM_MIMETYPE; 602 | --------------------------------------------------------------------------------