// 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 telegram = require("telegram-mtproto") const { nextRandomInt } = require("telegram-mtproto/lib/bin") const fileType = require("file-type") const pkg = require("../package.json") const TelegramPeer = require("./telegram-peer") const chalk = require("chalk") /** * @module telegram-puppet */ /** * Mapping from Telegram file types to MIME types and extensions. * @private */ function metaFromFileType(type) { const extension = type.substr("storage.file".length).toLowerCase() let fileClass, mimetype, matrixtype switch (type) { case "storage.fileGif": case "storage.fileJpeg": case "storage.filePng": case "storage.fileWebp": fileClass = "image" break case "storage.fileMov": mimetype = "quicktime" case "storage.fileMp4": fileClass = "video" break case "storage.fileMp3": mimetype = "mpeg" fileClass = "audio" break case "storage.filePartial": throw new Error("Partial files should be completed before fetching their type.") case "storage.fileUnknown": fileClass = "application" mimetype = "octet-stream" matrixtype = "m.file" break default: return undefined } mimetype = `${fileClass}/${mimetype || extension}` matrixtype = matrixtype || `m.${fileClass}` return { mimetype, extension, matrixtype } } /** * Mapping from MIME type to Matrix file type. Used when determining MIME type and extension using magic numbers. * * @param {string} mime The MIME type. * @returns {string} The corresponding Matrix file type. * @private */ function matrixFromMime(mime) { if (mime.startsWith("audio/")) { return "m.audio" } else if (mime.startsWith("video/")) { return "m.video" } else if (mime.startsWith("image/")) { return "m.image" } return "m.file" } /** * TelegramPuppet represents a Telegram account being controlled from Matrix. */ class TelegramPuppet { constructor(app, { userID, matrixUser, data, api_hash, api_id, server_config, api_config }) { this._client = undefined this.userID = userID this.matrixUser = matrixUser this.data = data this.app = app this.serverConfig = Object.assign({}, server_config) this.apiHash = api_hash this.apiID = api_id this.pts = 0 this.date = 0 this.lastID = 0 this.puppetStorage = { get: async (key) => { let value = this.data[key] if (typeof value === "string" && value.startsWith("b64:")) { value = Array.from(Buffer.from(value.substr("b64:".length), "base64")) } return value }, set: async (key, value) => { if (Array.isArray(value)) { value = `b64:${Buffer.from(value).toString("base64")}` } if (this.data[key] === value) { return } this.data[key] = value await this.matrixUser.save() }, remove: async (...keys) => { keys.forEach((key) => delete this.data[key]) await this.matrixUser.save() }, clear: async () => { this.data = {} await this.matrixUser.save() }, } this.apiConfig = Object.assign({}, { app_version: pkg.version, lang_code: "en", api_id, initConnection: 0x69796de9, layer: 57, invokeWithLayer: 0xda9b0d0d, }, api_config) if (this.data.dc && this.data[`dc${this.data.dc}_auth_key`]) { this.listen() } } static fromSubentry(app, matrixUser, data) { const userID = data.userID delete data.userID return new TelegramPuppet(app, Object.assign({ userID, matrixUser, data, }, app.config.telegram)) } toSubentry() { return Object.assign({ userID: this.userID, }, this.data) } get client() { if (!this._client) { this._client = telegram.MTProto({ api: this.apiConfig, server: this.serverConfig, app: { storage: this.puppetStorage }, }) } return this._client } async checkPhone(phone_number) { try { const status = this.client("auth.checkPhone", { phone_number }) if (status.phone_registered) { return "registered" } return "unregistered" } catch (err) { if (err.message === "PHONE_NUMBER_INVALID") { return "invalid" } throw err } } sendCode(phone_number) { return this.client("auth.sendCode", { phone_number, current_number: true, api_id: this.apiID, api_hash: this.apiHash, }) } logOut() { return this.client("auth.logOut") } async signIn(phone_number, phone_code_hash, phone_code) { try { const result = await this.client("auth.signIn", { phone_number, phone_code, phone_code_hash, }) return this.signInComplete(result) } catch (err) { if (err.type !== "SESSION_PASSWORD_NEEDED" && err.message !== "SESSION_PASSWORD_NEEDED") { console.error("Unknown login error:", JSON.stringify(err, "", " ")) throw err } const password = await this.client("account.getPassword", {}) return { status: "need-password", hint: password.hint, salt: password.current_salt, } } } async checkPassword(password_hash) { const result = await this.client("auth.checkPassword", { password_hash }) return this.signInComplete(result) } getDisplayName() { if (this.data.firstName || this.data.lastName) { return [this.data.firstName, this.data.lastName].filter(s => !!s).join(" ") } else if (this.data.username) { return this.data.username } return this.data.phone_number } signInComplete(data) { this.userID = data.user.id this.data.username = data.user.username this.data.firstName = data.user.first_name this.data.lastName = data.user.last_name this.data.phoneNumber = data.user.phone_number this.matrixUser.save() this.listen() return { status: "ok", } } async sendMessage(peer, message, entities) { if (!message) { throw new Error("Invalid parameter: message is undefined.") } const payload = { peer: peer.toInputPeer(), message, entities, random_id: [nextRandomInt(0xFFFFFFFF), nextRandomInt(0xFFFFFFFF)], } if (!payload.entities) { // Everything breaks if we send undefined things :/ delete payload.entities } const result = await this.client("messages.sendMessage", payload) return result } async sendMedia(peer, media) { if (!media) { throw new Error("Invalid parameter: media is undefined.") } const result = await this.client("messages.sendMedia", { peer: peer.toInputPeer(), media, random_id: [nextRandomInt(0xFFFFFFFF), nextRandomInt(0xFFFFFFFF)], }) // TODO use result? (maybe the ID) return result } async onUpdate(update) { if (!update) { this.app.error("Oh noes! Empty update") return } let to, from, portal switch (update._) { // Telegram user status handling. case "updateUserStatus": const user = await this.app.getTelegramUser(update.user_id) const presence = update.status._ === "userStatusOnline" ? "online" : "offline" await user.intent.getClient().setPresence({ presence }) return // // Telegram typing event handling // case "updateUserTyping": to = new TelegramPeer("user", update.user_id, { receiverID: this.userID }) /* falls through */ case "updateChatUserTyping": to = to || new TelegramPeer("chat", update.chat_id) portal = await this.app.getPortalByPeer(to) await portal.handleTelegramTyping({ from: update.user_id, to, source: this, }) return // // Telegram message handling/parsing. // The actual handling happens after the switch. // case "updateShortMessage": to = new TelegramPeer("user", update.user_id, { receiverID: this.userID }) from = update.out ? this.userID : update.user_id break case "updateShortChatMessage": to = new TelegramPeer("chat", update.chat_id) from = update.from_id break case "updateNewChannelMessage": // TODO use message.post_author from = -1 case "updateNewMessage": this.pts = update.pts update = update.message // Message defined at message#90dddc11 in layer 71 from = update.from_id || from to = TelegramPeer.fromTelegramData(update.to_id, update.from_id, this.userID) break case "updateReadMessages": case "updateReadHistoryOutbox": case "updateReadHistoryInbox": case "updateDeleteMessages": case "updateRestoreMessages": // TODO we probably want to handle those five updates properly this.pts = update.pts return default: // Unknown update type this.app.warn(`Update of unknown type ${update._} received: ${JSON.stringify(update, "", " ")}`) return } if (!to) { // This shouldn't happen this.app.warn("No target found for update", update) return } if (update._ === "messageService" && update.action._ === "messageActionChannelMigrateFrom") { return } portal = await this.app.getPortalByPeer(to) if (update._ === "messageService") { await portal.handleTelegramServiceMessage({ from, to, source: this, action: update.action, }) return } await portal.handleTelegramMessage({ from, to, id: update.id, date: update.date, fwdFrom: update.fwd_from ? update.fwd_from.from_id : 0, source: this, text: update.message, entities: update.entities, photo: update.media && update.media._ === "messageMediaPhoto" ? update.media.photo : undefined, document: update.media && update.media._ === "messageMediaDocument" ? update.media.document : undefined, geo: update.media && update.media._ === "messageMediaGeo" ? update.media.geo : undefined, caption: update.media ? update.media.caption : undefined, }) } async receiveUsers(users) { this.app.debug("green", "Handling received users:", JSON.stringify(users, "", " ")) for (const user of users) { const telegramUser = await this.app.getTelegramUser(user.id) await telegramUser.updateInfo(this, user, true) } } async receiveChats(chats) { this.app.debug("green", "Handling received chats:", JSON.stringify(chats, "", " ")) for (const chat of chats) { const peer = new TelegramPeer(chat._, chat.id, { accessHash: chat.access_hash, }) const portal = await this.app.getPortalByPeer(peer) await portal.updateInfo(this, chat) } } async handleUpdatesTooLong() { this.app.debug("magenta", "Handling updatesTooLong", this.pts, this.date) const dat = await this.client("updates.getDifference", { pts: this.pts, date: this.date, qts: -1, }) if (dat._ === "updates.differenceEmpty") { this.date = dat.date return } this.app.debug("magenta", `updates.getDifference: ${JSON.stringify(dat, "", " ")}`) // TODO use dat.users and dat.chats await this.receiveUsers(dat.users) await this.receiveChats(dat.chats) this.pts = dat.state.pts this.date = dat.state.date for (const message of dat.new_messages) { await this.onUpdate({ _: "updateNewMessage", pts: this.pts, message, }) } for (const update of dat.other_updates) { await this.onUpdate(update) } } async handleUpdate(data) { if (!data.update || data.update._ !== "updateUserStatus") { this.app.debug("green", "Raw event for", this.userID, JSON.stringify(data, "", " ")) } try { switch (data._) { case "updateShort": this.date = data.date await this.onUpdate(data.update) break case "updates": this.date = data.date await this.receiveUsers(data.users) await this.receiveChats(data.chats) for (const update of data.updates) { await this.onUpdate(update) } break case "updateShortMessage": case "updateShortChatMessage": await this.onUpdate(data) break case "updatesTooLong": if (this.pts === 0) { this.app.warn("updatesTooLong received, but we don't have a persistent timestamp :(") break } await this.handleUpdatesTooLong() break default: this.app.warn("Unrecognized update type:", data._) } } catch (err) { this.app.warn("Error handling update:", err) } } async listen() { this.client.bus.untypedMessage.observe(data => this.handleUpdate(data.message)) try { // FIXME updating status crashes or freezes //console.log("Updating online status...") //const statusUpdate = await this.client("account.updateStatus", { offline: false }) //console.log(statusUpdate) this.app.info("Fetching initial state...") const state = await this.client("updates.getState", {}) this.pts = state.pts this.date = state.date this.app.debug("green", "Initial state:", JSON.stringify(state, "", " ")) } catch (err) { console.error("Error getting initial state:", err) } try { this.app.info("Updating contact list...") const changed = await this.matrixUser.syncContacts() if (!changed) { this.app.info("Contacts were up-to-date") } else { this.app.info("Contacts updated") } } catch (err) { console.error("Failed to update contacts:", err) } try { this.app.info("Updating dialogs...") const changed = await this.matrixUser.syncChats() if (!changed) { this.app.info("Dialogs were up-to-date") } else { this.app.info("Dialogs updated") } } catch (err) { console.error("Failed to update dialogs:", err) } setInterval(async () => { try { await this.client("updates.getState", {}) } catch (err) { console.error("Error updating state:", err) console.error(err.stack) } }, 1000) } async uploadFile() { } async getFile(location) { if (location.volume_id && location.local_id) { location = { _: "inputFileLocation", volume_id: location.volume_id, local_id: location.local_id, secret: location.secret, } } else if (location.id && location.access_hash) { location = { _: "inputDocumentFileLocation", id: location.id, access_hash: location.access_hash, } } else { throw new Error("Unrecognized file location type.") } const file = await this.client("upload.getFile", { location, offset: 0, // Max download size: 100mb limit: 100 * 1024 * 1024, }) file.buffer = Buffer.from(file.bytes) if (file.type._ === "storage.filePartial") { const { mime, ext } = fileType(file.buffer) file.mimetype = mime file.extension = ext file.matrixtype = matrixFromMime(mime) } else { const meta = metaFromFileType(file.type._) if (meta) { file.mimetype = meta.mimetype file.extension = meta.extension file.matrixtype = meta.matrixtype } } return file } } module.exports = TelegramPuppet