tdl
Version:
Node.js bindings to TDLib (Telegram Database library)
640 lines (639 loc) • 28.3 kB
JavaScript
"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;