dograma
Version:
NodeJS/Browser MTProto API Telegram client library,
575 lines (530 loc) • 19.7 kB
text/typescript
import { Connection, TelegramClient, version } from "../";
import { sleep } from "../Helpers";
import {
ConnectionTCPFull,
ConnectionTCPObfuscated,
} from "../network/connection";
import { Session, StoreSession } from "../sessions";
import { Logger, PromisedNetSockets, PromisedWebSockets } from "../extensions";
import { Api } from "../tl";
import os from "./os";
import type { AuthKey } from "../crypto/AuthKey";
import { EntityCache } from "../entityCache";
import type { ParseInterface } from "./messageParse";
import type { EventBuilder } from "../events/common";
import { MarkdownParser } from "../extensions/markdown";
import { MTProtoSender } from "../network";
import { LAYER } from "../tl/AllTLObjects";
import {
ConnectionTCPMTProxyAbridged,
ProxyInterface,
} from "../network/connection/TCPMTProxy";
import { Semaphore } from "async-mutex";
import { LogLevel } from "../extensions/Logger";
import { isBrowser, isNode } from "../platform";
const EXPORTED_SENDER_RECONNECT_TIMEOUT = 1000; // 1 sec
const EXPORTED_SENDER_RELEASE_TIMEOUT = 30000; // 30 sec
const DEFAULT_DC_ID = 4;
const DEFAULT_IPV4_IP = isNode ? "149.154.167.91" : "vesta.web.telegram.org";
const DEFAULT_IPV6_IP = "2001:067c:04e8:f004:0000:0000:0000:000a";
/**
* Interface for creating a new client.
* All of these have a default value and you should only change those if you know what you are doing.
*/
export interface TelegramClientParams {
/** The connection instance to be used when creating a new connection to the servers. It must be a type.<br/>
* Defaults to {@link ConnectionTCPFull} on Node and {@link ConnectionTCPObfuscated} on browsers.
*/
connection?: typeof Connection;
/**
* Whether to connect to the servers through IPv6 or not. By default this is false.
*/
useIPV6?: boolean;
/**
* The timeout in seconds to be used when connecting. This does nothing for now.
*/
timeout?: number;
/**
* How many times a request should be retried.<br/>
* Request are retried when Telegram is having internal issues (due to INTERNAL error or RPC_CALL_FAIL error),<br/>
* when there is a errors.FloodWaitError less than floodSleepThreshold, or when there's a migrate error.<br/>
* defaults to 5.
*/
requestRetries?: number;
/**
* How many times the reconnection should retry, either on the initial connection or when Telegram disconnects us.<br/>
* May be set to a negative or undefined value for infinite retries, but this is not recommended, since the program can get stuck in an infinite loop.<br/>
* defaults to 5
*/
connectionRetries?: number;
/**
* Experimental proxy to be used for the connection. (only supports MTProxies)
*/
proxy?: ProxyInterface;
/**
* How many times we should retry borrowing a sender from another DC when it fails. defaults to 5
*/
downloadRetries?: number;
/** The delay in milliseconds to sleep between automatic reconnections. defaults to 1000*/
retryDelay?: number;
/**Whether reconnection should be retried connection_retries times automatically if Telegram disconnects us or not. defaults to true */
autoReconnect?: boolean;
/** does nothing for now */
sequentialUpdates?: boolean;
/** The threshold below which the library should automatically sleep on flood wait and slow mode wait errors (inclusive).<br/>
* For instance, if a FloodWaitError for 17s occurs and floodSleepThreshold is 20s, the library will sleep automatically.<br/>
* If the error was for 21s, it would raise FloodWaitError instead. defaults to 60 sec.*/
floodSleepThreshold?: number;
/**
* Device model to be sent when creating the initial connection. Defaults to os.type().toString().
*/
deviceModel?: string;
/**
* System version to be sent when creating the initial connection. defaults to os.release().toString() -.
*/
systemVersion?: string;
/**
* App version to be sent when creating the initial connection. Defaults to 1.0.
*/
appVersion?: string;
/**
* Language code to be sent when creating the initial connection. Defaults to 'en'.
*/
langCode?: string;
/**
* System lang code to be sent when creating the initial connection. Defaults to 'en'.
*/
systemLangCode?: string;
/**
* Instance of Logger to use. <br />
* If a `Logger` is given, it'll be used directly. If nothing is given, the default logger will be used. <br />
* To create your own Logger make sure you extends GramJS logger {@link Logger} and override `log` method.
*/
baseLogger?: Logger;
/**
* Whether to try to connect over Wss (or 443 port) or not.
*/
useWSS?: boolean;
/**
* Limits how many downloads happen at the same time.
*/
maxConcurrentDownloads?: number;
/**
* Whether to check for tampering in messages or not.
*/
securityChecks?: boolean;
/**
* Only for web DCs. Whether to use test servers or not.
*/
testServers?: boolean;
/**
* What type of network connection to use (Normal Socket (for node) or Websockets (for browsers usually) )
*/
networkSocket?: typeof PromisedNetSockets | typeof PromisedWebSockets;
}
const clientParamsDefault = {
connection: isNode ? ConnectionTCPFull : ConnectionTCPObfuscated,
networkSocket: isNode ? PromisedNetSockets : PromisedWebSockets,
useIPV6: false,
timeout: 10,
requestRetries: 5,
connectionRetries: Infinity,
retryDelay: 1000,
downloadRetries: 5,
autoReconnect: true,
sequentialUpdates: false,
floodSleepThreshold: 60,
deviceModel: "",
systemVersion: "",
appVersion: "",
langCode: "en",
systemLangCode: "en",
_securityChecks: true,
useWSS: isBrowser ? window.location.protocol == "https:" : false,
testServers: false,
};
export abstract class TelegramBaseClient {
/** The current gramJS version. */
__version__ = version;
/** @hidden */
_config?: Api.Config;
/** @hidden */
public _log: Logger;
/** @hidden */
public _floodSleepThreshold: number;
public session: Session;
public apiHash: string;
public apiId: number;
/** @hidden */
public _requestRetries: number;
/** @hidden */
public _downloadRetries: number;
/** @hidden */
public _connectionRetries: number;
/** @hidden */
public _retryDelay: number;
/** @hidden */
public _timeout: number;
/** @hidden */
public _autoReconnect: boolean;
/** @hidden */
public _connection: typeof Connection;
/** @hidden */
public _initRequest: Api.InitConnection;
/** @hidden */
public _sender?: MTProtoSender;
/** @hidden */
public _floodWaitedRequests: any;
/** @hidden */
public _borrowedSenderPromises: any;
/** @hidden */
public _bot?: boolean;
/** @hidden */
public _useIPV6: boolean;
/** @hidden */
public _selfInputPeer?: Api.InputPeerUser;
/** @hidden */
public useWSS: boolean;
/** @hidden */
public _eventBuilders: [EventBuilder, CallableFunction][];
/** @hidden */
public _entityCache: EntityCache;
/** @hidden */
public _lastRequest?: number;
/** @hidden */
public _parseMode?: ParseInterface;
/** @hidden */
public _ALBUMS = new Map<
string,
[ReturnType<typeof setTimeout>, Api.TypeUpdate[]]
>();
/** @hidden */
private _exportedSenderPromises = new Map<number, Promise<MTProtoSender>>();
/** @hidden */
private _exportedSenderReleaseTimeouts = new Map<
number,
ReturnType<typeof setTimeout>
>();
/** @hidden */
protected _loopStarted: boolean;
/** @hidden */
_reconnecting: boolean;
/** @hidden */
_destroyed: boolean;
/** @hidden */
protected _proxy?: ProxyInterface;
/** @hidden */
_semaphore: Semaphore;
/** @hidden */
_securityChecks: boolean;
/** @hidden */
public testServers: boolean;
/** @hidden */
public networkSocket: typeof PromisedNetSockets | typeof PromisedWebSockets;
constructor(
session: string | Session,
apiId: number,
apiHash: string,
clientParams: TelegramClientParams
) {
clientParams = { ...clientParamsDefault, ...clientParams };
if (!apiId || !apiHash) {
throw new Error("Your API ID or Hash cannot be empty or undefined");
}
if (clientParams.baseLogger) {
this._log = clientParams.baseLogger;
} else {
this._log = new Logger();
}
this._log.info("Running gramJS version " + version);
if (session && typeof session == "string") {
session = new StoreSession(session);
}
if (!(session instanceof Session)) {
throw new Error(
"Only StringSession and StoreSessions are supported currently :( "
);
}
this._floodSleepThreshold = clientParams.floodSleepThreshold!;
this.session = session;
this.apiId = apiId;
this.apiHash = apiHash;
this._useIPV6 = clientParams.useIPV6!;
this._requestRetries = clientParams.requestRetries!;
this._downloadRetries = clientParams.downloadRetries!;
this._connectionRetries = clientParams.connectionRetries!;
this._retryDelay = clientParams.retryDelay || 0;
this._timeout = clientParams.timeout!;
this._autoReconnect = clientParams.autoReconnect!;
this._proxy = clientParams.proxy;
this._semaphore = new Semaphore(
clientParams.maxConcurrentDownloads || 1
);
this.testServers = clientParams.testServers || false;
this.networkSocket = clientParams.networkSocket || PromisedNetSockets;
if (!(clientParams.connection instanceof Function)) {
throw new Error("Connection should be a class not an instance");
}
this._connection = clientParams.connection;
let initProxy;
if (this._proxy?.MTProxy) {
this._connection = ConnectionTCPMTProxyAbridged;
initProxy = new Api.InputClientProxy({
address: this._proxy!.ip,
port: this._proxy!.port,
});
}
this._initRequest = new Api.InitConnection({
apiId: this.apiId,
deviceModel:
clientParams.deviceModel || os.type().toString() || "Unknown",
systemVersion:
clientParams.systemVersion || os.release().toString() || "1.0",
appVersion: clientParams.appVersion || "1.0",
langCode: clientParams.langCode,
langPack: "", // this should be left empty.
systemLangCode: clientParams.systemLangCode,
proxy: initProxy,
});
this._eventBuilders = [];
this._floodWaitedRequests = {};
this._borrowedSenderPromises = {};
this._bot = undefined;
this._selfInputPeer = undefined;
this.useWSS = clientParams.useWSS!;
this._securityChecks = !!clientParams.securityChecks;
if (this.useWSS && this._proxy) {
throw new Error(
"Cannot use SSL with proxies. You need to disable the useWSS client param in TelegramClient"
);
}
this._entityCache = new EntityCache();
// These will be set later
this._config = undefined;
this._loopStarted = false;
this._reconnecting = false;
this._destroyed = false;
// parse mode
this._parseMode = MarkdownParser;
}
get floodSleepThreshold() {
return this._floodSleepThreshold;
}
set floodSleepThreshold(value: number) {
this._floodSleepThreshold = Math.min(value || 0, 24 * 60 * 60);
}
set maxConcurrentDownloads(value: number) {
// @ts-ignore
this._semaphore._value = value;
}
// region connecting
async _initSession() {
await this.session.load();
if (!this.session.serverAddress) {
this.session.setDC(
DEFAULT_DC_ID,
this._useIPV6 ? DEFAULT_IPV6_IP : DEFAULT_IPV4_IP,
this.useWSS ? 443 : 80
);
} else {
this._useIPV6 = this.session.serverAddress.includes(":");
}
}
get connected() {
return this._sender && this._sender.isConnected();
}
async disconnect() {
await this._disconnect();
await Promise.all(
Object.values(this._exportedSenderPromises).map(
(promise: Promise<MTProtoSender>) => {
return (
promise &&
promise.then((sender: MTProtoSender) => {
if (sender) {
return sender.disconnect();
}
return undefined;
})
);
}
)
);
this._exportedSenderPromises = new Map<
number,
Promise<MTProtoSender>
>();
// TODO cancel hanging promises
}
get disconnected() {
return !this._sender || this._sender._disconnected;
}
async _disconnect() {
await this._sender?.disconnect();
}
/**
* Disconnects all senders and removes all handlers
* Disconnect is safer as it will not remove your event handlers
*/
async destroy() {
this._destroyed = true;
await Promise.all([
this.disconnect(),
...Object.values(this._borrowedSenderPromises).map(
(promise: any) => {
return promise.then((sender: any) => sender.disconnect());
}
),
]);
this._eventBuilders = [];
}
/** @hidden */
async _authKeyCallback(authKey: AuthKey, dcId: number) {
this.session.setAuthKey(authKey, dcId);
await this.session.save();
}
/** @hidden */
async _cleanupExportedSender(dcId: number) {
if (this.session.dcId !== dcId) {
this.session.setAuthKey(undefined, dcId);
}
let sender = await this._exportedSenderPromises.get(dcId);
this._exportedSenderPromises.delete(dcId);
await sender?.disconnect();
}
/** @hidden */
async _connectSender(sender: MTProtoSender, dcId: number) {
// if we don't already have an auth key we want to use normal DCs not -1
const dc = await this.getDC(dcId, !!sender.authKey.getKey());
while (true) {
try {
await sender.connect(
new this._connection({
ip: dc.ipAddress,
port: dc.port,
dcId: dcId,
loggers: this._log,
proxy: this._proxy,
testServers: this.testServers,
socket: this.networkSocket,
})
);
if (this.session.dcId !== dcId && !sender._authenticated) {
this._log.info(
`Exporting authorization for data center ${dc.ipAddress} with layer ${LAYER}`
);
const auth = await this.invoke(
new Api.auth.ExportAuthorization({ dcId: dcId })
);
this._initRequest.query = new Api.auth.ImportAuthorization({
id: auth.id,
bytes: auth.bytes,
});
const req = new Api.InvokeWithLayer({
layer: LAYER,
query: this._initRequest,
});
await sender.send(req);
sender._authenticated = true;
}
sender.dcId = dcId;
sender.userDisconnected = false;
return sender;
} catch (err: any) {
if (err.errorMessage === "DC_ID_INVALID") {
sender._authenticated = true;
sender.userDisconnected = false;
return sender;
}
if (this._log.canSend(LogLevel.ERROR)) {
console.error(err);
}
await sleep(1000);
await sender.disconnect();
}
}
}
/** @hidden */
async _borrowExportedSender(
dcId: number,
shouldReconnect?: boolean,
existingSender?: MTProtoSender
): Promise<MTProtoSender> {
if (!this._exportedSenderPromises.get(dcId) || shouldReconnect) {
this._exportedSenderPromises.set(
dcId,
this._connectSender(
existingSender || this._createExportedSender(dcId),
dcId
)
);
}
let sender: MTProtoSender;
try {
sender = await this._exportedSenderPromises.get(dcId)!;
if (!sender.isConnected()) {
if (sender.isConnecting) {
await sleep(EXPORTED_SENDER_RECONNECT_TIMEOUT);
return this._borrowExportedSender(dcId, false, sender);
} else {
return this._borrowExportedSender(dcId, true, sender);
}
}
} catch (err) {
if (this._log.canSend(LogLevel.ERROR)) {
console.error(err);
}
return this._borrowExportedSender(dcId, true);
}
if (this._exportedSenderReleaseTimeouts.get(dcId)) {
clearTimeout(this._exportedSenderReleaseTimeouts.get(dcId)!);
this._exportedSenderReleaseTimeouts.delete(dcId);
}
this._exportedSenderReleaseTimeouts.set(
dcId,
setTimeout(() => {
this._exportedSenderReleaseTimeouts.delete(dcId);
sender.disconnect();
}, EXPORTED_SENDER_RELEASE_TIMEOUT)
);
return sender;
}
/** @hidden */
_createExportedSender(dcId: number) {
return new MTProtoSender(this.session.getAuthKey(dcId), {
logger: this._log,
dcId,
retries: this._connectionRetries,
delay: this._retryDelay,
autoReconnect: this._autoReconnect,
connectTimeout: this._timeout,
authKeyCallback: this._authKeyCallback.bind(this),
isMainSender: dcId === this.session.dcId,
onConnectionBreak: this._cleanupExportedSender.bind(this),
client: this as unknown as TelegramClient,
securityChecks: this._securityChecks,
});
}
/** @hidden */
getSender(dcId: number): Promise<MTProtoSender> {
return dcId
? this._borrowExportedSender(dcId)
: Promise.resolve(this._sender!);
}
// endregion
async getDC(
dcId: number,
download: boolean
): Promise<{ id: number; ipAddress: string; port: number }> {
throw new Error("Cannot be called from here!");
}
invoke<R extends Api.AnyRequest>(request: R): Promise<R["__response"]> {
throw new Error("Cannot be called from here!");
}
setLogLevel(level: LogLevel) {
this._log.setLevel(level);
}
get logger() {
return this._log;
}
}