// 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 TelegramPeer = require("./telegram-peer") const formatter = require("./formatter") /** * Portal represents a portal from a Matrix room to a Telegram chat. */ class Portal { constructor(app, roomID, peer) { this.app = app this.type = "portal" this.roomID = roomID this.peer = peer this.accessHashes = new Map() } get id() { return this.peer.id } get receiverID() { return this.peer.receiverID } static fromEntry(app, entry) { if (entry.type !== "portal") { throw new Error("MatrixUser can only be created from entry type \"portal\"") } const portal = new Portal(app, entry.data.roomID, TelegramPeer.fromSubentry(entry.data.peer)) if (portal.peer.type === "channel") { portal.accessHashes = new Map(entry.data.accessHashes) } return portal } async syncTelegramUsers(telegramPOV, users) { if (!users) { if (!await this.loadAccessHash(telegramPOV)) { return false } const data = await this.peer.getInfo(telegramPOV) users = data.users } for (const userData of users) { const user = await this.app.getTelegramUser(userData.id) await user.updateInfo(telegramPOV, userData, { updateAvatar: false }) await user.intent.join(this.roomID) } return true } async copyTelegramPhoto(telegramPOV, sender, photo) { const size = photo.sizes.slice(-1)[0] const uploaded = await this.copyTelegramFile(telegramPOV, sender, size.location, photo.id) uploaded.info.h = size.h uploaded.info.w = size.w uploaded.info.size = size.size uploaded.info.orientation = 0 return uploaded } async copyTelegramFile(telegramPOV, sender, location, id) { console.log(JSON.stringify(location, "", " ")) id = id || location.id const file = await telegramPOV.getFile(location) const uploaded = await sender.intent.getClient().uploadContent({ stream: file.buffer, name: `${id}.${file.extension}`, type: file.mimetype, }, { rawResponse: false }) uploaded.matrixtype = file.matrixtype uploaded.info = { mimetype: file.mimetype, size: location.size, } return uploaded } async updateAvatar(telegramPOV, photo) { if (!photo || !photo.location || this.peer.type === "user") { return false } photo = photo.location if (this.photo && this.avatarURL && this.photo.dc_id === photo.dc_id && this.photo.volume_id === photo.volume_id && this.photo.local_id === photo.local_id) { return false } const file = await telegramPOV.getFile(photo) const name = `${photo.volume_id}_${photo.local_id}.${file.extension}` const uploaded = await this.app.botIntent.getClient().uploadContent({ stream: file.buffer, name, type: file.mimetype, }, { rawResponse: false }) this.avatarURL = uploaded.content_uri this.photo = { dc_id: photo.dc_id, volume_id: photo.volume_id, local_id: photo.local_id, } await this.app.botIntent.setRoomAvatar(this.roomID, this.avatarURL) return true } loadAccessHash(telegramPOV) { return this.peer.loadAccessHash(this.app, telegramPOV, { portal: this }) } async handleTelegramTyping(evt) { if (!this.isMatrixRoomCreated()) { return } const typer = await this.app.getTelegramUser(evt.from) // The Intent API currently doesn't allow you to set the // typing timeout. Once it does, we should set it to ~5.5s // as Telegram resends typing notifications every 5 seconds. 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] }) // Falls through to invite everyone in initial user list 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 case "messageActionChannelCreate": // Channels don't send initial user lists 3:< 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 "messageActionChatEditPhoto": const sizes = evt.action.photo.sizes let largestSize = sizes[0] let largestSizePixels = largestSize.w * largestSize.h for (const size of sizes) { const pixels = size.w * size.h if (pixels > largestSizePixels) { largestSizePixels = pixels largestSize = size } } // TODO once permissions are synced, make the avatar change event come from the user who changed the avatar await this.updateAvatar(evt.source, largestSize) break case "messageActionChatEditTitle": this.peer.title = evt.action.title await this.save() const intent = await this.getMainIntent() await intent.setRoomName(this.roomID, this.peer.title) break default: console.log("Unhandled service message of type", evt.action._) console.log(evt.action) } } async handleTelegramMessage(evt) { if (!this.isMatrixRoomCreated()) { try { const result = await this.createMatrixRoom(evt.source, { invite: [evt.source.matrixUser.userID] }) if (!result.roomID) { return } } catch (err) { console.error("Error creating room:", err) console.error(err.stack) } } const sender = await this.app.getTelegramUser(evt.from) await sender.intent.sendTyping(this.roomID, false) if (evt.text && evt.text.length > 0) { if (evt.entities) { evt.html = formatter.telegramToMatrix(evt.text, evt.entities) sender.sendHTML(this.roomID, evt.html) } else { sender.sendText(this.roomID, evt.text) } } if (evt.photo) { const photo = await this.copyTelegramPhoto(evt.source, sender, evt.photo) photo.name = evt.caption || "Uploaded photo" sender.sendFile(this.roomID, photo) } else if (evt.document) { // TODO handle stickers better const file = await this.copyTelegramFile(evt.source, sender, evt.document) if (evt.caption) { file.name = evt.caption } else if (file.matrixtype === "m.audio") { file.name = "Uploaded audio" } else if (file.matrixtype === "m.video") { file.name = "Uploaded video" } else { file.name = "Uploaded document" } sender.sendFile(this.roomID, file) } else if (evt.geo) { sender.sendLocation(this.roomID, evt.geo) } } async handleMatrixEvent(sender, evt) { await this.loadAccessHash(sender.telegramPuppet) switch (evt.content.msgtype) { case "m.text": if (evt.content.format === "org.matrix.custom.html") { const { message, entities } = formatter.matrixToTelegram(evt.content.formatted_body) sender.telegramPuppet.sendMessage(this.peer, message, entities) } else { sender.telegramPuppet.sendMessage(this.peer, evt.content.body) } break case "m.video": case "m.audio": case "m.file": // TODO upload document break case "m.image": break case "m.geo": // TODO send location break default: console.log("Unhandled event:", evt) } } isMatrixRoomCreated() { 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) { await this.invite(invite) } return { created: false, roomID: this.roomID, } } if (this.creatingMatrixRoom) { await new Promise(resolve => setTimeout(resolve, 1000)) return { created: false, roomID: this.roomID, } } this.creatingMatrixRoom = true if (!await this.loadAccessHash(telegramPOV)) { this.creatingMatrixRoom = false throw new Error("Failed to load access hash.") } let room, info, users try { ({ info, users } = await this.peer.getInfo(telegramPOV)) if (this.peer.type === "chat") { room = await this.app.botIntent.createRoom({ options: { name: info.title, topic: info.about, visibility: "private", invite, }, }) } else if (this.peer.type === "channel") { room = await this.app.botIntent.createRoom({ options: { name: info.title, topic: info.about, visibility: info.username ? "public" : "private", room_alias_name: info.username ? this.app.config.bridge.alias_template.replace("${NAME}", info.username) : undefined, invite, }, }) } else if (this.peer.type === "user") { const user = await this.app.getTelegramUser(info.id) await user.updateInfo(telegramPOV, info, { updateAvatar: true }) room = await user.intent.createRoom({ createAsClient: true, options: { //name: user.getDisplayName(), topic: "Telegram private chat", visibility: "private", invite, }, }) } else { this.creatingMatrixRoom = false throw new Error(`Unrecognized peer type: ${this.peer.type}`) } } catch (err) { this.creatingMatrixRoom = false throw err instanceof Error ? err : new Error(err) } this.roomID = room.room_id this.creatingMatrixRoom = false this.app.portalsByRoomID.set(this.roomID, this) await this.save() if (this.peer.type !== "user") { try { await this.syncTelegramUsers(telegramPOV, users) if (info.photo && info.photo.photo_big) { await this.updateAvatar(telegramPOV, info.photo.photo_big) } } catch (err) { console.error(err) if (err instanceof Error) { console.error(err.stack) } } } return { created: true, roomID: this.roomID, } } async updateInfo(telegramPOV, dialog) { let changed = false if (this.peer.type === "channel") { if (telegramPOV && this.accessHashes.get(telegramPOV.userID) !== dialog.access_hash) { this.accessHashes.set(telegramPOV.userID, dialog.access_hash) changed = true } } else if (this.peer.type === "user") { const user = await this.app.getTelegramUser(this.peer.id) await user.updateInfo(telegramPOV, dialog) } changed = this.peer.updateInfo(dialog) || changed if (changed) { this.save() } return changed } toEntry() { return { type: this.type, id: this.id, receiverID: this.receiverID, data: { roomID: this.roomID, peer: this.peer.toSubentry(), accessHashes: this.peer.type === "channel" ? Array.from(this.accessHashes) : undefined, }, } } save() { return this.app.putRoom(this) } } module.exports = Portal