diff --git a/README.md b/README.md index 3761db0d..fd134080 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ A Telegram chat will be created once the bridge is stable enough. * [ ] Message redactions * [ ] Presence (currently always shown as online on Telegram) * [ ] Typing notifications (may not be possible) + * [ ] Membership actions (invite, kick, join, leave) + * [ ] Pinning messages * [ ] Power level * Telegram → Matrix * [x] Plaintext messages @@ -46,16 +48,17 @@ A Telegram chat will be created once the bridge is stable enough. * [x] Locations * [x] Presence * [x] Typing notifications + * [ ] Pinning messages * [ ] Message edits * [ ] Message deletions * [ ] Admin status * [x] Initial group/channel name/description + * [x] Membership actions (invite, kick, join, leave) * [ ] Group/channel name/description changes * Initiating chats * [x] Automatic portal creation for groups/channels at startup - * [ ] Automatic portal creation for groups/channels when receiving invite/message/etc + * [x] Automatic portal creation for groups/channels when receiving invite/message * [x] Private chat creation by inviting Telegram user to new room - * [ ] Inviting Telegram users to group/channel portals * [ ] Joining public channels/supergroups using room aliases * [x] Searching for Telegram users using management commands * Misc diff --git a/src/app.js b/src/app.js index 30b4e3ee..75ae7033 100644 --- a/src/app.js +++ b/src/app.js @@ -20,6 +20,7 @@ const marked = require("marked") const commands = require("./commands") const MatrixUser = require("./matrix-user") const TelegramUser = require("./telegram-user") +const TelegramPeer = require("./telegram-peer") const Portal = require("./portal") /** @@ -40,6 +41,12 @@ class MautrixTelegram { * @type {Map} */ this.matrixUsersByID = new Map() + /** + * Telegram ID -> {@link MatrixUser} cache. + * @priavte + * @type {Map} + */ + this.matrixUsersByTelegramID = new Map() /** * Telegram ID -> {@link TelegramUser} cache. * @private @@ -82,10 +89,10 @@ class MautrixTelegram { domain: config.homeserver.domain, registration: config.appservice.registration, controller: { - onUserQuery(user) { + onUserQuery(/*user*/) { return {} }, - async onEvent(request, context) { + async onEvent(request/*, context*/) { try { await self.handleMatrixEvent(request.getData()) } catch (err) { @@ -159,15 +166,31 @@ class MautrixTelegram { } /** - * Get a {@link Portal} by Telegram peer. + * Get a {@link Portal} by Telegram peer or peer ID. * * This will either get the room from the room cache or the bridge room database. * If the room is not found, a new {@link Portal} object is created. * - * @param {TelegramPeer} peer The TelegramPeer object whose portal to get. - * @returns {Portal} The Portal object. + * You may set the {@code opts.createIfNotFound} parameter to change whether or not to create the Portal + * automatically. However, if the peer is just the ID, a new room will not be created in any case. + * + * @param {TelegramPeer|number} peer The TelegramPeer object OR the ID of the peer whose portal to get. + * If only a peer ID is given, it is assumed that the peer is a chat or a + * channel. Searching for user peers requires the receiver ID, thus here it + * requires the full TelegramPeer object. + * @param {object} [opts] Additional options. + * @param {boolean} opts.createIfNotFound Whether or not to create the room if it is not found + * @returns {Portal} The Portal object. */ async getPortalByPeer(peer, { createIfNotFound = true } = {}) { + if (typeof peer === "number") { + peer = { + id: peer, + } + createIfNotFound = false + } else if (!(peer instanceof TelegramPeer)) { + throw new Error("Invalid argument: peer is not a number or a TelegramPeer.") + } let portal = this.portalsByPeerID.get(peer.id) if (portal) { return portal @@ -180,8 +203,7 @@ class MautrixTelegram { if (peer.type === "user") { query.receiverID = peer.receiverID } - const entries = await this.bridge.getRoomStore() - .select(query) + const entries = await this.bridge.getRoomStore().select(query) // Handle possible db query race conditions portal = this.portalsByPeerID.get(peer.id) @@ -223,16 +245,15 @@ class MautrixTelegram { // FIXME this is probably useless for (const [_, portalByPeer] of this.portalsByPeerID) { if (portalByPeer.roomID === id) { - this.portalsByRoomID.set(id, portal) + this.portalsByRoomID.set(id, portalByPeer) return portalByPeer } } - const entries = await this.bridge.getRoomStore() - .select({ - type: "portal", - roomID: id, - }) + const entries = await this.bridge.getRoomStore().select({ + type: "portal", + roomID: id, + }) // Handle possible db query race conditions portal = this.portalsByRoomID.get(id) @@ -262,7 +283,7 @@ class MautrixTelegram { */ async getTelegramUser(id, { createIfNotFound = true } = {}) { // TODO remove this after bugs are fixed - if (isNaN(parseInt(id))) { + if (isNaN(parseInt(id, 10))) { const err = new Error("Fatal: non-int Telegram user ID") console.error(err.stack) throw err @@ -272,11 +293,10 @@ class MautrixTelegram { return user } - const entries = await this.bridge.getUserStore() - .select({ - type: "remote", - id, - }) + const entries = await this.bridge.getUserStore().select({ + type: "remote", + id, + }) // Handle possible db query race conditions if (this.telegramUsersByID.has(id)) { @@ -294,6 +314,55 @@ class MautrixTelegram { return user } + /** + * Get a {@link MatrixUser} by Telegram user ID. + * + * This will either get the user from the user cache or the bridge user database. + * + * @param {number} id The Telegram user ID of the Matrix user to get. + * @returns {MatrixUser} The MatrixUser object. + */ + async getMatrixUserByTelegramID(id) { + console.log("Searching for Matrix user by Telegram ID", id) + let user = this.matrixUsersByTelegramID.get(id) + if (user) { + console.log("Found in cache", user.userID) + return user + } + + // Check if we have the user stored in the by- map + // FIXME this should be made useless by making sure we always add to the second map when appropriate + for (const [_, userByMXID] of this.matrixUsersByID) { + if (userByMXID.telegramUserID === id) { + console.log("Found in MXID cache", userByMXID.userID) + this.matrixUsersByTelegramID.set(id, userByMXID) + return userByMXID + } + } + + const entries = this.bridge.getUserStore().select({ + type: "matrix", + telegramID: id, + }) + + // Handle possible db query race conditions + if (this.matrixUsersByTelegramID.has(id)) { + console.log("Found in cache after race", user.userID) + return this.matrixUsersByTelegramID.get(id) + } + + if (entries.length) { + console.log("Found in db", user.userID) + user = MatrixUser.fromEntry(this, entries[0]) + } else { + console.log("Not found :(") + return undefined + } + this.matrixUsersByID.set(user.userID, user) + this.matrixUsersByTelegramID.set(id, user) + return user + } + /** * Get a {@link MatrixUser} by ID. * @@ -309,11 +378,10 @@ class MautrixTelegram { return user } - const entries = this.bridge.getUserStore() - .select({ - type: "matrix", - id, - }) + const entries = this.bridge.getUserStore().select({ + type: "matrix", + id, + }) // Handle possible db query race conditions if (this.matrixUsersByID.has(id)) { @@ -328,6 +396,9 @@ class MautrixTelegram { return undefined } this.matrixUsersByID.set(id, user) + if (user.telegramUserID) { + this.matrixUsersByID.set(user.telegramUserID, user) + } return user } @@ -385,7 +456,6 @@ class MautrixTelegram { * @param {MatrixEvent} evt The invite event. */ async handleInvite(sender, evt) { - console.log(evt) const asBotID = this.bridge.getBot().getUserId() if (evt.state_key === asBotID) { // Accept all AS bot invites. @@ -399,16 +469,17 @@ class MautrixTelegram { } return } + if (evt.sender === asBotID || evt.sender === evt.state_key) { + return + } // Check if the invited user is a Telegram user. const capture = this.usernameRegex.exec(evt.state_key) - console.log(capture) if (!capture) { return } const telegramID = +capture[1] - console.log(telegramID) if (!telegramID || isNaN(telegramID)) { return } diff --git a/src/matrix-user.js b/src/matrix-user.js index 8eda394b..1f309fee 100644 --- a/src/matrix-user.js +++ b/src/matrix-user.js @@ -32,9 +32,16 @@ class MatrixUser { this.commandStatus = undefined this.puppetData = undefined this.contacts = [] + this.chats = [] this._telegramPuppet = undefined } + get telegramUserID() { + return this._telegramPuppet + ? this._telegramPuppet.userID || undefined + : undefined + } + /** * Convert a database entry into a MatrixUser. * @@ -50,7 +57,8 @@ class MatrixUser { const user = new MatrixUser(app, entry.id) user.phoneNumber = entry.data.phoneNumber user.phoneCodeHash = entry.data.phoneCodeHash - user.contactIDs = entry.data.contactIDs + user.setContactIDs(entry.data.contactIDs) + user.setChatIDs(entry.data.chatIDs) if (entry.data.puppet) { user.puppetData = entry.data.puppet // Create the telegram puppet instance @@ -71,10 +79,12 @@ class MatrixUser { 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, }, } @@ -102,18 +112,37 @@ class MatrixUser { 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. */ - set contactIDs(list) { - // FIXME This is somewhat dangerous - setTimeout(async () => { - if (list) { - this.contacts = await Promise.all(list.map(id => this.app.getTelegramUser(id))) - } - }, 0) + 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))) } /** @@ -139,16 +168,17 @@ class MatrixUser { } /** - * Synchronize the dialogs (groups, channels) of this user. + * 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 syncDialogs({ createRooms = true } = {}) { + async syncChats({ createRooms = true } = {}) { const dialogs = await this.telegramPuppet.client("messages.getDialogs", {}) let changed = false + this.chats = [] for (const dialog of dialogs.chats) { if (dialog._ === "chatForbidden" || dialog.deactivated) { continue @@ -158,6 +188,7 @@ class MatrixUser { if (await portal.updateInfo(this.telegramPuppet, dialog)) { changed = true } + this.chats.push(portal) if (createRooms) { try { await portal.createMatrixRoom(this.telegramPuppet, { @@ -169,9 +200,25 @@ class MatrixUser { } } } + await this.save() return changed } + async join(portal) { + if (!this.chats.includes(portal.id)) { + this.chats.push(portal.id) + this.save() + } + } + + async leave(portal) { + const chatIDIndex = this.chats.indexOf(portal.id) + if (chatIDIndex > -1) { + this.chats.splice(chatIDIndex, 1) + this.save() + } + } + /** * Search for contacts of this user. * diff --git a/src/portal.js b/src/portal.js index 2a92898c..214eb040 100644 --- a/src/portal.js +++ b/src/portal.js @@ -141,6 +141,38 @@ class Portal { typer.intent.sendTyping(this.roomID, true/*, 5500*/) } + async handleTelegramServiceMessage(evt) { + let matrixUser, telegramUser + switch (evt.action._) { + case "messageActionChatCreate": + await this.createMatrixRoom(evt.source, { invite: [evt.source.matrixUser.userID] }) + break + case "messageActionChatDeleteUser": + matrixUser = await this.app.getMatrixUserByTelegramID(evt.action.user_id) + if (matrixUser) { + matrixUser.leave(this) + this.kick(matrixUser.userID, "Left Telegram chat") + } + telegramUser = await this.app.getTelegramUser(evt.action.user_id) + telegramUser.intent.leave(this.roomID) + break + case "messageActionChatAddUser": + for (const userID of evt.action.users) { + matrixUser = await this.app.getMatrixUserByTelegramID(userID) + if (matrixUser) { + matrixUser.join(this) + this.invite(matrixUser.userID) + } + telegramUser = await this.app.getTelegramUser(userID) + telegramUser.intent.join(this.roomID) + } + break + default: + console.log("Unhandled service message of type", evt.action._) + console.log(evt.action) + } + } + async handleTelegramMessage(evt) { if (!this.isMatrixRoomCreated()) { try { @@ -156,7 +188,7 @@ class Portal { const sender = await this.app.getTelegramUser(evt.from) await sender.intent.sendTyping(this.roomID, false) - if (evt.text.length > 0) { + if (evt.text && evt.text.length > 0) { if (evt.entities) { evt.html = formatter.telegramToMatrix(evt.text, evt.entities) sender.sendHTML(this.roomID, evt.html) @@ -218,16 +250,43 @@ class Portal { return !!this.roomID } + async getMainIntent() { + return this.peer.type === "user" + ? (await this.app.getTelegramUser(this.peer.id)).intent + : this.app.botIntent + } + + async invite(users) { + const intent = await this.getMainIntent() + // TODO check membership before inviting? + if (Array.isArray(users)) { + for (const userID of users) { + if (typeof userID === "string") { + intent.invite(this.roomID, userID) + } + } + } else if (typeof users === "string") { + intent.invite(this.roomID, users) + } + } + + async kick(users, reason) { + const intent = await this.getMainIntent() + if (Array.isArray(users)) { + for (const userID of users) { + if (typeof userID === "string") { + intent.kick(this.roomID, users, reason) + } + } + } else if (typeof users === "string") { + intent.kick(this.roomID, users, reason) + } + } + async createMatrixRoom(telegramPOV, { invite = [], inviteEvenIfNotCreated = true } = {}) { if (this.roomID) { if (invite && inviteEvenIfNotCreated) { - const intent = this.peer.type === "user" - ? (await this.app.getTelegramUser(this.peer.id)).intent - : this.app.botIntent - for (const userID of invite) { - // TODO check membership before inviting? - intent.invite(this.roomID, userID) - } + await this.invite(invite) } return { created: false, @@ -235,9 +294,7 @@ class Portal { } } if (this.creatingMatrixRoom) { - console.log("Ongoing room creation detected!") await new Promise(resolve => setTimeout(resolve, 1000)) - console.log("Ongoing room creation waited for,", this.roomID) return { created: false, roomID: this.roomID, diff --git a/src/telegram-puppet.js b/src/telegram-puppet.js index 4ce0c8aa..ab31a1b7 100644 --- a/src/telegram-puppet.js +++ b/src/telegram-puppet.js @@ -319,6 +319,15 @@ class TelegramPuppet { console.log(update) portal = await this.app.getPortalByPeer(to) + + if (update._ === "messageService") { + await portal.handleTelegramServiceMessage({ + from, + to, + action: update.action, + }) + return + } await portal.handleTelegramMessage({ from, to, @@ -334,8 +343,8 @@ class TelegramPuppet { geo: update.media && update.media._ === "messageMediaGeo" ? update.media.geo : undefined, - caption: update.media ? - update.media.caption + caption: update.media + ? update.media.caption : undefined, }) } @@ -391,7 +400,7 @@ class TelegramPuppet { } try { console.log("Updating dialogs...") - const changed = await this.matrixUser.syncDialogs() + const changed = await this.matrixUser.syncChats() if (!changed) { console.log("Dialogs were up-to-date") } else {