UNPKG

tdl

Version:

Node.js bindings to TDLib (Telegram Database library)

640 lines (639 loc) 28.3 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Client = exports.UnknownError = exports.TDLibError = void 0; const node_path_1 = require("node:path"); const debug_1 = __importDefault(require("debug")); const util_1 = require("./util"); const prompt = __importStar(require("./prompt")); const version_1 = require("./version"); const queue_1 = require("./queue"); // NOTE: if needed, this client can be abstracted into a different package later const debug = (0, debug_1.default)('tdl:client'); const debugReceive = (0, debug_1.default)('tdl:client:receive'); const debugReq = (0, debug_1.default)('tdl:client:request'); const defaultOptions = { databaseDirectory: '_td_database', filesDirectory: '_td_files', databaseEncryptionKey: '', useTestDc: false, skipOldUpdates: false, tdlibParameters: { use_message_database: true, use_secret_chats: false, system_language_code: 'en', application_version: '1.0', device_model: 'Unknown device', system_version: 'Unknown' } }; const defaultLoginDetails = { type: 'user', getPhoneNumber: prompt.getPhoneNumber, getEmailAddress: prompt.getEmailAddress, getEmailCode: prompt.getEmailCode, confirmOnAnotherDevice: prompt.confirmOnAnotherDevice, getAuthCode: prompt.getAuthCode, getPassword: prompt.getPassword, getName: prompt.getName }; class TDLibError extends Error { constructor(code, message) { super(message); this._ = 'error'; this.code = code; this.name = 'TDLibError'; } toString() { return `TDLibError: ${this.code} ${this.message}`; } } exports.TDLibError = TDLibError; class UnknownError extends Error { constructor(err) { if (typeof err === 'string') super(err); else super(); this.err = err; this.name = 'UnknownError'; } } exports.UnknownError = UnknownError; const TDLIB_1_8_6 = new version_1.Version('1.8.6'); const TDLIB_DEFAULT = new version_1.Version('1.8.27'); const TDL_MAGIC = '-tdl-'; // All package-public methods in the Client class are meant to be defined as // properties. class Client { constructor(tdjson, managing, options = {}) { this._pending = new Map(); this._requestId = 0; this._initialized = false; this._preinitRequests = []; this._version = TDLIB_DEFAULT; this._connectionStateName = 'connectionStateWaitingForNetwork'; this._authorizationState = null; this._events = { update: new Set(), error: new Set(), close: new Set() }; this.getVersion = () => { if (this._version === TDLIB_DEFAULT) throw new Error('Unknown TDLib version'); return this._version.toString(); }; this.on = (event, fn) => { let listeners = this._events[event]; if (listeners == null) listeners = this._events[event] = new Set(); listeners.add(fn); return this; }; this.once = (event, fn) => { let listeners = this._events[event]; if (listeners == null) listeners = this._events[event] = new Set(); fn.once = true; listeners.add(fn); return this; }; this.off = (event, fn) => { const listeners = this._events[event]; if (listeners == null) return false; return listeners.delete(fn); }; this.emit = (event, value) => { const listeners = this._events[event]; if (event === 'error' && (listeners == null || listeners.size === 0)) { // Creating unhandled promise rejection if no error handlers are set Promise.reject(value); } if (listeners == null) return; for (const listener of listeners) { if (listener.once === true) listeners.delete(listener); listener(value); } }; this.addListener = this.on; this.removeListener = this.off; this.iterUpdates = () => { if (this._client.val == null) throw new Error('The client is closed'); const unconsumedEvents = new queue_1.Queue(); let defer = null; let finished = false; const finish = () => { this.off('update', onUpdate); finished = true; debug('Finished an async iterator'); }; function onUpdate(update) { if (update._ === 'updateAuthorizationState' && update.authorization_state._ == 'authorizationStateClosed') { finish(); } if (defer != null) { defer.resolve({ done: false, value: update }); defer = null; } else { unconsumedEvents.push(update); } } this.on('update', onUpdate); const iterator = { next() { if (!unconsumedEvents.isEmpty()) { const update = unconsumedEvents.shift(); return Promise.resolve({ done: false, value: update }); } if (finished) return Promise.resolve({ done: true, value: undefined }); if (defer != null) { finish(); throw new Error('Cannot call next() twice in succession'); } return new Promise((resolve, reject) => { defer = { resolve, reject }; }); }, return() { finish(); return Promise.resolve({ done: true, value: undefined }); }, [Symbol.asyncIterator]() { return iterator; } }; return iterator; }; this.invoke = (request) => { const id = this._requestId; this._requestId++; if (id >= Number.MAX_SAFE_INTEGER) throw new Error('Too large request id'); const responsePromise = new Promise((resolve, reject) => { this._pending.set(id, { resolve, reject }); }); if (this._initialized === false) { this._preinitRequests.push({ request, id }); return responsePromise; } this._send(request, id); return responsePromise; }; // Sends { _: 'close' } and waits until the client gets destroyed this.close = () => { debug('close'); return new Promise(resolve => { if (this._client.val == null) return resolve(); this._sendTdl({ _: 'close' }); this.once('close', () => resolve()); }); }; this.login = (arg = {}) => { return new Promise((resolve, reject) => { if (this._client.val == null) return reject(new Error('The client is closed')); let cachedLoginDetails = null; function needLoginDetails() { if (cachedLoginDetails == null) { cachedLoginDetails = (0, util_1.mergeDeepRight)(defaultLoginDetails, typeof arg === 'function' ? arg() : arg); } return cachedLoginDetails; } function needUserLogin() { const loginDetails = needLoginDetails(); if (loginDetails.type !== 'user') throw new Error('Expected to log in as a bot, received user auth update'); return loginDetails; } const processAuthorizationState = async (authState) => { // Note: authorizationStateWaitPhoneNumber may not be the first update // in the login flow in case of a previous incomplete login attempt try { switch (authState._) { case 'authorizationStateReady': { // Finished (this may be the first update if already logged in) this.off('update', onUpdate); resolve(undefined); return; } case 'authorizationStateClosed': { throw new Error('Received authorizationStateClosed'); } case 'authorizationStateWaitPhoneNumber': { const loginDetails = needLoginDetails(); let retry = false; if (loginDetails.type === 'user') { while (true) { const phoneNumber = await loginDetails.getPhoneNumber(retry); try { await this.invoke({ _: 'setAuthenticationPhoneNumber', phone_number: phoneNumber }); return; } catch (e) { if (e?.message === 'PHONE_NUMBER_INVALID') retry = true; else throw e; } } } else { while (true) { const token = await loginDetails.getToken(retry); try { await this.invoke({ _: 'checkAuthenticationBotToken', token }); return; } catch (e) { if (e?.message === 'ACCESS_TOKEN_INVALID') retry = true; else throw e; } } } } // TDLib >= v1.8.6 only case 'authorizationStateWaitEmailAddress': { const loginDetails = needUserLogin(); await this.invoke({ _: 'setAuthenticationEmailAddress', email_address: await loginDetails.getEmailAddress() }); return; } // TDLib >= v1.8.6 only case 'authorizationStateWaitEmailCode': { const loginDetails = needUserLogin(); await this.invoke({ _: 'checkAuthenticationEmailCode', code: { // Apple ID and Google ID are not supported _: 'emailAddressAuthenticationCode', code: await loginDetails.getEmailCode() } }); return; } case 'authorizationStateWaitOtherDeviceConfirmation': { const loginDetails = needUserLogin(); loginDetails.confirmOnAnotherDevice(authState.link); return; } case 'authorizationStateWaitCode': { const loginDetails = needUserLogin(); let retry = false; while (true) { const code = await loginDetails.getAuthCode(retry); try { await this.invoke({ _: 'checkAuthenticationCode', code }); return; } catch (e) { if (e?.message === 'PHONE_CODE_EMPTY' || e?.message === 'PHONE_CODE_INVALID') retry = true; else throw e; } } } case 'authorizationStateWaitRegistration': { const loginDetails = needUserLogin(); const { firstName, lastName = '' } = await loginDetails.getName(); await this.invoke({ _: 'registerUser', first_name: firstName, last_name: lastName }); return; } case 'authorizationStateWaitPassword': { const loginDetails = needUserLogin(); const passwordHint = authState.password_hint; let retry = false; while (true) { const password = await loginDetails.getPassword(passwordHint, retry); try { await this.invoke({ _: 'checkAuthenticationPassword', password }); return; } catch (e) { if (e?.message === 'PASSWORD_HASH_INVALID') retry = true; else throw e; } } } } } catch (e) { this.off('update', onUpdate); reject(e); } }; function onUpdate(update) { if (update._ !== 'updateAuthorizationState') return; processAuthorizationState(update.authorization_state); } // Process last received authorization state first if (this._authorizationState != null) processAuthorizationState(this._authorizationState); this.on('update', onUpdate); }); }; this.loginAsBot = (token) => { return this.login({ type: 'bot', getToken: retry => retry ? Promise.reject(new Error('Invalid bot token')) : Promise.resolve(typeof token === 'string' ? token : token()) }); }; this.isClosed = () => { return this._client.val == null; }; this._options = (0, util_1.mergeDeepRight)(defaultOptions, options); this._tdjson = tdjson; this._client = { isTdn: !managing.useOldTdjsonInterface, val: null }; this.execute = managing.executeFunc; if (managing.bare) { this._initialized = true; } else { if (!options.apiId && !options.tdlibParameters?.api_id) throw new TypeError('Valid api_id must be provided.'); if (!options.apiHash && !options.tdlibParameters?.api_hash) throw new TypeError('Valid api_hash must be provided.'); } if (options.verbosityLevel != null) { throw new TypeError('Set verbosityLevel in tdl.configure instead'); } if (!this._client.isTdn) { this._client.val = this._tdjson.tdold.create(managing.receiveTimeout); if (this._client.val == null) throw new Error('Failed to create a TDLib client'); // Note: To allow defining listeners before the first update, we must // ensure that emit is not executed in the current tick. process.nextTick // or queueMicrotask are redundant here because of await in the _loop // function. this._loop(); } else { this._client.val = this._tdjson.tdnew.createClientId(); // The new tdjson interface requires to send a dummy request first this._sendTdl({ _: 'getOption', name: 'version' }); } } // Called by the client manager in case the new interface is used getClientId() { if (!this._client.isTdn) throw new Error('Cannot get id of a client in the old tdjson interface'); if (this._client.val == null) throw new Error('Cannot get id of a closed client'); return this._client.val; } _finishInit() { debug('Finished initialization'); this._initialized = true; for (const r of this._preinitRequests) this._send(r.request, r.id); this._preinitRequests = []; } // There's a bit of history behind this renaming of @type to _ in tdl. // Initially, it was because this code was written in Flow which had a bug // with disjoint unions (https://flow.org/en/docs/lang/refinements/) // not working if the tag is referenced via square brackets. _ has been chosen // because it is already an old convention in JS MTProto libraries and // webogram. The bug in Flow was later fixed, however the renaming is kept, // since it is more convenient to write if (o._ === '...') instead of // if (o['@type'] === '...'). Funny, other JS TDLib libraries also followed // with this renaming to _. _send(request, extra) { debugReq('send', request); const renamedRequest = (0, util_1.deepRenameKey)('_', '@type', request); renamedRequest['@extra'] = extra; const tdRequest = JSON.stringify(renamedRequest); if (this._client.val == null) throw new Error('A closed client cannot be reused, create a new client'); if (this._client.isTdn) this._tdjson.tdnew.send(this._client.val, tdRequest); else this._tdjson.tdold.send(this._client.val, tdRequest); } _sendTdl(request) { this._send(request, TDL_MAGIC); } _handleClose() { if (this._client.val == null) { debug('Trying to close an already closed client'); return; } if (!this._client.isTdn) this._tdjson.tdold.destroy(this._client.val); this._client.val = null; this.emit('close'); debug('closed'); } // Used with the old tdjson interface async _loop() { if (this._client.isTdn) throw new Error('Can start the loop in the old tdjson interface only'); try { while (true) { if (this._client.val === null) { debug('receive loop: destroyed client'); break; } const responseString = await this._tdjson.tdold.receive(this._client.val); if (responseString == null) { debug('receive loop: response is empty'); continue; } const res = JSON.parse(responseString); this.handleReceive(res); } } catch (e) { this._handleClose(); throw e; } } // Can be called by the client manager in case the new interface is used handleReceive(res) { try { this._handleReceive((0, util_1.deepRenameKey)('@type', '_', res)); } catch (e) { debug('handleReceive: caught error', e); const error = e instanceof Error ? e : new UnknownError(e); this.emit('error', error); } } // This function can be called with any TDLib object _handleReceive(res) { debugReceive(res); const isError = res._ === 'error'; const id = res['@extra']; const defer = id != null ? this._pending.get(id) : undefined; if (defer != null) { // a response to a request made by client.invoke delete res['@extra']; this._pending.delete(id); if (isError) defer.reject(new TDLibError(res.code, res.message)); else defer.resolve(res); return; } if (isError) { // error not connected to any request. we'll emit it // the error may still potentially have @extra and it's good to save that const resError = res; const error = new TDLibError(resError.code, resError.message); if (id != null) error['@extra'] = id; throw error; } if (id === TDL_MAGIC) { // a response to a request sent by tdl itself (during initialization) // it's irrelevant, just ignoring it (it's most likely `{ _: 'ok' }`) debug('(TDL_MAGIC) Not emitting response', res); return; } // if the object is not connected to any known request, we treat it as an // update. note that in a weird case (maybe if the @extra was manually set) // it still can contain the @extra field, this is intended and we want to // pass it further to client.on('update') this._handleUpdate(res); } _handleUpdate(update) { // updateOption, updateConnectionState, updateAuthorizationState // are always emitted, even with skipOldUpdates set to true switch (update._) { case 'updateOption': if (update.name === 'version' && update.value._ === 'optionValueString') { debug('Received version:', update.value.value); this._version = new version_1.Version(update.value.value); } break; case 'updateConnectionState': debug('New connection state:', update.state); this._connectionStateName = update.state._; break; case 'updateAuthorizationState': debug('New authorization state:', update.authorization_state._); this._authorizationState = update.authorization_state; if (update.authorization_state._ === 'authorizationStateClosed') this._handleClose(); else if (!this._initialized) this._handleAuthInit(update.authorization_state); break; default: const shouldSkip = this._options.skipOldUpdates && this._connectionStateName === 'connectionStateUpdating'; if (shouldSkip) return; } this.emit('update', update); } _handleAuthInit(authState) { // Note: pre-initialization requests should not call client.invoke switch (authState._) { case 'authorizationStateWaitTdlibParameters': if (this._version.lt(TDLIB_1_8_6)) { this._sendTdl({ _: 'setTdlibParameters', parameters: { database_directory: (0, node_path_1.resolve)(this._options.databaseDirectory), files_directory: (0, node_path_1.resolve)(this._options.filesDirectory), api_id: this._options.apiId, api_hash: this._options.apiHash, use_test_dc: this._options.useTestDc, ...this._options.tdlibParameters, _: 'tdlibParameters' } }); } else { this._sendTdl({ database_directory: (0, node_path_1.resolve)(this._options.databaseDirectory), files_directory: (0, node_path_1.resolve)(this._options.filesDirectory), api_id: this._options.apiId, api_hash: this._options.apiHash, use_test_dc: this._options.useTestDc, database_encryption_key: this._options.databaseEncryptionKey, ...this._options.tdlibParameters, _: 'setTdlibParameters' }); this._finishInit(); } return; // @ts-expect-error: This update can be received in TDLib <= v1.8.5 only case 'authorizationStateWaitEncryptionKey': this._sendTdl({ _: 'checkDatabaseEncryptionKey', encryption_key: this._options.databaseEncryptionKey }); this._finishInit(); } } } exports.Client = Client;