From e2464f7f3fcf1a63ba783db1c2eeeabf63d91b24 Mon Sep 17 00:00:00 2001 From: alina sireneva Date: Fri, 28 Jun 2024 17:30:47 +0300 Subject: [PATCH] feat(core): qr code login --- packages/core/scripts/generate-client.cjs | 2 + packages/core/src/highlevel/base.ts | 14 +- packages/core/src/highlevel/client.ts | 61 ++++++++ packages/core/src/highlevel/client.types.ts | 6 +- packages/core/src/highlevel/methods.ts | 1 + .../core/src/highlevel/methods/_imports.ts | 1 + .../src/highlevel/methods/auth/resend-code.ts | 18 ++- .../src/highlevel/methods/auth/send-code.ts | 26 ++-- .../src/highlevel/methods/auth/sign-in-qr.ts | 141 ++++++++++++++++++ .../src/highlevel/methods/auth/sign-in.ts | 19 ++- .../core/src/highlevel/methods/auth/start.ts | 39 ++++- .../core/src/highlevel/updates/manager.ts | 4 + packages/core/src/highlevel/worker/port.ts | 14 +- 13 files changed, 311 insertions(+), 35 deletions(-) create mode 100644 packages/core/src/highlevel/methods/auth/sign-in-qr.ts diff --git a/packages/core/scripts/generate-client.cjs b/packages/core/scripts/generate-client.cjs index 548f24f3..670d2052 100644 --- a/packages/core/scripts/generate-client.cjs +++ b/packages/core/scripts/generate-client.cjs @@ -743,6 +743,8 @@ withParams(params: RpcCallOptions): this\n`) 'computeSrpParams', 'computeNewPasswordHash', 'onConnectionState', + 'getServerUpdateHandler', + 'changePrimaryDc', ].forEach((name) => { output.write( `TelegramClient.prototype.${name} = function(...args) {\n` + diff --git a/packages/core/src/highlevel/base.ts b/packages/core/src/highlevel/base.ts index d2e87812..d02e3baf 100644 --- a/packages/core/src/highlevel/base.ts +++ b/packages/core/src/highlevel/base.ts @@ -15,7 +15,7 @@ import { writeStringSession, } from '../utils/index.js' import { LogManager } from '../utils/logger.js' -import { ConnectionState, ITelegramClient } from './client.types.js' +import { ConnectionState, ITelegramClient, ServerUpdateHandler } from './client.types.js' import { AppConfigManager } from './managers/app-config-manager.js' import { ITelegramStorageProvider } from './storage/provider.js' import { TelegramStorageManager, TelegramStorageManagerExtraOptions } from './storage/storage.js' @@ -30,7 +30,7 @@ export interface BaseTelegramClientOptions extends MtClientOptions { export class BaseTelegramClient implements ITelegramClient { readonly updates?: UpdatesManager - private _serverUpdatesHandler: (updates: tl.TypeUpdates) => void = () => {} + private _serverUpdatesHandler: ServerUpdateHandler = () => {} private _connectionStateHandler: (state: ConnectionState) => void = () => {} readonly log @@ -276,10 +276,14 @@ export class BaseTelegramClient implements ITelegramClient { this.updates?.handleClientUpdate(updates, noDispatch) } - onServerUpdate(handler: (update: tl.TypeUpdates) => void): void { + onServerUpdate(handler: ServerUpdateHandler): void { this._serverUpdatesHandler = handler } + getServerUpdateHandler(): ServerUpdateHandler { + return this._serverUpdatesHandler + } + onUpdate(handler: RawUpdateHandler): void { if (!this.updates) { throw new MtArgumentError('Updates manager is disabled') @@ -325,4 +329,8 @@ export class BaseTelegramClient implements ITelegramClient { get stopSignal(): AbortSignal { return this.mt.stopSignal } + + changePrimaryDc(dcId: number): Promise { + return this.mt.network.changePrimaryDc(dcId) + } } diff --git a/packages/core/src/highlevel/client.ts b/packages/core/src/highlevel/client.ts index ec4d191f..945cc347 100644 --- a/packages/core/src/highlevel/client.ts +++ b/packages/core/src/highlevel/client.ts @@ -21,6 +21,7 @@ import { sendCode } from './methods/auth/send-code.js' import { sendRecoveryCode } from './methods/auth/send-recovery-code.js' import { signIn } from './methods/auth/sign-in.js' import { signInBot } from './methods/auth/sign-in-bot.js' +import { signInQr } from './methods/auth/sign-in-qr.js' import { start } from './methods/auth/start.js' import { startTest } from './methods/auth/start-test.js' import { isSelfPeer } from './methods/auth/utils.js' @@ -262,6 +263,7 @@ import { BotReactionCountUpdate, BotReactionUpdate, BotStoppedUpdate, + BusinessCallbackQuery, BusinessChatLink, BusinessConnection, BusinessMessage, @@ -455,6 +457,13 @@ export interface TelegramClient extends ITelegramClient { * @param handler Inline callback query handler */ on(name: 'inline_callback_query', handler: (upd: InlineCallbackQuery) => void): this + /** + * Register a business callback query handler + * + * @param name Event name + * @param handler Business callback query handler + */ + on(name: 'business_callback_query', handler: (upd: BusinessCallbackQuery) => void): this /** * Register a poll update handler * @@ -647,6 +656,9 @@ export interface TelegramClient extends ITelegramClient { /** Confirmation code identifier from {@link SentCode} */ phoneCodeHash: string + + /** Abort signal */ + abortSignal?: AbortSignal }): Promise /** * Simple wrapper that calls {@link start} and then @@ -678,6 +690,9 @@ export interface TelegramClient extends ITelegramClient { /** Additional code settings to pass to the server */ codeSettings?: Omit + + /** Abort signal */ + abortSignal?: AbortSignal }): Promise /** * Send a code to email needed to recover your password @@ -697,6 +712,29 @@ export interface TelegramClient extends ITelegramClient { * @throws BadRequestError In case the bot token is invalid */ signInBot(token: string): Promise + + /** + * Execute the [QR login flow](https://core.telegram.org/api/qr-login). + * + * This method will resolve once the authorization is complete, + * returning the authorized user. + * **Available**: 👤 users only + * + */ + signInQr(params: { + /** + * Function that will be called whenever the login URL is changed. + * + * The app is expected to display `url` as a QR code to the user + */ + onUrlUpdated: (url: string, expires: Date) => void + + /** Password for 2FA */ + password?: MaybeDynamic + + /** Abort signal */ + abortSignal?: AbortSignal + }): Promise /** * Authorize a user in Telegram with a valid confirmation code. * @@ -713,6 +751,8 @@ export interface TelegramClient extends ITelegramClient { phoneCodeHash: string /** The confirmation code that was received */ phoneCode: string + /** Abort signal */ + abortSignal?: AbortSignal }): Promise /** * Utility function to quickly authorize on test DC @@ -778,6 +818,15 @@ export interface TelegramClient extends ITelegramClient { */ sessionForce?: boolean + /** + * When passed, [QR login flow](https://core.telegram.org/api/qr-login) + * will be used instead of the regular login flow. + * + * This function will be called whenever the login URL is changed, + * and the app is expected to display it as a QR code to the user. + */ + qrCodeHandler?: (url: string, expires: Date) => void + /** * Phone number of the account. * If account does not exist, it will be created @@ -830,6 +879,9 @@ export interface TelegramClient extends ITelegramClient { /** Additional code settings to pass to the server */ codeSettings?: Omit + + /** Abort signal */ + abortSignal?: AbortSignal }): Promise /** * Check if the given peer/input peer is referring to the current user @@ -5613,6 +5665,9 @@ TelegramClient.prototype.sendRecoveryCode = function (...args) { TelegramClient.prototype.signInBot = function (...args) { return signInBot(this._client, ...args) } +TelegramClient.prototype.signInQr = function (...args) { + return signInQr(this._client, ...args) +} TelegramClient.prototype.signIn = function (...args) { return signIn(this._client, ...args) } @@ -6428,6 +6483,12 @@ TelegramClient.prototype.computeNewPasswordHash = function (...args) { TelegramClient.prototype.onConnectionState = function (...args) { return this._client.onConnectionState(...args) } +TelegramClient.prototype.getServerUpdateHandler = function (...args) { + return this._client.getServerUpdateHandler(...args) +} +TelegramClient.prototype.changePrimaryDc = function (...args) { + return this._client.changePrimaryDc(...args) +} TelegramClient.prototype.onServerUpdate = function () { throw new Error('onServerUpdate is not available for TelegramClient, use .on() methods instead') } diff --git a/packages/core/src/highlevel/client.types.ts b/packages/core/src/highlevel/client.types.ts index 970a0ec8..f7ee9886 100644 --- a/packages/core/src/highlevel/client.types.ts +++ b/packages/core/src/highlevel/client.types.ts @@ -22,6 +22,8 @@ import type { StringSessionData } from './utils/string-session.js' */ export type ConnectionState = 'offline' | 'connecting' | 'updating' | 'connected' +export type ServerUpdateHandler = (update: tl.TypeUpdates) => void + // NB: when adding new methods, don't forget to add them to: // - worker/port.ts // - generate-client script @@ -51,7 +53,8 @@ export interface ITelegramClient { emitError(err: unknown): void handleClientUpdate(updates: tl.TypeUpdates, noDispatch?: boolean): void - onServerUpdate(handler: (update: tl.TypeUpdates) => void): void + onServerUpdate(handler: ServerUpdateHandler): void + getServerUpdateHandler(): ServerUpdateHandler onUpdate(handler: RawUpdateHandler): void onConnectionState(handler: (state: ConnectionState) => void): void @@ -61,6 +64,7 @@ export interface ITelegramClient { // or at least load this once at startup (and then these methods can be made sync) getPoolSize(kind: ConnectionKind, dcId?: number): Promise getPrimaryDcId(): Promise + changePrimaryDc(newDc: number): Promise computeSrpParams(request: tl.account.RawPassword, password: string): Promise computeNewPasswordHash(algo: tl.TypePasswordKdfAlgo, password: string): Promise diff --git a/packages/core/src/highlevel/methods.ts b/packages/core/src/highlevel/methods.ts index e7284798..ac341b44 100644 --- a/packages/core/src/highlevel/methods.ts +++ b/packages/core/src/highlevel/methods.ts @@ -10,6 +10,7 @@ export { sendCode } from './methods/auth/send-code.js' export { sendRecoveryCode } from './methods/auth/send-recovery-code.js' export { signIn } from './methods/auth/sign-in.js' export { signInBot } from './methods/auth/sign-in-bot.js' +export { signInQr } from './methods/auth/sign-in-qr.js' export { start } from './methods/auth/start.js' export { startTest } from './methods/auth/start-test.js' export { isSelfPeer } from './methods/auth/utils.js' diff --git a/packages/core/src/highlevel/methods/_imports.ts b/packages/core/src/highlevel/methods/_imports.ts index 72c12c7e..961b1069 100644 --- a/packages/core/src/highlevel/methods/_imports.ts +++ b/packages/core/src/highlevel/methods/_imports.ts @@ -26,6 +26,7 @@ import { BotReactionCountUpdate, BotReactionUpdate, BotStoppedUpdate, + BusinessCallbackQuery, BusinessChatLink, BusinessConnection, BusinessMessage, diff --git a/packages/core/src/highlevel/methods/auth/resend-code.ts b/packages/core/src/highlevel/methods/auth/resend-code.ts index 9f93ce68..da0c7d23 100644 --- a/packages/core/src/highlevel/methods/auth/resend-code.ts +++ b/packages/core/src/highlevel/methods/auth/resend-code.ts @@ -17,15 +17,21 @@ export async function resendCode( /** Confirmation code identifier from {@link SentCode} */ phoneCodeHash: string + + /** Abort signal */ + abortSignal?: AbortSignal }, ): Promise { - const { phone, phoneCodeHash } = params + const { phone, phoneCodeHash, abortSignal } = params - const res = await client.call({ - _: 'auth.resendCode', - phoneNumber: normalizePhoneNumber(phone), - phoneCodeHash, - }) + const res = await client.call( + { + _: 'auth.resendCode', + phoneNumber: normalizePhoneNumber(phone), + phoneCodeHash, + }, + { abortSignal }, + ) assertTypeIs('sendCode', res, 'auth.sentCode') diff --git a/packages/core/src/highlevel/methods/auth/send-code.ts b/packages/core/src/highlevel/methods/auth/send-code.ts index 5b685f85..be5fcf3a 100644 --- a/packages/core/src/highlevel/methods/auth/send-code.ts +++ b/packages/core/src/highlevel/methods/auth/send-code.ts @@ -21,23 +21,29 @@ export async function sendCode( /** Additional code settings to pass to the server */ codeSettings?: Omit + + /** Abort signal */ + abortSignal?: AbortSignal }, ): Promise { const phone = normalizePhoneNumber(params.phone) const { id, hash } = await client.getApiCrenetials() - const res = await client.call({ - _: 'auth.sendCode', - phoneNumber: phone, - apiId: id, - apiHash: hash, - settings: { - _: 'codeSettings', - logoutTokens: params.futureAuthTokens, - ...params.codeSettings, + const res = await client.call( + { + _: 'auth.sendCode', + phoneNumber: phone, + apiId: id, + apiHash: hash, + settings: { + _: 'codeSettings', + logoutTokens: params.futureAuthTokens, + ...params.codeSettings, + }, }, - }) + { abortSignal: params.abortSignal }, + ) assertTypeIs('sendCode', res, 'auth.sentCode') diff --git a/packages/core/src/highlevel/methods/auth/sign-in-qr.ts b/packages/core/src/highlevel/methods/auth/sign-in-qr.ts new file mode 100644 index 00000000..5245b55c --- /dev/null +++ b/packages/core/src/highlevel/methods/auth/sign-in-qr.ts @@ -0,0 +1,141 @@ +import { tl } from '@mtcute/tl' + +import { getPlatform } from '../../../platform.js' +import { ControllablePromise, createControllablePromise } from '../../../utils/controllable-promise.js' +import { sleepWithAbort } from '../../../utils/misc-utils.js' +import { assertTypeIs } from '../../../utils/type-assertions.js' +import { ITelegramClient, ServerUpdateHandler } from '../../client.types.js' +import { MaybeDynamic, User } from '../../types/index.js' +import { resolveMaybeDynamic } from '../../utils/misc-utils.js' +import { checkPassword } from './check-password.js' + +// @available=user +/** + * Execute the [QR login flow](https://core.telegram.org/api/qr-login). + * + * This method will resolve once the authorization is complete, + * returning the authorized user. + */ +export async function signInQr( + client: ITelegramClient, + params: { + /** + * Function that will be called whenever the login URL is changed. + * + * The app is expected to display `url` as a QR code to the user + */ + onUrlUpdated: (url: string, expires: Date) => void + + /** Password for 2FA */ + password?: MaybeDynamic + + /** Abort signal */ + abortSignal?: AbortSignal + }, +): Promise { + const { onUrlUpdated, abortSignal } = params + + let waiter: ControllablePromise | undefined + + // crutch – we need to wait for the updateLoginToken update. + // we replace the server update handler temporarily because: + // - updates manager may be disabled, in which case `onUpdate` will never be called + // - even if the updates manager is enabled, it won't start until we're logged in + // + // todo: how can we make this more clean? + const originalHandler = client.getServerUpdateHandler() + + const onUpdate: ServerUpdateHandler = (upd) => { + if (upd._ === 'updateShort' && upd.update._ === 'updateLoginToken') { + waiter?.resolve() + client.onServerUpdate(originalHandler) + } + } + + client.onServerUpdate(onUpdate) + + abortSignal?.addEventListener('abort', () => { + client.onServerUpdate(originalHandler) + waiter?.reject(abortSignal.reason) + }) + + try { + const { id, hash } = await client.getApiCrenetials() + const platform = getPlatform() + + loop: while (true) { + let res: tl.auth.TypeLoginToken + + try { + res = await client.call( + { + _: 'auth.exportLoginToken', + apiId: id, + apiHash: hash, + exceptIds: [], + }, + { abortSignal }, + ) + } catch (e) { + if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED') && params.password) { + return checkPassword(client, await resolveMaybeDynamic(params.password)) + } + + throw e + } + + switch (res._) { + case 'auth.loginToken': + onUrlUpdated( + `tg://login?token=${platform.base64Encode(res.token, true)}`, + new Date(res.expires * 1000), + ) + + waiter = createControllablePromise() + await Promise.race([waiter, sleepWithAbort(res.expires * 1000 - Date.now(), client.stopSignal)]) + break + case 'auth.loginTokenMigrateTo': { + await client.changePrimaryDc(res.dcId) + + let res2: tl.auth.TypeLoginToken + + try { + res2 = await client.call( + { + _: 'auth.importLoginToken', + token: res.token, + }, + { abortSignal }, + ) + } catch (e) { + if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED') && params.password) { + return checkPassword(client, await resolveMaybeDynamic(params.password)) + } + + throw e + } + + assertTypeIs('auth.importLoginToken', res2, 'auth.loginTokenSuccess') + break loop + } + case 'auth.loginTokenSuccess': + break loop + } + } + + const [self] = await client.call( + { + _: 'users.getUsers', + id: [{ _: 'inputUserSelf' }], + }, + { abortSignal }, + ) + assertTypeIs('users.getUsers', self, 'user') + + await client.notifyLoggedIn(self) + + return new User(self) + } finally { + client.onServerUpdate(originalHandler) + } +} diff --git a/packages/core/src/highlevel/methods/auth/sign-in.ts b/packages/core/src/highlevel/methods/auth/sign-in.ts index 739a2c7b..b03b9cfd 100644 --- a/packages/core/src/highlevel/methods/auth/sign-in.ts +++ b/packages/core/src/highlevel/methods/auth/sign-in.ts @@ -19,16 +19,21 @@ export async function signIn( phoneCodeHash: string /** The confirmation code that was received */ phoneCode: string + /** Abort signal */ + abortSignal?: AbortSignal }, ): Promise { - const { phone, phoneCodeHash, phoneCode } = params + const { phone, phoneCodeHash, phoneCode, abortSignal } = params - const res = await client.call({ - _: 'auth.signIn', - phoneNumber: normalizePhoneNumber(phone), - phoneCodeHash, - phoneCode, - }) + const res = await client.call( + { + _: 'auth.signIn', + phoneNumber: normalizePhoneNumber(phone), + phoneCodeHash, + phoneCode, + }, + { abortSignal }, + ) return _onAuthorization(client, res) } diff --git a/packages/core/src/highlevel/methods/auth/start.ts b/packages/core/src/highlevel/methods/auth/start.ts index 876cdde0..2b0cb0b6 100644 --- a/packages/core/src/highlevel/methods/auth/start.ts +++ b/packages/core/src/highlevel/methods/auth/start.ts @@ -15,6 +15,7 @@ import { resendCode } from './resend-code.js' import { sendCode } from './send-code.js' import { signIn } from './sign-in.js' import { signInBot } from './sign-in-bot.js' +import { signInQr } from './sign-in-qr.js' // @available=both /** @@ -48,6 +49,15 @@ export async function start( */ sessionForce?: boolean + /** + * When passed, [QR login flow](https://core.telegram.org/api/qr-login) + * will be used instead of the regular login flow. + * + * This function will be called whenever the login URL is changed, + * and the app is expected to display it as a QR code to the user. + */ + qrCodeHandler?: (url: string, expires: Date) => void + /** * Phone number of the account. * If account does not exist, it will be created @@ -100,12 +110,17 @@ export async function start( /** Additional code settings to pass to the server */ codeSettings?: Omit + + /** Abort signal */ + abortSignal?: AbortSignal }, ): Promise { if (params.session) { await client.importSession(params.session, params.sessionForce) } + const { abortSignal } = params + let has2fa = false let sentCode: SentCode | undefined let phone: string | null = null @@ -128,7 +143,7 @@ export async function start( } // if has2fa == true, then we are half-logged in, but need to enter password - if (!has2fa) { + if (!has2fa && !params.qrCodeHandler) { if (!params.phone && !params.botToken) { throw new MtArgumentError('Neither phone nor bot token were provided') } @@ -156,6 +171,7 @@ export async function start( phone, futureAuthTokens: params.futureAuthTokens, codeSettings: params.codeSettings, + abortSignal, }) } catch (e) { if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED')) { @@ -168,7 +184,11 @@ export async function start( if (sentCode) { if (params.forceSms && (sentCode.type === 'app' || sentCode.type === 'email')) { - sentCode = await resendCode(client, { phone: phone!, phoneCodeHash: sentCode.phoneCodeHash }) + sentCode = await resendCode(client, { + phone: phone!, + phoneCodeHash: sentCode.phoneCodeHash, + abortSignal, + }) } if (params.codeSentCallback) { @@ -186,7 +206,12 @@ export async function start( if (!code) throw new tl.RpcError(400, 'PHONE_CODE_EMPTY') try { - return await signIn(client, { phone: phone!, phoneCodeHash: sentCode.phoneCodeHash, phoneCode: code }) + return await signIn(client, { + phone: phone!, + phoneCodeHash: sentCode.phoneCodeHash, + phoneCode: code, + abortSignal, + }) } catch (e) { if (!tl.RpcError.is(e)) throw e @@ -245,5 +270,13 @@ export async function start( } } + if (params.qrCodeHandler) { + return await signInQr(client, { + onUrlUpdated: params.qrCodeHandler, + password: params.password, + abortSignal, + }) + } + throw new MtArgumentError('Failed to log in with provided credentials') } diff --git a/packages/core/src/highlevel/updates/manager.ts b/packages/core/src/highlevel/updates/manager.ts index 01c60aa5..3cf17f3f 100644 --- a/packages/core/src/highlevel/updates/manager.ts +++ b/packages/core/src/highlevel/updates/manager.ts @@ -180,6 +180,10 @@ export class UpdatesManager { this._handler = handler } + getHandler(): RawUpdateHandler { + return this._handler + } + onCatchingUp(handler: (catchingUp: boolean) => void): void { this._onCatchingUp = handler } diff --git a/packages/core/src/highlevel/worker/port.ts b/packages/core/src/highlevel/worker/port.ts index 4fecea7b..11142b9f 100644 --- a/packages/core/src/highlevel/worker/port.ts +++ b/packages/core/src/highlevel/worker/port.ts @@ -1,7 +1,5 @@ -import { tl } from '@mtcute/tl' - import { LogManager } from '../../utils/logger.js' -import { ConnectionState, ITelegramClient } from '../client.types.js' +import { ConnectionState, ITelegramClient, ServerUpdateHandler } from '../client.types.js' import { PeersIndex } from '../types/peers/peers-index.js' import { RawUpdateHandler } from '../updates/types.js' import { AppConfigManagerProxy } from './app-config.js' @@ -37,6 +35,7 @@ export abstract class TelegramWorkerPort imp readonly getApiCrenetials readonly getPoolSize readonly getPrimaryDcId + readonly changePrimaryDc readonly computeSrpParams readonly computeNewPasswordHash readonly startUpdatesLoop @@ -71,6 +70,7 @@ export abstract class TelegramWorkerPort imp this.getApiCrenetials = bind('getApiCrenetials') this.getPoolSize = bind('getPoolSize') this.getPrimaryDcId = bind('getPrimaryDcId') + this.changePrimaryDc = bind('changePrimaryDc') this.computeSrpParams = bind('computeSrpParams') this.computeNewPasswordHash = bind('computeNewPasswordHash') this.startUpdatesLoop = bind('startUpdatesLoop') @@ -79,11 +79,15 @@ export abstract class TelegramWorkerPort imp abstract connectToWorker(worker: SomeWorker, handler: ClientMessageHandler): [SendFn, () => void] - private _serverUpdatesHandler: (updates: tl.TypeUpdates) => void = () => {} - onServerUpdate(handler: (updates: tl.TypeUpdates) => void): void { + private _serverUpdatesHandler: ServerUpdateHandler = () => {} + onServerUpdate(handler: ServerUpdateHandler): void { this._serverUpdatesHandler = handler } + getServerUpdateHandler(): ServerUpdateHandler { + return this._serverUpdatesHandler + } + private _errorHandler: (err: unknown) => void = () => {} onError(handler: (err: unknown) => void): void { this._errorHandler = handler