diff --git a/package-lock.json b/package-lock.json index 44819fb3..a9a408a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -626,6 +626,11 @@ } } }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" + }, "cipher-base": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", @@ -843,6 +848,11 @@ "which": "1.3.0" } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" + }, "cryptiles": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", @@ -2471,6 +2481,16 @@ "request": "2.83.0" } }, + "md5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz", + "integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "1.1.6" + } + }, "md5.js": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz", diff --git a/package.json b/package.json index 7e35e932..9b0c7c99 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "matrix-appservice-bridge": "1.x.x", "commander": "2.11.x", "yamljs": "0.3.x", - "colors": "1.1.x" + "colors": "1.1.x", + "md5": "2.2.x" }, "devDependencies": { "eslint": "4.11.x", diff --git a/src/app.js b/src/app.js index 143f54c9..516f76af 100644 --- a/src/app.js +++ b/src/app.js @@ -15,9 +15,11 @@ // along with this program. If not, see . const {Bridge} = require("matrix-appservice-bridge") const crypto = require("crypto") +const YAML = require("yamljs") const commands = require("./commands") const MatrixUser = require("./matrix-user") -const YAML = require("yamljs") +const TelegramUser = require("./telegram-user") +const Portal = require("./portal") class MautrixTelegram { constructor(config) { @@ -25,6 +27,8 @@ class MautrixTelegram { this.matrixUsersByID = new Map() this.telegramUsersByID = new Map() + this.portalsByPeerID = new Map() + this.portalsByRoomID = new Map() const self = this this.bridge = new Bridge({ @@ -69,38 +73,135 @@ class MautrixTelegram { this.config.bridge.username_template.replace("${ID}", id)) } - getMatrixUser(id) { - let user = this.matrixUsersByID.get(id) - if (user) { - return Promise.resolve(user) + async getPortalByPeer(peer) { + let portal = this.portalsByPeerID.get(peer.id) + if (portal) { + return portal } - return this.bridge.getUserStore().select({ + const entries = await this.bridge.getRoomStore().select({ + type: "portal", + id: peer.id, + }) + + // Handle possible db query race conditions + portal = this.portalsByPeerID.get(peer.id) + if (portal) { + return portal + } + + if (entries.length) { + portal = Portal.fromEntry(this, entries[0]) + } else { + portal = new Portal(this, undefined, peer) + } + this.portalsByPeerID.set(peer.id, portal) + if (portal.roomID) { + this.portalsByRoomID.set(portal.roomID, portal) + } + return portal + } + + async getPortalByRoomID(id) { + let portal = this.portalsByRoomID.get(id) + if (portal) { + return portal + } + + // Check if we have it stored in the by-peer map + for (const [_, portalByPeer] of this.portalsByPeerID) { + if (portalByPeer.roomID === id) { + this.portalsByRoomID.set(id, portal) + return portalByPeer + } + } + + const entries = await this.bridge.getRoomStore().select({ + type: "portal", + roomID: id, + }) + + // Handle possible db query race conditions + let portal = this.portalsByRoomID.get(id) + if (portal) { + return portal + } + + if (entries.length) { + portal = Portal.fromEntry(this, entries[0]) + } else { + // Don't create portals based on room ID + return undefined + } + this.portalsByPeerID.set(portal.id, portal) + this.portalsByRoomID.set(id, portal) + return portal + } + + async getTelegramUser(id) { + let user = this.telegramUsersByID.get(id) + if (user) { + return user + } + + const entries = await this.bridge.getUserStore().select({ + type: "remote", + id, + }) + + // Handle possible db query race conditions + if (this.telegramUsersByID.has(id)) { + return this.telegramUsersByID.get(id) + } + + if (entries.length) { + user = TelegramUser.fromEntry(this, entries[0]) + } else { + user = new TelegramUser(this, id) + } + this.telegramUsersByID.set(id, user) + return user + } + + async getMatrixUser(id) { + let user = this.matrixUsersByID.get(id) + if (user) { + return user + } + + const entries = this.bridge.getUserStore().select({ type: "matrix", id, - }).then(entries => { - this.matrixUsersByID.get(id) - if (user) { - return Promise.resolve(user) - } - - if (entries.length) { - user = MatrixUser.fromEntry(this, entries[0]) - } else { - user = new MatrixUser(this, id) - } - this.matrixUsersByID.set(id, user) - return user }) + + // Handle possible db query race conditions + if (this.matrixUsersByID.has(id)) { + return this.matrixUsersByID.get(id) + } + + if (entries.length) { + user = MatrixUser.fromEntry(this, entries[0]) + } else { + user = new MatrixUser(this, id) + } + this.matrixUsersByID.set(id, user) + return user } putUser(user) { const entry = user.toEntry() - const query = { + return this.bridge.getUserStore().upsert({ type: entry.type, id: entry.id, - } - return this.bridge.getUserStore().upsert(query, entry) + }, entry) + } + + putRoom(room) { + const entry = room.toEntry() + return this.bridge.getUserStore().upsert({ + type: entry.type, + id: entry.id, + }, entry) } handleMatrixEvent(evt) { diff --git a/src/commands.js b/src/commands.js index fc4bafce..0fc5f615 100644 --- a/src/commands.js +++ b/src/commands.js @@ -20,8 +20,8 @@ const commands = {} function run(sender, command, args, reply, app) { if (sender.commandStatus) { if (command === "cancel") { - sender.commandStatus = undefined reply(`${sender.commandStatus.action} cancelled.`) + sender.commandStatus = undefined return } args.unshift(command) @@ -78,7 +78,6 @@ const enterCode = async (sender, args, reply) => { try { const data = await sender.signInToTelegram(args[0]) if (data.status === "ok") { - // TODO show who the user logged in as reply(`Logged in successfully as @${sender.telegramPuppet.getDisplayName()}.`) sender.commandStatus = undefined } else if (data.status === "need-password") { @@ -92,8 +91,9 @@ const enterCode = async (sender, args, reply) => { reply(`Unexpected sign in response, status=${data.status}`) } } catch (err) { + // TODO login fails somewhere with TypeError: Cannot read property 'status' of undefined reply(`Login failed: ${err}`) - console.log(err) + console.error(err.stack) } } @@ -120,6 +120,29 @@ commands.login = async (sender, args, reply) => { } } +commands.register = async (sender, args, reply) => { + reply("Registration has not yet been implemented. Please use the offical apps for now.") +} + +commands.logout = async (sender, args, reply) => { + try { + sender.logOutFromTelegram() + reply("Logged out successfully.") + } catch (err) { + reply(`Failed to log out: ${err}`) + } +} + +const TelegramPeer = require("./telegram-peer") +const Portal = require("./portal") + +commands.test = async (sender, args, reply, app) => { + const peer = new TelegramPeer(args[0], +args[1]) + const hashFound = await peer.getAccessHash(app, sender.telegramPuppet) + reply("Access hash found: " + hashFound) + new Portal(app, "", peer).createMatrixRoom(sender.telegramPuppet) +} + ////////////////////////////// // General command handlers // ////////////////////////////// diff --git a/src/matrix-user.js b/src/matrix-user.js index 5ad43993..16b6bbb9 100644 --- a/src/matrix-user.js +++ b/src/matrix-user.js @@ -13,7 +13,9 @@ // // 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") /** * MatrixUser represents a Matrix user who probably wants to control their @@ -27,6 +29,7 @@ class MatrixUser { this.phoneNumber = undefined this.phoneCodeHash = undefined this.commandStatus = undefined + this.contacts = [] this._telegramPuppet = undefined } @@ -38,8 +41,10 @@ class MatrixUser { const user = new MatrixUser(app, entry.id) user.phoneNumber = entry.data.phoneNumber user.phoneCodeHash = entry.data.phoneCodeHash + user.contactIDs = entry.data.contactIDs if (entry.data.puppet) { user.puppetData = entry.data.puppet + // Create the telegram puppet instance user.telegramPuppet } return user @@ -47,7 +52,7 @@ class MatrixUser { toEntry() { if (this._telegramPuppet) { - this.puppetData = this.telegramPuppet.toSubentry() + this.puppetData = this._telegramPuppet.toSubentry() } return { type: "matrix", @@ -55,6 +60,7 @@ class MatrixUser { data: { phoneNumber: this.phoneNumber, phoneCodeHash: this.phoneCodeHash, + contactIDs: this.contactIDs, puppet: this.puppetData, }, } @@ -76,36 +82,98 @@ class MatrixUser { throw new Error(message) } + get contactIDs() { + return this.contacts.map(contact => contact.id) + } + + 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 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) + if (telegramUser.updateInfo(this.telegramPuppet, contact)) { + telegramUser.save() + } + contacts.users[index] = telegramUser + } + this.contacts = contacts.users + await this.save() + return true + } + + async syncDialogs() { + const dialogs = await this.telegramPuppet.client("messages.getDialogs", {}) + for (const dialog of dialogs.chats) { + const peer = new TelegramPeer(dialog._, dialog.id) + const portal = await this.app.getPortalByPeer(peer) + if (portal.updateInfo(this.telegramPuppet, dialog)) { + portal.save() + } + } + } + async sendTelegramCode(phoneNumber) { - // TODO handle existing login? + 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.") + } try { const result = await this.telegramPuppet.sendCode(phoneNumber) this.phoneNumber = phoneNumber this.phoneCodeHash = result.phone_code_hash - await this.saveChanges() + await this.save() return result } catch (err) { return this.parseTelegramError(err) } } + async logOutFromTelegram() { + const ok = await this.telegramPuppet.logOut() + if (!ok) { + return false + } + this._telegramPuppet = undefined + this.puppetData = undefined + await this.save() + return true + } + 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.saveChanges() + await this.save() return result } async checkPassword(password_hash) { const result = await this.telegramPuppet.checkPassword(password_hash) - await this.saveChanges() + await this.save() return result } - saveChanges() { + save() { return this.app.putUser(this) } } diff --git a/src/portal.js b/src/portal.js index 43144a9c..2fac2c2e 100644 --- a/src/portal.js +++ b/src/portal.js @@ -18,9 +18,11 @@ const TelegramPeer = require("./telegram-peer") class Portal { constructor(app, roomID, peer) { this.app = app + this.type = "portal" this.roomID = roomID this.peer = peer + this.accessHashes = new Map() } static fromEntry(app, entry) { @@ -28,6 +30,56 @@ class Portal { throw new Error("MatrixUser can only be created from entry type \"portal\"") } - return new Portal(app, entry.data.roomID, TelegramPeer.fromSubentry(entry.data.peer)) + 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 createMatrixRoom(telegramPOV) { + if (this.roomID) { + return + } + + try { + await this.peer.getInfo(telegramPOV) + } catch (err) { + console.error(err) + console.error(err.stack) + } + } + + 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 + } + if (this.title !== dialog.title) { + this.title = dialog.title + changed = true + } + return changed + } + + toEntry() { + return { + type: this.type, + id: 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 diff --git a/src/telegram-peer.js b/src/telegram-peer.js index 746454f8..b62048ca 100644 --- a/src/telegram-peer.js +++ b/src/telegram-peer.js @@ -18,22 +18,72 @@ class TelegramPeer { constructor(type, id, accessHash) { this.type = type this.id = id - this.accessHash = accessHash + this.accessHash = +(accessHash || 0) } static fromTelegramData(peer) { switch(peer._) { case "peerChat": - return new Peer("chat", peer.chat_id) + return new TelegramPeer("chat", peer.chat_id) case "peerUser": - return new Peer("user", peer.user_id, peer.access_hash) + return new TelegramPeer("user", peer.user_id, peer.access_hash || 0) case "peerChannel": - return new Peer("channel", peer.channel_id, peer.access_hash) + return new TelegramPeer("channel", peer.channel_id, peer.access_hash || 0) default: throw new Error(`Unrecognized peer type ${peer._}`) } } + async getAccessHash(app, telegramPOV) { + if (this.type === "chat" || this.accessHash > 0) { + return true + } else if (this.type === "user") { + const user = await app.getTelegramUser(this.id) + if (user.accessHashes.has(telegramPOV.userID)) { + this.accessHash = user.accessHashes.get(telegramPOV.userID) + return true + } + return false + } else if (this.type === "channel") { + const portal = await app.getPortalByPeer(this) + if (portal.accessHashes.has(telegramPOV.userID)) { + this.accessHash = portal.accessHashes.get(telegramPOV.userID) + return true + } + return false + } + + } + + async getInfo(telegramPOV) { + let info, participants + switch(this.type) { + case "user": + throw new Error("Can't get chat info of user") + case "chat": + info = await telegramPOV.client("messages.getFullChat", { + chat_id: this.id, + }) + break + case "channel": + // FIXME I'm broken (Error: CHANNEL_INVALID) + info = await telegramPOV.client("channels.getFullChannel", { + channel: this.toInputChannel(), + }) + participants = await telegramPOV.client("channels.getParticipants", { + channel: this.toInputChannel(), + filter: { _: "channelParticipantsRecent" }, + offset: 0, + limit: 1000, + }) + break + default: + throw new Error(`Unknown peer type ${this.type}`) + } + console.log(JSON.stringify(info, "", " ")) + console.log(JSON.stringify(participants, "", " ")) + } + toInputPeer() { switch(this.type) { case "chat": @@ -71,15 +121,13 @@ class TelegramPeer { } static fromSubentry(entry) { - const accessHash = entry.accessHash ? new Buffer(entry.accessHash) : undefined - return new Peer(entry.type, entry.id, accessHash) + return new TelegramPeer(entry.type, entry.id) } toSubentry() { return { type: this.type, id: this.id, - accessHash: this.accessHash.toString(), } } @@ -87,3 +135,5 @@ class TelegramPeer { return `${this.type} ${this.id}` } } + +module.exports = TelegramPeer diff --git a/src/telegram-puppet.js b/src/telegram-puppet.js index 6f904d1a..1108cf68 100644 --- a/src/telegram-puppet.js +++ b/src/telegram-puppet.js @@ -16,6 +16,7 @@ const pkg = require("../package.json") const os = require("os") const telegram = require("telegram-mtproto") +const TelegramPeer = require("./telegram-peer") /** * TelegramPuppet represents a Telegram account being controlled from Matrix. @@ -40,18 +41,20 @@ class TelegramPuppet { return value }, set: async (key, value) => { - if (this.data[key] === value) return Promise.resolve() + if (this.data[key] === value) { + return + } this.data[key] = value - await this.matrixUser.saveChanges() + await this.matrixUser.save() }, remove: async (...keys) => { keys.forEach((key) => delete this.data[key]) - await this.matrixUser.saveChanges() + await this.matrixUser.save() }, clear: async () => { this.data = {} - await this.matrixUser.saveChanges() + await this.matrixUser.save() }, } @@ -97,6 +100,21 @@ class TelegramPuppet { 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, @@ -106,13 +124,17 @@ class TelegramPuppet { }) } + 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, }) - this.signInComplete(result) + return this.signInComplete(result) } catch (err) { if (err.message !== "SESSION_PASSWORD_NEEDED") { throw err @@ -147,32 +169,90 @@ class TelegramPuppet { this.data.firstName = data.user.first_name this.data.lastName = data.user.last_name this.data.phoneNumber = data.user.phone_number - this.matrixUser.saveChanges() + this.matrixUser.save() this.listen() return { status: "ok", } } + async sendMessage(peer, message) { + const result = await this.client("messages.sendMessage", { + peer: peer.toInputPeer(), + message, + random_id: ~~(Math.random() * (1<<30)), + }) + return result + } + + handleMessage(message) { + console.log( + `Received message from ${message.from.id} to ${message.to.type.replace("user", "1-1 chat")}${message.to.type === "user" ? "" : " " + message.to.id}: ${message.text}`) + } + onUpdate(update) { - console.log("Update received:", update) + if (!update) { + console.log("Oh noes! Empty update") + return + } + switch(update._) { + case "updateUserStatus": + console.log(update.user_id, "is now", update.status._.substr("userStatus".length)) + break + case "updateUserTyping": + console.log(update.user_id, "is typing in a 1-1 chat") + break + case "updateChatUserTyping": + console.log(update.user_id, "is typing in", update.chat_id) + break + case "updateShortMessage": + this.handleMessage({ + from: this.app.getTelegramUser(update.user_id), + to: new TelegramPeer("user", update.user_id), + text: update.message, + }) + break + case "updateShortChatMessage": + this.handleMessage({ + from: this.app.getTelegramUser(update.user_id), + to: new TelegramPeer("chat", update.chat_id), + text: update.message, + }) + break + case "updateNewMessage": + update = update.message // Message defined at message#90dddc11 in layer 71 + this.handleMessage({ + from: update.from_id, + to: TelegramPeer.fromTelegramData(update.to_id), + text: update.message, + }) + break + default: + console.log(`Update of type ${update._} received:\n${JSON.stringify(update, "", " ")}`) + } } handleUpdate(data) { - switch(data._) { - case "updateShort": - this.onUpdate(data.update) - break - case "updates": - for (const update of data.updates) { - this.onUpdate(update) - } - break - case "updateShortChatMessage": - this.onUpdate(update) - break - default: - console.log("Unrecognized update type:", data._) + try { + switch (data._) { + case "updateShort": + this.onUpdate(data.update) + break + case "updates": + for (const update of data.updates) { + this.onUpdate(update) + } + break + case "updateShortMessage": + case "updateShortChatMessage": + this.onUpdate(data) + break + default: + console.log("Unrecognized update type:", data._) + } + } catch (err) { + console.error("Error handling update:", err) + console.log(e.stack) } } @@ -185,18 +265,35 @@ class TelegramPuppet { try { console.log("Updating online status...") - const statusUpdate = await client("account.updateStatus", { offline: false }) - console.log(statusUpdate) + //const statusUpdate = await client("account.updateStatus", { offline: false }) + //console.log(statusUpdate) console.log("Fetching initial state...") const state = await client("updates.getState", {}) console.log("Initial state:", state) } catch (err) { console.error("Error getting initial state:", err) } + try { + console.log("Updating contact list...") + const changed = await this.matrixUser.syncContacts() + if (!changed) { + console.log("Contacts were up-to-date") + } else { + console.log("Contacts updated") + } + } catch (err) { + console.error("Failed to update contacts:", err) + } + try { + console.log("Syncing dialogs...") + await this.matrixUser.syncDialogs() + } catch (err) { + console.error("Failed to sync dialogs:", err) + } setInterval(async () => { try { const state = client("updates.getState", {}) - console.log("New state received") + // TODO use state? } catch (err) { console.error("Error updating state:", err) } diff --git a/src/telegram-user.js b/src/telegram-user.js index ea5ffd37..d0350350 100644 --- a/src/telegram-user.js +++ b/src/telegram-user.js @@ -13,6 +13,7 @@ // // You should have received a copy of the GNU General Public License // along with this program. If not, see . +const TelegramPeer = require("./telegram-peer") /** * TelegramUser represents a Telegram user who probably has an @@ -22,9 +23,10 @@ class TelegramUser { constructor(app, id, user) { this.app = app this.id = id + this.accessHashes = new Map() this._intent = undefined if (user) { - this.updateInfo(user) + this.updateInfo(undefined, user) } } @@ -41,6 +43,12 @@ class TelegramUser { user.phoneNumber = data.phoneNumber user.photo = data.photo user.avatarURL = data.avatarURL + user.accessHashes = new Map(data.accessHashes) + return user + } + + toPeer(telegramPOV) { + return new TelegramPeer("user", this.id, this.accessHashes.get(telegramPOV.userID)) } toEntry() { @@ -54,12 +62,17 @@ class TelegramUser { phoneNumber: this.phoneNumber, photo: this.photo, avatarURL: this.avatarURL, + accessHashes: Array.from(this.accessHashes), }, } } - updateFrom(user) { + updateInfo(telegramPOV, user) { let changed = false + if (telegramPOV && this.accessHashes.get(telegramPOV.userID) !== +user.access_hash) { + this.accessHashes.set(telegramPOV.userID, +user.access_hash) + changed = true + } if (this.firstName !== user.first_name) { this.firstName = user.first_name changed = true @@ -68,6 +81,10 @@ class TelegramUser { this.lastName = user.last_name changed = true } + if (this.username !== user.username) { + this.username = user.username + changed = true + } return changed } @@ -94,6 +111,10 @@ class TelegramUser { return this.id } + save() { + return this.app.putUser(this) + } + sendText(roomID, text) { return this.intent.sendText(roomID, text) } @@ -122,18 +143,20 @@ class TelegramUser { }) } - async updateAvatarImageFrom(user, puppet) { - if (!user.photo) return Promise.resolve() + async updateAvatarImageFrom(telegramPOV, user) { + if (!user.photo) { + return + } const photo = user.photo.photo_big 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 Promise.resolve(this.avatarURL) + return this.avatarURL } - const file = await puppet.getFile(photo) + const file = await telegramPOV.getFile(photo) const name = `${photo.volume_id}_${photo.local_id}.${file.extension}` const uploaded = await this.uploadContent({ @@ -153,3 +176,5 @@ class TelegramUser { return this.avatarURL } } + +module.exports = TelegramUser