// mautrix-telegram - A Matrix-Telegram puppeting bridge // Copyright (C) 2017 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . const md5 = require("md5") const TelegramPuppet = require("./telegram-puppet") const TelegramPeer = require("./telegram-peer") const strSim = require("string-similarity") const chalk = require("chalk") /** * MatrixUser represents a Matrix user who probably wants to control their * Telegram account from Matrix. */ class MatrixUser { constructor(app, userID) { this.app = app this.userID = userID this.whitelisted = app.checkWhitelist(userID) this.phoneNumber = undefined this.phoneCodeHash = undefined this.commandStatus = undefined this.puppetData = undefined this.contacts = [] this.chats = [] this._telegramPuppet = undefined } /** * Get the user ID of the Telegram user this Matrix user controls. * * @returns {number|undefined} The Telegram user ID, or undefined if not logged in. */ get telegramUserID() { return this._telegramPuppet ? this._telegramPuppet.userID || undefined : undefined } /** * Convert a database entry into a MatrixUser. * * @param {MautrixTelegram} app The app main class instance. * @param {Object} entry The database entry. * @returns {MatrixUser} The loaded MatrixUser. */ static fromEntry(app, entry) { if (entry.type !== "matrix") { throw new Error("MatrixUser can only be created from entry type \"matrix\"") } const user = new MatrixUser(app, entry.id) user.phoneNumber = entry.data.phoneNumber user.phoneCodeHash = entry.data.phoneCodeHash user.setContactIDs(entry.data.contactIDs) user.setChatIDs(entry.data.chatIDs) if (entry.data.puppet) { user.puppetData = entry.data.puppet // Create the telegram puppet instance user.telegramPuppet } return user } /** * Convert this MatrixUser into a database entry. * * @returns {Object} A user store database entry. */ toEntry() { if (this._telegramPuppet) { this.puppetData = this._telegramPuppet.toSubentry() } return { type: "matrix", id: this.userID, telegramID: this.telegramUserID, data: { phoneNumber: this.phoneNumber, phoneCodeHash: this.phoneCodeHash, contactIDs: this.contactIDs, chatIDs: this.chatIDs, puppet: this.puppetData, }, } } /** * Get the telegram puppet this Matrix user controls. * If one doesn't exist, it'll be created based on the {@link #puppetData} field. * * @returns {TelegramPuppet} The Telegram account controller. */ get telegramPuppet() { if (!this._telegramPuppet) { this._telegramPuppet = TelegramPuppet.fromSubentry(this.app, this, this.puppetData || {}) } return this._telegramPuppet } /** * Get the IDs of all the Telegram contacts of this user. * * @returns {number[]} A list of Telegram user IDs. */ get contactIDs() { return this.contacts.map(contact => contact.id) } /** * Get the IDs of all the Telegram chats this user is in. * * @returns {number[]} A list of Telegram chat IDs. */ get chatIDs() { return this.chats.map(chat => chat.id) } /** * Update the contacts of this user based on a list of Telegram user IDs. * * @param {number[]} list The list of Telegram user IDs. */ async setContactIDs(list) { if (!list) { return } this.contacts = await Promise.all(list.map(id => this.app.getTelegramUser(id))) } /** * Update the chats of this user based on a list of Telegram chat IDs. * * @param {number[]} list The list of Telegram chat IDs. */ async setChatIDs(list) { if (!list) { return } this.chats = await Promise.all(list.map(id => this.app.getPortalByPeer(id))) } /** * Synchronize the contacts of this user. * * @returns {boolean} Whether or not anything changed. */ async syncContacts() { const contacts = await this.telegramPuppet.client("contacts.getContacts", { hash: md5(this.contactIDs.join(",")), }) if (contacts._ === "contacts.contactsNotModified") { return false } for (const [index, contact] of Object.entries(contacts.users)) { const telegramUser = await this.app.getTelegramUser(contact.id) await telegramUser.updateInfo(this.telegramPuppet, contact, true) contacts.users[index] = telegramUser } this.contacts = contacts.users await this.save() return true } /** * Synchronize the chats (groups, channels) of this user. * * @param {object} [opts] Additional options. * @param {boolean} opts.createRooms Whether or not portal rooms should be automatically created. * Defaults to {@code true} * @returns {boolean} Whether or not anything changed. */ async syncChats({ createRooms = true } = {}) { const dialogs = await this.telegramPuppet.client("messages.getDialogs", {}) let changed = false for (const user of dialogs.users) { if (!user.self) { continue } // Automatically create Saved Messages room const peer = new TelegramPeer("user", user.id, { receiverID: user.id, accessHash: user.access_hash, }) const portal = await this.app.getPortalByPeer(peer) if (createRooms) { try { await portal.createMatrixRoom(this.telegramPuppet, { invite: [this.userID], }) } catch (err) { console.error(err) console.error(err.stack) } } } this.chats = [] for (const dialog of dialogs.chats) { if (dialog._ === "chatForbidden" || dialog._ === "channelForbidden" || dialog.deactivated) { continue } const peer = new TelegramPeer(dialog._, dialog.id, { accessHash: dialog.access_hash, }) const portal = await this.app.getPortalByPeer(peer) this.chats.push(portal) if (createRooms) { if (peer.type === "channel") { portal.accessHashes.set(this.telegramPuppet.userID, dialog.access_hash) } try { await portal.createMatrixRoom(this.telegramPuppet, { invite: [this.userID], }) } catch (err) { console.error(`Failed to create a room for ${dialog._} ${dialog.id}`) console.error(err) continue } } if (await portal.updateInfo(this.telegramPuppet, dialog)) { changed = true } } await this.save() return changed } /** * Add a {@link Portal} to the chat list of this user. * * This should only be used for non-private chat portals. * * @param {Portal} portal The portal to add. */ async join(portal) { if (!this.chats.includes(portal.id)) { this.chats.push(portal.id) await this.save() } } /** * Remove a {@link Portal} from the chat list of this user. * * This should only be used for non-private chat portals. * * @param {Portal} portal The portal to remove. */ async leave(portal) { const chatIDIndex = this.chats.indexOf(portal.id) if (chatIDIndex > -1) { this.chats.splice(chatIDIndex, 1) await this.save() } } /** * Search for contacts of this user. * * @param {string} query The search query. * @param {object} [opts] Additional options. * @param {number} opts.maxResults The maximum number of results to show. * @param {number} opts.minSimilarity The minimum query similarity, below which results should be ignored. * @returns {Object[]} The search results. */ async searchContacts(query, { maxResults = 5, minSimilarity = 0.45 } = {}) { const results = [] for (const contact of this.contacts) { let displaynameSimilarity = 0 let usernameSimilarity = 0 let numberSimilarity = 0 if (contact.firstName || contact.lastName) { displaynameSimilarity = strSim.compareTwoStrings(query, contact.getFirstAndLastName()) } if (contact.username) { usernameSimilarity = strSim.compareTwoStrings(query, contact.username) } if (contact.phoneNumber) { numberSimilarity = strSim.compareTwoStrings(query, contact.phoneNumber) } const similarity = Math.max(displaynameSimilarity, usernameSimilarity, numberSimilarity) if (similarity >= minSimilarity) { results.push({ similarity, match: Math.round(similarity * 1000) / 10, contact, }) } } return results .sort((a, b) => b.similarity - a.similarity) .slice(0, maxResults) } /** * Search for non-contact Telegram users from the point of view of this user. * @param {string} query The search query. * @param {object} [opts] Additional options. * @param {number} opts.maxResults The maximum number of results to show. * @returns {Object[]} The search results. */ async searchTelegram(query, { maxResults = 5 } = {}) { const results = await this.telegramPuppet.client("contacts.search", { q: query, limit: maxResults, }) const resultUsers = [] for (const userInfo of results.users) { const user = await this.app.getTelegramUser(userInfo.id) user.updateInfo(this.telegramPuppet, userInfo) resultUsers.push(user) } return resultUsers } /** * Request a Telegarm phone code for logging in (or registering) * * @param {string} phoneNumber The phone number. * @returns {Object} The code send result as returned by {@link TelegramPuppet#sendCode()}. */ async sendTelegramCode(phoneNumber) { if (this._telegramPuppet && this._telegramPuppet.userID) { throw new Error("You are already logged in. Please log out before logging in again.") } switch (this.telegramPuppet.checkPhone(phoneNumber)) { case "unregistered": throw new Error("That number has not been registered. Please register it first.") case "invalid": throw new Error("Invalid phone number.") } const result = await this.telegramPuppet.sendCode(phoneNumber) this.phoneNumber = phoneNumber this.phoneCodeHash = result.phone_code_hash await this.save() return result } /** * Log out from Telegram. */ async logOutFromTelegram() { this.telegramPuppet.logOut() // TODO kick user from all portals this._telegramPuppet = undefined this.puppetData = undefined await this.save() } /** * Sign in to Telegram with a phone code sent using {@link #sendTelegramCode()}. * * @param {number} phoneCode The phone code. * @returns {Object} The sign in result as returned by {@link TelegramPuppet#signIn()}. */ async signInToTelegram(phoneCode) { if (!this.phoneNumber) throw new Error("Phone number not set") if (!this.phoneCodeHash) throw new Error("Phone code not sent") const result = await this.telegramPuppet.signIn(this.phoneNumber, this.phoneCodeHash, phoneCode) this.phoneCodeHash = undefined await this.save() return result } /** * Finish signing in to Telegram using the two-factor auth password. * * @param {string} password_hash The salted hash of the password. * @returns {Object} The sign in result as returned by {@link TelegramPuppet#checkPassword()} */ async checkPassword(password_hash) { const result = await this.telegramPuppet.checkPassword(password_hash) await this.save() return result } /** * Save this MatrixUser to the database. */ save() { return this.app.putUser(this) } } module.exports = MatrixUser