Prechádzať zdrojové kódy

Fix DC migration on connect
Fix reconnecting
Fix disconnecting

painor 5 rokov pred
rodič
commit
ca5727cbc7

+ 823 - 0
src/lib/gramjs/client/TelegramClient.js

@@ -0,0 +1,823 @@
+const Logger = require('../extensions/Logger')
+const { sleep } = require('../Helpers')
+const errors = require('../errors')
+const MemorySession = require('../sessions/Memory')
+const { addKey } = require('../crypto/RSA')
+const { TLRequest } = require('../tl/tlobject')
+const utils = require('../Utils')
+const Session = require('../sessions/Abstract')
+const JSONSession = require('../sessions/JSONSession')
+const os = require('os')
+const { GetConfigRequest } = require('../tl/functions/help')
+const { LAYER } = require('../tl/AllTLObjects')
+const { functions, types } = require('../tl')
+const { computeCheck } = require('../Password')
+const MTProtoSender = require('../network/MTProtoSender')
+const { ConnectionTCPObfuscated } = require('../network/connection/TCPObfuscated')
+
+const DEFAULT_DC_ID = 2
+const DEFAULT_IPV4_IP = 'venus.web.telegram.org'
+const DEFAULT_IPV6_IP = '[2001:67c:4e8:f002::a]'
+
+class TelegramClient {
+    static DEFAULT_OPTIONS = {
+        connection: ConnectionTCPObfuscated,
+        useIPV6: false,
+        proxy: null,
+        timeout: 10,
+        requestRetries: 5,
+        connectionRetries: 5,
+        retryDelay: 1,
+        autoReconnect: true,
+        sequentialUpdates: false,
+        FloodSleepLimit: 60,
+        deviceModel: null,
+        systemVersion: null,
+        appVersion: null,
+        langCode: 'en',
+        systemLangCode: 'en',
+        baseLogger: 'gramjs',
+        useWSS: false,
+    }
+
+
+    constructor(session, apiId, apiHash, opts = TelegramClient.DEFAULT_OPTIONS) {
+        if (apiId === undefined || apiHash === undefined) {
+            throw Error('Your API ID or Hash are invalid. Please read "Requirements" on README.md')
+        }
+        const args = { ...TelegramClient.DEFAULT_OPTIONS, ...opts }
+        this.apiId = apiId
+        this.apiHash = apiHash
+        this._useIPV6 = args.useIPV6
+        this._entityCache = new Set()
+        if (typeof args.baseLogger == 'string') {
+            this._log = new Logger()
+        } else {
+            this._log = args.baseLogger
+        }
+        // Determine what session we will use
+        if (typeof session === 'string' || !session) {
+            try {
+                session = JSONSession.tryLoadOrCreateNew(session)
+            } catch (e) {
+                console.log(e)
+                session = new MemorySession()
+            }
+        } else if (!(session instanceof Session)) {
+            throw new Error('The given session must be str or a session instance')
+        }
+        if (!session.serverAddress || (session.serverAddress.includes(':') !== this._useIPV6)) {
+            session.setDC(DEFAULT_DC_ID, this._useIPV6 ? DEFAULT_IPV6_IP : DEFAULT_IPV4_IP, args.useWSS ? 443 : 80)
+        }
+        this.floodSleepLimit = args.floodSleepLimit
+        this._eventBuilders = []
+
+        this._phoneCodeHash = {}
+        this.session = session
+        // this._entityCache = EntityCache();
+        this.apiId = parseInt(apiId)
+        this.apiHash = apiHash
+
+        this._requestRetries = args.requestRetries
+        this._connectionRetries = args.connectionRetries
+        this._retryDelay = args.retryDelay || 0
+        if (args.proxy) {
+            this._log.warn('proxies are not supported')
+        }
+        this._proxy = args.proxy
+        this._timeout = args.timeout
+        this._autoReconnect = args.autoReconnect
+
+        this._connection = args.connection
+        // TODO add proxy support
+
+        this._floodWaitedRequests = {}
+
+        this._initWith = (x) => {
+            return new functions.InvokeWithLayerRequest({
+                layer: LAYER,
+                query: new functions.InitConnectionRequest({
+                    apiId: this.apiId,
+                    deviceModel: args.deviceModel || os.type().toString() || 'Unknown',
+                    systemVersion: args.systemVersion || os.release().toString() || '1.0',
+                    appVersion: args.appVersion || '1.0',
+                    langCode: args.langCode,
+                    langPack: '', // this should be left empty.
+                    systemLangCode: args.systemLangCode,
+                    query: x,
+                    proxy: null, // no proxies yet.
+                }),
+            })
+        }
+        // These will be set later
+        this._config = null
+        this._sender = new MTProtoSender(this.session.authKey, {
+            logger: this._log,
+            retries: this._connectionRetries,
+            delay: this._retryDelay,
+            autoReconnect: this._autoReconnect,
+            connectTimeout: this._timeout,
+            authKeyCallback: this._authKeyCallback.bind(this),
+            updateCallback: this._handleUpdate.bind(this),
+
+        })
+        this.phoneCodeHashes = []
+    }
+
+
+    // region Connecting
+
+    /**
+     * Connects to the Telegram servers, executing authentication if required.
+     * Note that authenticating to the Telegram servers is not the same as authenticating
+     * the app, which requires to send a code first.
+     * @returns {Promise<void>}
+     */
+    async connect() {
+        const connection = new this._connection(this.session.serverAddress
+            , this.session.port, this.session.dcId, this._log)
+        if (!await this._sender.connect(connection)) {
+            return
+        }
+        this.session.authKey = this._sender.authKey
+        await this.session.save()
+        await this._sender.send(this._initWith(
+            new GetConfigRequest(),
+        ))
+    }
+
+
+    /**
+     * Disconnects from the Telegram server
+     * @returns {Promise<void>}
+     */
+    async disconnect() {
+        if (this._sender) {
+            await this._sender.disconnect()
+        }
+    }
+
+
+    async _switchDC(newDc) {
+        this._log.info(`Reconnecting to new data center ${newDc}`)
+        const DC = await this._getDC(newDc)
+        this.session.setDC(DC.id, DC.ipAddress, DC.port)
+        // authKey's are associated with a server, which has now changed
+        // so it's not valid anymore. Set to None to force recreating it.
+        this._sender.authKey.key = null
+        this.session.authKey = null
+        await this.session.save()
+        await this.disconnect()
+        return await this.connect()
+    }
+
+    async _authKeyCallback(authKey) {
+        this.session.authKey = authKey
+        await this.session.save()
+    }
+
+    // endregion
+
+    // region Working with different connections/Data Centers
+
+    async _getDC(dcId, cdn = false) {
+        if (!this._config) {
+            this._config = await this.invoke(new functions.help.GetConfigRequest())
+        }
+        if (cdn && !this._cdnConfig) {
+            this._cdnConfig = await this.invoke(new functions.help.GetCdnConfigRequest())
+            for (const pk of this._cdnConfig.publicKeys) {
+                addKey(pk.publicKey)
+            }
+        }
+        for (const DC of this._config.dcOptions) {
+            if (DC.id === dcId && Boolean(DC.ipv6) === this._useIPV6 && Boolean(DC.cdn) === cdn) {
+                return DC
+            }
+        }
+    }
+
+    // endregion
+
+    // region Invoking Telegram request
+    /**
+     * Invokes a MTProtoRequest (sends and receives it) and returns its result
+     * @param request
+     * @returns {Promise}
+     */
+    async invoke(request) {
+        if (!(request instanceof TLRequest)) {
+            throw new Error('You can only invoke MTProtoRequests')
+        }
+        await request.resolve(this, utils)
+
+        if (request.CONSTRUCTOR_ID in this._floodWaitedRequests) {
+            const due = this._floodWaitedRequests[request.CONSTRUCTOR_ID]
+            const diff = Math.round(due - new Date().getTime() / 1000)
+            if (diff <= 3) {
+                delete this._floodWaitedRequests[request.CONSTRUCTOR_ID]
+            } else if (diff <= this.floodSleepLimit) {
+                this._log.info(`Sleeping early for ${diff}s on flood wait`)
+                await sleep(diff)
+                delete this._floodWaitedRequests[request.CONSTRUCTOR_ID]
+            } else {
+                throw new errors.FloodWaitError({
+                    request: request,
+                    capture: diff,
+                })
+            }
+        }
+        this._last_request = new Date().getTime()
+        let attempt = 0
+        for (attempt = 0; attempt < this._requestRetries; attempt++) {
+            try {
+                const promise = this._sender.send(request)
+                const result = await promise
+                this.session.processEntities(result)
+                this._entityCache.add(result)
+                return result
+            } catch (e) {
+                if (e instanceof errors.ServerError || e instanceof errors.RpcCallFailError ||
+                    e instanceof errors.RpcMcgetFailError) {
+                    this._log.warn(`Telegram is having internal issues ${e.constructor.name}`)
+                    await sleep(2000)
+                } else if (e instanceof errors.FloodWaitError || e instanceof errors.FloodTestPhoneWaitError) {
+                    this._floodWaitedRequests = new Date().getTime() / 1000 + e.seconds
+                    if (e.seconds <= this.floodSleepLimit) {
+                        this._log.info(`Sleeping for ${e.seconds}s on flood wait`)
+                        await sleep(e.seconds * 1000)
+                    } else {
+                        throw e
+                    }
+                } else if (e instanceof errors.PhoneMigrateError || e instanceof errors.NetworkMigrateError ||
+                    e instanceof errors.UserMigrateError) {
+                    this._log.info(`Phone migrated to ${e.newDc}`)
+                    const shouldRaise = e instanceof errors.PhoneMigrateError || e instanceof errors.NetworkMigrateError
+                    if (shouldRaise && await this.isUserAuthorized()) {
+                        throw e
+                    }
+                    await this._switchDC(e.newDc)
+                } else {
+                    throw e
+                }
+            }
+        }
+        throw new Error(`Request was unsuccessful ${attempt} time(s)`)
+    }
+
+    async getMe() {
+        const me = (await this.invoke(new functions.users
+            .GetUsersRequest({ id: [new types.InputUserSelf()] })))[0]
+        return me
+    }
+
+
+    async start(args = {
+        phone: null,
+        code: null,
+        password: null,
+        botToken: null,
+        forceSMS: null,
+        firstName: null,
+        lastName: null,
+        maxAttempts: 5,
+    }) {
+        args.maxAttempts = args.maxAttempts || 5
+        if (!this.isConnected()) {
+            await this.connect()
+        }
+        if (await this.isUserAuthorized()) {
+            return this
+        }
+        if (args.code == null && !args.botToken) {
+            throw new Error('Please pass a promise to the code arg')
+        }
+        if (!args.botToken && !args.phone) {
+            throw new Error('Please provide either a phone or a bot token')
+        }
+        if (!args.botToken) {
+            while (typeof args.phone == 'function') {
+                const value = await args.phone()
+                if (value.indexOf(':') !== -1) {
+                    args.botToken = value
+                    break
+                }
+                args.phone = utils.parsePhone(value) || args.phone
+            }
+        }
+        if (args.botToken) {
+            await this.signIn({
+                botToken: args.botToken,
+            })
+            return this
+        }
+
+        let me
+        let attempts = 0
+        let twoStepDetected = false
+
+        await this.sendCodeRequest(args.phone, args.forceSMS)
+
+        let signUp = false
+        while (attempts < args.maxAttempts) {
+            try {
+                const value = await args.code()
+                if (!value) {
+                    throw new errors.PhoneCodeEmptyError({
+                        request: null,
+                    })
+                }
+
+                if (signUp) {
+                    me = await this.signUp({
+                        code: value,
+                        firstName: args.firstName,
+                        lastName: args.lastName,
+                    })
+                } else {
+                    // this throws SessionPasswordNeededError if 2FA enabled
+                    me = await this.signIn({
+                        phone: args.phone,
+                        code: value,
+                    })
+                }
+                break
+            } catch (e) {
+                if (e instanceof errors.SessionPasswordNeededError) {
+                    twoStepDetected = true
+                    break
+                } else if (e instanceof errors.PhoneNumberOccupiedError) {
+                    signUp = true
+                } else if (e instanceof errors.PhoneNumberUnoccupiedError) {
+                    signUp = true
+                } else if (e instanceof errors.PhoneCodeEmptyError ||
+                    e instanceof errors.PhoneCodeExpiredError ||
+                    e instanceof errors.PhoneCodeHashEmptyError ||
+                    e instanceof errors.PhoneCodeInvalidError) {
+                    console.log('Invalid code. Please try again.')
+                } else {
+                    throw e
+                }
+            }
+            attempts++
+        }
+        if (attempts >= args.maxAttempts) {
+            throw new Error(`${args.maxAttempts} consecutive sign-in attempts failed. Aborting`)
+        }
+        if (twoStepDetected) {
+            if (!args.password) {
+                throw new Error('Two-step verification is enabled for this account. ' +
+                    'Please provide the \'password\' argument to \'start()\'.')
+            }
+            if (typeof args.password == 'function') {
+                for (let i = 0; i < args.maxAttempts; i++) {
+                    try {
+                        const pass = await args.password()
+                        me = await this.signIn({
+                            phone: args.phone,
+                            password: pass,
+                        })
+                        break
+                    } catch (e) {
+                        console.log('Invalid password. Please try again')
+                    }
+                }
+            } else {
+                me = await this.signIn({
+                    phone: args.phone,
+                    password: args.password,
+                })
+            }
+
+        }
+        const name = utils.getDisplayName(me)
+        console.log('Signed in successfully as', name)
+        return this
+    }
+
+    async signIn(args = {
+        phone: null,
+        code: null,
+        password: null,
+        botToken: null,
+        phoneCodeHash: null,
+    }) {
+        let result
+        if (args.phone && !args.code && !args.password) {
+            return await this.sendCodeRequest(args.phone)
+        } else if (args.code) {
+            const [phone, phoneCodeHash] =
+                this._parsePhoneAndHash(args.phone, args.phoneCodeHash)
+            // May raise PhoneCodeEmptyError, PhoneCodeExpiredError,
+            // PhoneCodeHashEmptyError or PhoneCodeInvalidError.
+            result = await this.invoke(new functions.auth.SignInRequest({
+                phoneNumber: phone,
+                phoneCodeHash: phoneCodeHash,
+                phoneCode: args.code.toString(),
+            }))
+        } else if (args.password) {
+            for (let i = 0; i < 5; i++) {
+                try {
+                    const pwd = await this.invoke(new functions.account.GetPasswordRequest())
+                    result = await this.invoke(new functions.auth.CheckPasswordRequest({
+                        password: computeCheck(pwd, args.password),
+                    }))
+                    break;
+                } catch (err) {
+                    console.error(`Password check attempt ${i + 1} of 5 failed. Reason: `, err);
+                }
+            }
+        } else if (args.botToken) {
+            result = await this.invoke(new functions.auth.ImportBotAuthorizationRequest(
+                {
+                    flags: 0,
+                    botAuthToken: args.botToken,
+                    apiId: this.apiId,
+                    apiHash: this.apiHash,
+                },
+            ))
+        } else {
+            throw new Error('You must provide a phone and a code the first time, ' +
+                'and a password only if an RPCError was raised before.')
+        }
+        return this._onLogin(result.user)
+    }
+
+
+    _parsePhoneAndHash(phone, phoneHash) {
+        phone = utils.parsePhone(phone) || this._phone
+        if (!phone) {
+            throw new Error('Please make sure to call send_code_request first.')
+        }
+        phoneHash = phoneHash || this._phoneCodeHash[phone]
+        if (!phoneHash) {
+            throw new Error('You also need to provide a phone_code_hash.')
+        }
+
+        return [phone, phoneHash]
+    }
+
+    // endregion
+    async isUserAuthorized() {
+        if (this._authorized===undefined || this._authorized===null) {
+            try {
+                await this.invoke(new functions.updates.GetStateRequest())
+                this._authorized = true
+            } catch (e) {
+                this._authorized = false
+            }
+        }
+        return this._authorized
+    }
+
+    /**
+     * Callback called whenever the login or sign up process completes.
+     * Returns the input user parameter.
+     * @param user
+     * @private
+     */
+    _onLogin(user) {
+        this._bot = Boolean(user.bot)
+        this._authorized = true
+        return user
+    }
+
+    async sendCodeRequest(phone, forceSMS = false) {
+        let result
+        phone = utils.parsePhone(phone) || this._phone
+        let phoneHash = this._phoneCodeHash[phone]
+
+        if (!phoneHash) {
+            try {
+                result = await this.invoke(new functions.auth.SendCodeRequest({
+                    phoneNumber: phone,
+                    apiId: this.apiId,
+                    apiHash: this.apiHash,
+                    settings: new types.CodeSettings(),
+                }))
+            } catch (e) {
+                if (e instanceof errors.AuthRestartError) {
+                    return await this.sendCodeRequest(phone, forceSMS)
+                }
+                throw e
+            }
+
+            // If we already sent a SMS, do not resend the code (hash may be empty)
+            if (result.type instanceof types.auth.SentCodeTypeSms) {
+                forceSMS = false
+            }
+            if (result.phoneCodeHash) {
+                this._phoneCodeHash[phone] = phoneHash = result.phoneCodeHash
+            }
+        } else {
+            forceSMS = true
+        }
+        this._phone = phone
+        if (forceSMS) {
+            result = await this.invoke(new functions.auth.ResendCodeRequest({
+                phone: phone,
+                phoneHash: phoneHash,
+            }))
+
+            this._phoneCodeHash[phone] = result.phoneCodeHash
+        }
+        return result
+    }
+
+
+    // event region
+    addEventHandler(callback, event) {
+        this._eventBuilders.push([event, callback])
+    }
+
+    _handleUpdate(update) {
+        this.session.processEntities(update)
+        this._entityCache.add(update)
+
+        if (update instanceof types.Updates || update instanceof types.UpdatesCombined) {
+            // TODO deal with entities
+            const entities = {}
+            for (const x of [...update.users, ...update.chats]) {
+                entities[utils.getPeerId(x)] = x
+            }
+            for (const u of update.updates) {
+                this._processUpdate(u, update.updates, entities)
+            }
+        } else if (update instanceof types.UpdateShort) {
+            this._processUpdate(update.update, null)
+        } else {
+            this._processUpdate(update, null)
+        }
+        // TODO add caching
+        // this._stateCache.update(update)
+    }
+
+    _processUpdate(update, others, entities) {
+        update._entities = entities || {}
+        const args = {
+            update: update,
+            others: others,
+        }
+        this._dispatchUpdate(args)
+    }
+
+
+    // endregion
+
+    // region private methods
+
+    /**
+     Gets a full entity from the given string, which may be a phone or
+     a username, and processes all the found entities on the session.
+     The string may also be a user link, or a channel/chat invite link.
+
+     This method has the side effect of adding the found users to the
+     session database, so it can be queried later without API calls,
+     if this option is enabled on the session.
+
+     Returns the found entity, or raises TypeError if not found.
+     * @param string {string}
+     * @returns {Promise<void>}
+     * @private
+     */
+    async _getEntityFromString(string) {
+        const phone = utils.parsePhone(string)
+        if (phone) {
+            try {
+                for (const user of (await this.invoke(
+                    new functions.contacts.GetContactsRequest(0))).users) {
+                    if (user.phone === phone) {
+                        return user
+                    }
+                }
+            } catch (e) {
+                if (e instanceof errors.BotMethodInvalidError) {
+                    throw new Error('Cannot get entity by phone number as a ' +
+                        'bot (try using integer IDs, not strings)')
+                }
+                throw e
+            }
+        } else if (['me', 'this'].includes(string.toLowerCase())) {
+            return await this.getMe()
+        } else {
+            const { username, isJoinChat } = utils.parseUsername(string)
+            if (isJoinChat) {
+                const invite = await this.invoke(new functions.messages.CheckChatInviteRequest({
+                    'hash': username,
+                }))
+                if (invite instanceof types.ChatInvite) {
+                    throw new Error('Cannot get entity from a channel (or group) ' +
+                        'that you are not part of. Join the group and retry',
+                    )
+                } else if (invite instanceof types.ChatInviteAlready) {
+                    return invite.chat
+                }
+            } else if (username) {
+                try {
+                    const result = await this.invoke(
+                        new functions.contacts.ResolveUsernameRequest(username))
+                    const pid = utils.getPeerId(result.peer, false)
+                    if (result.peer instanceof types.PeerUser) {
+                        for (const x of result.users) {
+                            if (x.id === pid) {
+                                return x
+                            }
+                        }
+                    } else {
+                        for (const x of result.chats) {
+                            if (x.id === pid) {
+                                return x
+                            }
+                        }
+                    }
+                } catch (e) {
+                    if (e instanceof errors.UsernameNotOccupiedError) {
+                        throw new Error(`No user has "${username}" as username`)
+                    }
+                    throw e
+                }
+            }
+        }
+        throw new Error(`Cannot find any entity corresponding to "${string}"`)
+    }
+
+    // endregion
+
+
+    // users region
+    /**
+     Turns the given entity into its input entity version.
+
+     Most requests use this kind of :tl:`InputPeer`, so this is the most
+     suitable call to make for those cases. **Generally you should let the
+     library do its job** and don't worry about getting the input entity
+     first, but if you're going to use an entity often, consider making the
+     call:
+
+     Arguments
+     entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`):
+     If a username or invite link is given, **the library will
+     use the cache**. This means that it's possible to be using
+     a username that *changed* or an old invite link (this only
+     happens if an invite link for a small group chat is used
+     after it was upgraded to a mega-group).
+
+     If the username or ID from the invite link is not found in
+     the cache, it will be fetched. The same rules apply to phone
+     numbers (``'+34 123456789'``) from people in your contact list.
+
+     If an exact name is given, it must be in the cache too. This
+     is not reliable as different people can share the same name
+     and which entity is returned is arbitrary, and should be used
+     only for quick tests.
+
+     If a positive integer ID is given, the entity will be searched
+     in cached users, chats or channels, without making any call.
+
+     If a negative integer ID is given, the entity will be searched
+     exactly as either a chat (prefixed with ``-``) or as a channel
+     (prefixed with ``-100``).
+
+     If a :tl:`Peer` is given, it will be searched exactly in the
+     cache as either a user, chat or channel.
+
+     If the given object can be turned into an input entity directly,
+     said operation will be done.
+
+     Unsupported types will raise ``TypeError``.
+
+     If the entity can't be found, ``ValueError`` will be raised.
+
+     Returns
+     :tl:`InputPeerUser`, :tl:`InputPeerChat` or :tl:`InputPeerChannel`
+     or :tl:`InputPeerSelf` if the parameter is ``'me'`` or ``'self'``.
+
+     If you need to get the ID of yourself, you should use
+     `get_me` with ``input_peer=True``) instead.
+
+     Example
+     .. code-block:: python
+
+     // If you're going to use "username" often in your code
+     // (make a lot of calls), consider getting its input entity
+     // once, and then using the "user" everywhere instead.
+     user = await client.get_input_entity('username')
+
+     // The same applies to IDs, chats or channels.
+     chat = await client.get_input_entity(-123456789)
+
+     * @param peer
+     * @returns {Promise<void>}
+     */
+    async getInputEntity(peer) {
+        // Short-circuit if the input parameter directly maps to an InputPeer
+        try {
+            return utils.getInputPeer(peer)
+            // eslint-disable-next-line no-empty
+        } catch (e) {
+        }
+        // Next in priority is having a peer (or its ID) cached in-memory
+        try {
+            // 0x2d45687 == crc32(b'Peer')
+            if (typeof peer === 'number' || peer.SUBCLASS_OF_ID === 0x2d45687) {
+                if (this._entityCache.has(peer)) {
+                    return this._entityCache[peer]
+                }
+            }
+            // eslint-disable-next-line no-empty
+        } catch (e) {
+        }
+        // Then come known strings that take precedence
+        if (['me', 'this'].includes(peer)) {
+            return new types.InputPeerSelf()
+        }
+        // No InputPeer, cached peer, or known string. Fetch from disk cache
+        try {
+            return this.session.getInputEntity(peer)
+            // eslint-disable-next-line no-empty
+        } catch (e) {
+        }
+        // Only network left to try
+        if (typeof peer === 'string') {
+            return utils.getInputPeer(await this._getEntityFromString(peer))
+        }
+        // If we're a bot and the user has messaged us privately users.getUsers
+        // will work with access_hash = 0. Similar for channels.getChannels.
+        // If we're not a bot but the user is in our contacts, it seems to work
+        // regardless. These are the only two special-cased requests.
+        peer = utils.getPeer(peer)
+        if (peer instanceof types.PeerUser) {
+            const users = await this.invoke(new functions.users.GetUsersRequest({
+                id: [new types.InputUser({
+                    userId: peer.userId, accessHash: 0,
+                })],
+            }))
+            if (users && !(users[0] instanceof types.UserEmpty)) {
+                // If the user passed a valid ID they expect to work for
+                // channels but would be valid for users, we get UserEmpty.
+                // Avoid returning the invalid empty input peer for that.
+                //
+                // We *could* try to guess if it's a channel first, and if
+                // it's not, work as a chat and try to validate it through
+                // another request, but that becomes too much work.
+                return utils.getInputPeer(users[0])
+            }
+        } else if (peer instanceof types.PeerChat) {
+            return new types.InputPeerChat({
+                chatId: peer.chatId,
+            })
+        } else if (peer instanceof types.PeerChannel) {
+            try {
+                const channels = await this.invoke(new functions.channels.GetChannelsRequest({
+                    id: [new types.InputChannel({
+                        channelId: peer.channelId,
+                        accessHash: 0,
+                    })],
+                }))
+
+                return utils.getInputPeer(channels.chats[0])
+                // eslint-disable-next-line no-empty
+            } catch (e) {
+                console.log(e)
+            }
+        }
+        throw new Error(`Could not find the input entity for ${peer.id || peer.channelId || peer.chatId || peer.userId}.
+         Please read https://` +
+            'docs.telethon.dev/en/latest/concepts/entities.html to' +
+            ' find out more details.',
+        )
+    }
+
+
+    // endregion
+
+
+    async _dispatchUpdate(args = {
+        update: null,
+        others: null,
+        channelId: null,
+        ptsDate: null,
+    }) {
+        for (const [builder, callback] of this._eventBuilders) {
+            const event = builder.build(args.update)
+            if (event) {
+                await callback(event)
+            }
+        }
+    }
+
+    isConnected() {
+        if (this._sender) {
+            if (this._sender.isConnected()) {
+                return true
+            }
+        }
+        return false
+    }
+
+    async signUp() {
+
+    }
+}
+
+module.exports = TelegramClient

+ 152 - 0
src/lib/gramjs/errors/Common.js

@@ -0,0 +1,152 @@
+/**
+ * Errors not related to the Telegram API itself
+ */
+
+const struct = require('python-struct')
+
+/**
+ * Occurs when a read operation was cancelled.
+ */
+class ReadCancelledError extends Error {
+    constructor() {
+        super('The read operation was cancelled.')
+    }
+}
+
+/**
+ * Occurs when a type is not found, for example,
+ * when trying to read a TLObject with an invalid constructor code.
+ */
+class TypeNotFoundError extends Error {
+    constructor(invalidConstructorId, remaining) {
+        super(`Could not find a matching Constructor ID for the TLObject that was supposed to be
+        read with ID ${invalidConstructorId}. Most likely, a TLObject was trying to be read when
+         it should not be read. Remaining bytes: ${remaining}`)
+        this.invalidConstructorId = invalidConstructorId
+        this.remaining = remaining
+    }
+}
+
+/**
+ * Occurs when using the TCP full mode and the checksum of a received
+ * packet doesn't match the expected checksum.
+ */
+class InvalidChecksumError extends Error {
+    constructor(checksum, validChecksum) {
+        super(`Invalid checksum (${checksum} when ${validChecksum} was expected). This packet should be skipped.`)
+        this.checksum = checksum
+        this.validChecksum = validChecksum
+    }
+}
+
+/**
+ * Occurs when the buffer is invalid, and may contain an HTTP error code.
+ * For instance, 404 means "forgotten/broken authorization key", while
+ */
+class InvalidBufferError extends Error {
+    constructor(payload) {
+        let code = null
+        if (payload.length === 4) {
+            code = -(struct.unpack('<i', payload)[0])
+            super(`Invalid response buffer (HTTP code ${code})`)
+        } else {
+            super(`Invalid response buffer (too short ${payload})`)
+        }
+        this.code = code
+        this.payload = payload
+    }
+}
+
+/**
+ * Generic security error, mostly used when generating a new AuthKey.
+ */
+class SecurityError extends Error {
+    constructor(...args) {
+        if (!args.length) {
+            args = ['A security check failed.']
+        }
+        super(...args)
+    }
+}
+
+/**
+ * Occurs when there's a hash mismatch between the decrypted CDN file
+ * and its expected hash.
+ */
+class CdnFileTamperedError extends SecurityError {
+    constructor() {
+        super('The CDN file has been altered and its download cancelled.')
+    }
+}
+
+/**
+ * Occurs when another exclusive conversation is opened in the same chat.
+ */
+class AlreadyInConversationError extends Error {
+    constructor() {
+        super('Cannot open exclusive conversation in a chat that already has one open conversation')
+    }
+}
+
+/**
+ * Occurs when handling a badMessageNotification
+ */
+class BadMessageError extends Error {
+    static ErrorMessages = {
+        16:
+            'msg_id too low (most likely, client time is wrong it would be worthwhile to ' +
+            'synchronize it using msg_id notifications and re-send the original message ' +
+            'with the “correct” msg_id or wrap it in a container with a new msg_id if the ' +
+            'original message had waited too long on the client to be transmitted).',
+
+        17:
+            'msg_id too high (similar to the previous case, the client time has to be ' +
+            'synchronized, and the message re-sent with the correct msg_id).',
+
+        18:
+            'Incorrect two lower order msg_id bits (the server expects client message msg_id ' +
+            'to be divisible by 4).',
+
+        19: 'Container msg_id is the same as msg_id of a previously received message ' + '(this must never happen).',
+
+        20:
+            'Message too old, and it cannot be verified whether the server has received a ' +
+            'message with this msg_id or not.',
+
+        32:
+            'msg_seqno too low (the server has already received a message with a lower ' +
+            'msg_id but with either a higher or an equal and odd seqno).',
+
+        33:
+            'msg_seqno too high (similarly, there is a message with a higher msg_id but with ' +
+            'either a lower or an equal and odd seqno).',
+
+        34: 'An even msg_seqno expected (irrelevant message), but odd received.',
+
+        35: 'Odd msg_seqno expected (relevant message), but even received.',
+
+        48:
+            'Incorrect server salt (in this case, the bad_server_salt response is received with ' +
+            'the correct salt, and the message is to be re-sent with it).',
+
+        64: 'Invalid container.',
+    }
+
+    constructor(code) {
+        super(BadMessageError.ErrorMessages[code] || `Unknown error code (this should not happen): ${code}.`)
+        this.code = code
+    }
+}
+
+// TODO : Support multi errors.
+
+module.exports = {
+    ReadCancelledError,
+    TypeNotFoundError,
+    InvalidChecksumError,
+    InvalidBufferError,
+    SecurityError,
+    CdnFileTamperedError,
+    AlreadyInConversationError,
+    BadMessageError,
+}

+ 108 - 0
src/lib/gramjs/extensions/PromisedWebSockets.js

@@ -0,0 +1,108 @@
+const WebSocketClient = require('websocket').w3cwebsocket
+
+const closeError = new Error('WebSocket was closed')
+
+class PromisedWebSockets {
+    constructor() {
+        this.isBrowser = typeof process === 'undefined' ||
+            process.type === 'renderer' ||
+            process.browser === true ||
+            process.__nwjs
+        this.client = null
+
+        this.closed = true
+    }
+
+    async read(number) {
+        if (this.closed) {
+            console.log('couldn\'t read')
+            throw closeError
+        }
+        const canWe = await this.canRead
+
+        const toReturn = this.stream.slice(0, number)
+        this.stream = this.stream.slice(number)
+        if (this.stream.length === 0) {
+            this.canRead = new Promise((resolve) => {
+                this.resolveRead = resolve
+            })
+        }
+
+        return toReturn
+    }
+
+    async readAll() {
+        if (this.closed || !await this.canRead) {
+            throw closeError
+        }
+        const toReturn = this.stream
+        this.stream = Buffer.alloc(0)
+        this.canRead = new Promise((resolve) => {
+            this.resolveRead = resolve
+        })
+        return toReturn
+    }
+
+    getWebSocketLink(ip, port) {
+        if (port === 443) {
+            return `wss://${ip}:${port}/apiws`
+        } else {
+            return `ws://${ip}:${port}/apiws`
+        }
+    }
+
+    async connect(port, ip) {
+        console.log('trying to connect')
+        this.stream = Buffer.alloc(0)
+
+        this.canRead = new Promise((resolve) => {
+            this.resolveRead = resolve
+        })
+        this.closed  = false
+        this.website = this.getWebSocketLink(ip, port)
+        this.client = new WebSocketClient(this.website, 'binary')
+        return new Promise(function(resolve, reject) {
+            this.client.onopen = function() {
+                this.receive()
+                resolve(this)
+            }.bind(this)
+            this.client.onerror = function(error) {
+                reject(error)
+            }
+            this.client.onclose = function() {
+                if (this.client.closed) {
+                    this.resolveRead(false)
+                    this.closed = true
+                }
+            }.bind(this)
+        }.bind(this))
+    }
+
+    write(data) {
+        if (this.closed) {
+            throw closeError
+        }
+        this.client.send(data)
+    }
+
+    async close() {
+        console.log('something happened. closing')
+        await this.client.close()
+        this.closed = true
+    }
+
+    async receive() {
+        this.client.onmessage = async function(message) {
+            let data
+            if (this.isBrowser) {
+                data = Buffer.from(await new Response(message.data).arrayBuffer())
+            } else {
+                data = Buffer.from(message.data)
+            }
+            this.stream = Buffer.concat([this.stream, data])
+            this.resolveRead(true)
+        }.bind(this)
+    }
+}
+
+module.exports = PromisedWebSockets

+ 89 - 0
src/lib/gramjs/network/MTProtoPlainSender.js

@@ -0,0 +1,89 @@
+/**
+ *  This module contains the class used to communicate with Telegram's servers
+ *  in plain text, when no authorization key has been created yet.
+ */
+const Helpers = require('../Helpers')
+const MTProtoState = require('./MTProtoState')
+const struct = require('python-struct')
+const BinaryReader = require('../extensions/BinaryReader')
+const { InvalidBufferError } = require('../errors/Common')
+const JSBI = require('jsbi')
+
+/**
+ * MTProto Mobile Protocol plain sender (https://core.telegram.org/mtproto/description#unencrypted-messages)
+ */
+
+class MTProtoPlainSender {
+    /**
+     * Initializes the MTProto plain sender.
+     * @param connection connection: the Connection to be used.
+     * @param loggers
+     */
+    constructor(connection, loggers) {
+        this._state = new MTProtoState(connection, loggers)
+        this._connection = connection
+    }
+
+    /**
+     * Sends and receives the result for the given request.
+     * @param request
+     */
+    async send(request) {
+
+        let body = request.bytes
+        let msgId = this._state._getNewMsgId()
+        const res = Buffer.concat([struct.pack('<qqi', [0, msgId.toString(), body.length]), body])
+
+        await this._connection.send(res)
+        body = await this._connection.recv()
+        if (body.length < 8) {
+            throw new InvalidBufferError(body)
+        }
+        const reader = new BinaryReader(body)
+        const authKeyId = reader.readLong()
+        if (JSBI.notEqual(authKeyId, JSBI.BigInt(0))) {
+            throw new Error('Bad authKeyId')
+        }
+        msgId = reader.readLong()
+        if (JSBI.equal(msgId, JSBI.BigInt(0))) {
+            throw new Error('Bad msgId')
+        }
+        /** ^ We should make sure that the read ``msg_id`` is greater
+         * than our own ``msg_id``. However, under some circumstances
+         * (bad system clock/working behind proxies) this seems to not
+         * be the case, which would cause endless assertion errors.
+         */
+
+        const length = reader.readInt()
+        if (length <= 0) {
+            throw new Error('Bad length')
+        }
+        /**
+         * We could read length bytes and use those in a new reader to read
+         * the next TLObject without including the padding, but since the
+         * reader isn't used for anything else after this, it's unnecessary.
+         */
+        return reader.tgReadObject()
+    }
+
+    /**
+     * Generates a new message ID based on the current time (in ms) since epoch
+     * @returns {JSBI.BigInt}
+     */
+    getNewMsgId() {
+        // See https://core.telegram.org/mtproto/description#message-identifier-msg-id
+        const msTime = Date.now()
+        let newMsgId =
+            (JSBI.BigInt(Math.floor(msTime / 1000)) << JSBI.BigInt(32)) | // "must approximately equal unixtime*2^32"
+            (JSBI.BigInt(msTime % 1000) << JSBI.BigInt(32)) | // "approximate moment in time the message was created"
+            (JSBI.BigInt(Helpers.getRandomInt(0, 524288)) << JSBI.BigInt(2)) // "message identifiers are divisible by 4"
+        // Ensure that we always return a message ID which is higher than the previous one
+        if (this._lastMsgId >= newMsgId) {
+            newMsgId = this._lastMsgId + JSBI.BigInt(4)
+        }
+        this._lastMsgId = newMsgId
+        return JSBI.BigInt(newMsgId)
+    }
+}
+
+module.exports = MTProtoPlainSender