using Grpc.Core; using Deal.Telegram.Telegram; namespace Deal.Telegram.Sessions; /// /// Сессия тенанта: id тенанта, клиент Telegram и состояние входа (план Task 9, Sessions/TenantSession.cs). /// /// Соответствует TelegramManager прототипа (telegram.py L82–222) для одного тенанта: 1 аккаунт на /// тенанта (Ruling 3/архитектура §7.1), фазы idle|phone|code|password|qr|ready, error/qrUrl/account. /// Все операции сериализованы per-tenant семафором (команды исполняются только /// на сессии своего тенанта; Ruling 1). Клиент создаётся фабрикой под ключи приложения из запроса; /// авторизованная сессия сохраняется в файл data/sessions/<tenant>.session (AES-GCM-обёртка). /// QR-вход выполняется фоновой задачей: RPC возвращается после первого URL, сканирование/ошибки /// обновляют состояние в фоне (как _wait_qr прототипа L302–312). /// public sealed class TenantSession : IAsyncDisposable { private readonly ITelegramClientFactory _clientFactory; private readonly SessionStore _sessionStore; private readonly ILogger _logger; private readonly SemaphoreSlim _gate = new(1, 1); // Таймаут одной попытки переподключения heartbeat по умолчанию (зависший connect не должен блокировать // heartbeat остальных тенантов и остановку хоста — замечание code-review). private static readonly TimeSpan DefaultReconnectAttemptTimeout = TimeSpan.FromSeconds(10); private readonly TimeSpan _reconnectAttemptTimeout; private ISessionClient? _client; private int _clientApiId; private string? _clientApiHash; private bool _registered; private bool _loggedOut; private AuthPhase _phase = AuthPhase.Idle; private string? _error; private string? _account; private string? _qrUrl; private string? _phone; private CancellationTokenSource? _qrCts; private Task? _qrWaitTask; private volatile bool _listenerActive; /// /// Realtime-listener сессии жив (включает RealtimeMonitorService при фазе Ready). /// public AuthPhase Phase => _phase; /// /// Событие входящего сообщения аккаунта (план Task 10; поднимается для всех текстовых сообщений /// клиента). RealtimeListener службы подписывается на сессию и фильтрует по зеркалу мониторинга. /// public event Func? MessageReceived; /// /// Создаёт сессию тенанта (объект переиспользуется между входами/выходами). /// /// Id тенанта (принадлежность сессии). /// Фабрика клиентов Telegram (реальная или фейк в тестах). /// Файловое хранилище сессий (шифрование at-rest). /// Логгер. /// Таймаут попытки переподключения (null — 10 с по умолчанию; тесты). public TenantSession( string tenantId, ITelegramClientFactory clientFactory, SessionStore sessionStore, ILogger logger, TimeSpan? reconnectAttemptTimeout = null) { TenantId = tenantId; _clientFactory = clientFactory; _sessionStore = sessionStore; _logger = logger; _reconnectAttemptTimeout = reconnectAttemptTimeout ?? DefaultReconnectAttemptTimeout; } /// /// Id тенанта, которому принадлежит сессия (команды только своей сессии). /// public string TenantId { get; } /// /// Вход по номеру телефона: запросить SMS-код (start_phone L134–147). /// /// api_id приложения (из тела запроса ядра). /// api_hash приложения. /// Номер телефона (международный формат). /// Отмена операции. /// Снимок состояния после операции (фаза "code"). /// Нет ключей (INVALID_ARGUMENT) / ошибки Telegram. public async Task StartPhoneAsync(int apiId, string apiHash, string phone, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ValidateApiKeys(apiId, apiHash); _registered = true; _loggedOut = false; _error = null; _phone = phone; CancelQrFlow(); await EnsureClientAsync(apiId, apiHash, cancellationToken).ConfigureAwait(false); if (_client!.IsAuthorized) { // Сессия уже авторизована (например, возобновлена на старте) — код не нужен. await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); return Snapshot(); } try { await _client.ConnectAsync(cancellationToken).ConfigureAwait(false); await _client.RequestCodeAsync(phone, cancellationToken).ConfigureAwait(false); } catch (SessionException exception) { // 1:1 start_phone L144–147: фаза idle + текст ошибки. _phase = AuthPhase.Idle; _error = exception.Message; throw; } catch (Exception exception) when (exception is not OperationCanceledException) { SessionException wrapped = new(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); _phase = AuthPhase.Idle; _error = wrapped.Message; throw wrapped; } _phase = AuthPhase.Code; return Snapshot(); } finally { _gate.Release(); } } /// /// Начать QR-вход (qr_start L286–300): фаза "qr" + первый URL либо "ready", если уже вошли. /// /// api_id приложения. /// api_hash приложения. /// Отмена операции. /// Снимок состояния: Qr с qrUrl или Ready (авторизация уже была). /// Нет ключей (INVALID_ARGUMENT) / ошибки Telegram до первого URL. public async Task StartQrAsync(int apiId, string apiHash, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ValidateApiKeys(apiId, apiHash); _registered = true; _loggedOut = false; _error = null; await EnsureClientAsync(apiId, apiHash, cancellationToken).ConfigureAwait(false); if (_client!.IsAuthorized) { // 1:1 qr_start L291–293: уже авторизованы — финализация, url пуст. await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); return Snapshot(); } if (_phase == AuthPhase.Qr && _qrWaitTask is { IsCompleted: false }) { // Повторный вызов во время активного QR — вернуть текущий URL (python L294–295). return Snapshot(); } CancelQrFlow(); _phase = AuthPhase.Qr; _qrUrl = null; var qrCts = new CancellationTokenSource(); _qrCts = qrCts; var firstUrlTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _qrWaitTask = RunQrFlowAsync(qrCts.Token, firstUrlTcs); string? firstUrl; try { firstUrl = await WaitForFirstQrUrlAsync(_qrWaitTask, firstUrlTcs, cancellationToken).ConfigureAwait(false); } catch (SessionException exception) { // Ошибка до первого URL (сеть/Telegram): сброс к idle + текст ошибки, как _wait_qr L306–309. _phase = AuthPhase.Idle; _qrUrl = null; _error = exception.Message; throw; } catch (OperationCanceledException) { if (_phase == AuthPhase.Qr) { _phase = AuthPhase.Idle; _qrUrl = null; } // Отмена ожидания до первого URL отменяет и фоновый QR-вход (_qrCts): иначе задача // LoginWithQRCode продолжала бы авторизацию «скрыто» после отмены RPC (замечание code-review). CancelQrFlow(); throw; } if (firstUrl is not null) { _qrUrl = firstUrl; } // Задача завершилась без URL — авторизация произошла мгновенно (фаза Ready выставлена задачей) // либо URL пришёл (фаза Qr). Возвращаем актуальный снимок. return Snapshot(); } finally { _gate.Release(); } } /// /// Отправить SMS-код (submit_code L149–166). Фазы вне "code" — FAILED_PRECONDITION. /// /// Код из SMS/Telegram-сообщения. /// Отмена операции. /// Снимок состояния: "password" при 2FA либо "ready" после авторизации. public async Task SendCodeAsync(string code, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { EnsureLoginStarted(); if (_phase != AuthPhase.Code || _client is null) { throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.CodeNotRequested); } _error = null; string? nextStep; try { nextStep = await _client.SubmitCodeAsync(code, cancellationToken).ConfigureAwait(false); } catch (SessionException exception) { // Неверный/истёкший код — фаза остаётся "code" (повтор ввода, как в прототипе). _error = exception.Message; throw; } if (nextStep == "password") { _phase = AuthPhase.Password; return Snapshot(); } await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); return Snapshot(); } finally { _gate.Release(); } } /// /// Отправить облачный пароль 2FA (submit_password L168–176). Фазы вне "password" — FAILED_PRECONDITION. /// /// Облачный пароль. /// Отмена операции. /// Снимок состояния после операции (фаза "ready"). public async Task SendPasswordAsync(string password, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { EnsureLoginStarted(); if (_phase != AuthPhase.Password || _client is null) { throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.PasswordNotRequested); } _error = null; try { await _client.SubmitPasswordAsync(password, cancellationToken).ConfigureAwait(false); } catch (SessionException exception) { // Неверный пароль — фаза остаётся "password" (повтор ввода, как в прототипе). _error = exception.Message; throw; } await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); return Snapshot(); } finally { _gate.Release(); } } /// /// Отключить аккаунт и удалить сессию тенанта (disconnect L189–207). Возвращает null — сессии больше нет. /// /// Отмена операции. public async Task LogoutAsync(CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { CancelQrFlow(); if (_client is not null) { try { await _client.LogOutAsync(cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { // Auth_LogOut недоступен (сеть) — локальный выход и удаление файла всё равно выполняются. _logger.LogWarning(exception, "Logout {TenantId}: Auth_LogOut не выполнен — продолжаем локальный выход", TenantId); } try { await _sessionStore.DeleteAsync(TenantId, cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { _logger.LogWarning(exception, "Logout {TenantId}: файл сессии не удалён", TenantId); } DetachClientMessages(_client); await _client.DisposeAsync().ConfigureAwait(false); _client = null; } _clientApiId = 0; _clientApiHash = null; _phase = AuthPhase.Idle; _error = null; _account = null; _qrUrl = null; _phone = null; _loggedOut = true; _registered = false; return null; } finally { _gate.Release(); } } /// /// Снимок состояния для GetStatus; null — сессии тенанта нет (аккаунт не подключён). /// /// Отмена операции. public async Task GetSnapshotAsync(CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { return _loggedOut || !_registered ? null : Snapshot(); } finally { _gate.Release(); } } // --- Каталог и сообщения (план Task 10; исполняются на ready-сессии своего тенанта) --- /// /// Список диалогов аккаунта (refresh_dialogs L505–519); фаза обязана быть ready. /// /// Верхняя граница числа диалогов (прототип: 500). /// Отмена операции. /// Диалоги аккаунта (нейтральный вид). public async Task> ListDialogsAsync(int limit, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); return await client.GetDialogsAsync(limit, cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } } /// /// Последние сообщения диалога (get_messages); фаза обязана быть ready. /// /// Подписанный id диалога. /// Сколько последних сообщений запросить. /// Отмена операции. /// Сообщения диалога (от новых к старым, непустые тексты). public async Task> GetMessagesAsync(string dialogId, int limit, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); return await client.GetMessagesAsync(dialogId, limit, cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } } /// /// Помечает диалог прочитанным (send_read_acknowledge); фаза обязана быть ready. /// /// Подписанный id диалога. /// Отмена операции. public async Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); await client.MarkReadAsync(dialogId, cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } } // --- Discovery (план Task 11; discovery_search/info/read/join/leave L622–873) --- /// /// Глобальный поиск каналов/групп по ключу (discovery_search L624–664); фаза ready. /// /// Поисковый запрос (ключ задачи discovery). /// Верхняя граница результата. /// Отмена операции. /// Найденные источники (нейтральный вид; личные чаты отсеивает ядро, Ruling 10). public async Task> SearchAsync(string query, int limit, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); return await client.SearchAsync(query, limit, cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } } /// /// Инфо об источнике для оценки кандидата (discovery_info L666–716); фаза ready. /// /// Подписанный id источника. /// Отмена операции. /// Инфо (по умолчанию — только id; сбои определения не бросаются, см. ISessionClient). public async Task GetInfoAsync(string dialogId, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); return await client.GetInfoAsync(dialogId, cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } } /// /// Выборка сообщений источника для оценки (discovery_read L718–800); фаза ready. /// /// Подписанный id источника. /// Размер выборки (limit ≤ 0 — пусто без сети). /// Отмена операции. /// Результат чтения (ok/messages либо no_history). public async Task ReadForEvalAsync(string dialogId, int limit, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); return await client.ReadForEvalAsync(dialogId, limit, cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } } /// /// Вступить в канал/группу по username (discovery_join L818–839); фаза ready. /// /// Username (без «@»; нормализует DiscoveryOps). /// Отмена операции. public async Task JoinAsync(string username, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); await client.JoinAsync(username, cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } } /// /// Выйти из канала/группы (discovery_leave L841–848); фаза ready. /// /// Подписанный id диалога. /// Отмена операции. public async Task LeaveAsync(string dialogId, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); await client.LeaveAsync(dialogId, cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } } /// /// Ставит признак живого realtime-listener (для GetStatus.listener, L109). /// /// True — listener сессии подписан на события сообщений. public void SetListenerActive(bool active) => _listenerActive = active; // Проверяет готовность сессии и соединения (фаза ready + клиент). // cancellationToken: Отмена операции. private async Task EnsureReadyConnectedAsync(CancellationToken cancellationToken) { ISessionClient client = RequireReadyClient(); if (client.IsConnected) { return client; } // Как refresh_dialogs L507–508: разорванное соединение ready-сессии поднимаем перед операцией. try { await client.ConnectAsync(cancellationToken).ConfigureAwait(false); } catch (SessionException) { throw; } catch (Exception exception) when (exception is not OperationCanceledException) { throw new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); } return client; } // Ready-клиент сессии (иначе «Telegram не подключён», FAILED_PRECONDITION). private ISessionClient RequireReadyClient() { if (_loggedOut || !_registered || _client is null || _phase != AuthPhase.Ready) { throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected); } return _client; } // --- Проброс realtime-сообщений клиента на уровень службы --- // Передаёт сообщение клиента подписчикам сессии (каждый в своей ошибко-изоляции). // message: Входящее сообщение аккаунта. private async Task ForwardClientMessageAsync(TelegramMessage message) { Func? subscribers = MessageReceived; if (subscribers is null) { return; } foreach (Delegate subscriber in subscribers.GetInvocationList()) { try { await ((Func)subscriber)(message).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { _logger.LogWarning(exception, "Обработчик сообщения {TenantId} завершился с ошибкой", TenantId); } } } // Подписывает проброс сообщений нового клиента (вызывается после создания клиента). // client: Новый клиент сессии. private void AttachClientMessages(ISessionClient client) => client.MessageReceived += ForwardClientMessageAsync; // Отписывает проброс сообщений клиента (перед Dispose клиента). // client: Уходящий клиент сессии. private void DetachClientMessages(ISessionClient client) => client.MessageReceived -= ForwardClientMessageAsync; /// /// Авто-возобновление на старте (auto_resume L209–222)... /// Авто-возобновление на старте (auto_resume L209–222): поднять клиент из сохранённой сессии; /// авторизованная сессия → фаза "ready". Не бросает — сбои сети/сессии оставляют фазу idle. /// /// Содержимое файла сессии тенанта. /// Отмена операции. /// True — сессия возобновлена (ready); false — не авторизована/сбой. public async Task TryResumeAsync(StoredSession stored, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ValidateApiKeys(stored.ApiId, stored.ApiHash); _registered = true; _loggedOut = false; _error = null; if (_client is not null && _client.ApiId == stored.ApiId && _client.ApiHash == stored.ApiHash) { // Клиент уже создан под те же ключи (например, жив после входа в этой сессии). } else { if (_client is not null) { DetachClientMessages(_client); await _client.DisposeAsync().ConfigureAwait(false); } _client = _clientFactory.Create(stored.ApiId, stored.ApiHash, stored.SessionBytes); _clientApiId = stored.ApiId; _clientApiHash = stored.ApiHash; AttachClientMessages(_client); } try { await _client!.ConnectAsync(cancellationToken).ConfigureAwait(false); if (!_client.IsAuthorized) { _phase = AuthPhase.Idle; return false; } await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); return true; } catch (Exception exception) when (exception is not OperationCanceledException) { // 1:1 auto_resume L219–221: сбой не роняет старт — фаза idle, ошибка для статуса. _logger.LogWarning(exception, "auto_resume {TenantId} пропущен", TenantId); _phase = AuthPhase.Idle; _error = exception is SessionException sessionException ? sessionException.Message : null; return false; } } finally { _gate.Release(); } } /// /// Сердцебиение (heartbeat L318–327): для фазы "ready" при обрыве соединения — повторный connect. /// Ошибки только логируются; статус-error не меняется (как в прототипе). /// /// Отмена операции. public async Task TryReconnectAsync(CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (_loggedOut || _client is null || _phase != AuthPhase.Ready || _client.IsConnected) { return; } try { // Собственный лимит попытки: linked-токен с CancelAfter на время попытки переподключения. // Зависший connect не держит _gate (heartbeat остальных тенантов и shutdown не блокируются). using CancellationTokenSource attemptTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); attemptTimeout.CancelAfter(_reconnectAttemptTimeout); await _client.ConnectAsync(attemptTimeout.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { // Таймаут собственной попытки — не отмена хоста: предупреждение, повтор следующим циклом. _logger.LogWarning( "Heartbeat {TenantId}: таймаут переподключения ({TimeoutSeconds:0} с) — повторим следующим циклом", TenantId, _reconnectAttemptTimeout.TotalSeconds); } catch (Exception exception) when (exception is not OperationCanceledException) { _logger.LogWarning(exception, "Heartbeat {TenantId}: переподключение не удалось — повторим следующим циклом", TenantId); } } finally { _gate.Release(); } } /// /// Остановка (хост гасится): сохраняет текущие байты сессии (перешифровка при остановке, Ruling 3), /// отменяет QR и освобождает клиент. Ошибки не бросаются (фоновая остановка). /// /// Отмена операции. public async Task FlushAndDisposeAsync(CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { CancelQrFlow(); if (_client is not null) { try { await PersistCurrentSessionAsync(cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { _logger.LogWarning(exception, "Остановка {TenantId}: сессия не сохранена", TenantId); } DetachClientMessages(_client); await _client.DisposeAsync().ConfigureAwait(false); _client = null; } } finally { _gate.Release(); } } /// public async ValueTask DisposeAsync() { // Сброс без сетевых операций: авторизованные сессии уже сохранены на ключевых событиях // (FlushAndDisposeAsync зовёт хост при остановке; здесь — финальная очистка клиента). await _gate.WaitAsync().ConfigureAwait(false); try { CancelQrFlow(); if (_client is not null) { DetachClientMessages(_client); await _client.DisposeAsync().ConfigureAwait(false); _client = null; } } finally { _gate.Release(); } } // --- внутренние помощники (вызываются под _gate) --- // Проверяет, что сессия тенанта существует и не закрыта (иначе «Telegram не подключён»). private void EnsureLoginStarted() { if (_loggedOut || !_registered) { throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected); } } // Ключи приложения обязательны (ядро передаёт их в теле; Ruling 3). private static void ValidateApiKeys(int apiId, string apiHash) { if (apiId <= 0 || string.IsNullOrWhiteSpace(apiHash)) { throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.NoApiKeys); } } // Гарантирует клиент под запрошенные ключи: существующий клиент с теми же ключами переиспользуется; // при смене ключей живая сессия сначала сохраняется (свои ключи), затем клиент пересоздаётся; // сохранённая сессия с диска подсевается только при совпадении ключей приложения. // apiId: api_id приложения. // apiHash: api_hash приложения. // cancellationToken: Отмена операции. private async Task EnsureClientAsync(int apiId, string apiHash, CancellationToken cancellationToken) { if (_client is not null && _clientApiId == apiId && _clientApiHash == apiHash) { return; } if (_client is not null) { DetachClientMessages(_client); try { await PersistCurrentSessionAsync(cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { _logger.LogWarning(exception, "Сессия {TenantId}: предыдущий клиент не сохранён при смене ключей", TenantId); } await _client.DisposeAsync().ConfigureAwait(false); _client = null; } StoredSession? stored = await _sessionStore.LoadAsync(TenantId, cancellationToken).ConfigureAwait(false); byte[]? seed = stored is not null && stored.ApiId == apiId && stored.ApiHash == apiHash ? stored.SessionBytes : null; _client = _clientFactory.Create(apiId, apiHash, seed); _clientApiId = apiId; _clientApiHash = apiHash; AttachClientMessages(_client); } // Финализация авторизации (_finalize L178–187): аккаунт в статус, фаза "ready", сессия сохранена. // Сбой get_me не отменяет готовность — сохраняем сессию без account (готовность важнее имени). // cancellationToken: Отмена операции. private async Task CompleteAuthorizationAsync(CancellationToken cancellationToken) { if (_client is null) { return; } try { _account = await _client.GetAccountAsync(cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { _logger.LogWarning(exception, "Финализация {TenantId}: account не получен — готовность сохраняется", TenantId); } _phase = AuthPhase.Ready; _qrUrl = null; _error = null; await PersistCurrentSessionAsync(cancellationToken).ConfigureAwait(false); } // Шифрует и сохраняет текущие байты сессии в файл (если клиент их уже сформировал). // cancellationToken: Отмена операции. private async Task PersistCurrentSessionAsync(CancellationToken cancellationToken) { byte[]? sessionBytes = _client?.SessionBytes; if (_client is null || sessionBytes is not { Length: > 0 }) { return; } await _sessionStore.SaveAsync( TenantId, new StoredSession { ApiId = _client.ApiId, ApiHash = _client.ApiHash, SessionBytes = sessionBytes, }, cancellationToken).ConfigureAwait(false); } // Отменяет активный QR-вход (без ожидания фоновой задачи: её guard увидит смену фазы). private void CancelQrFlow() { if (_qrCts is not null) { _qrCts.Cancel(); _qrCts.Dispose(); _qrCts = null; } _qrWaitTask = null; } // Фоновая задача QR-входа: ждёт сканирования; URL обновляет колбэк, после авторизации под замком // финализирует сессию. Ошибка до первого URL пробрасывается (StartQrAsync держит замок и сам // сбросит фазу); после выдачи URL состояние обновляется здесь под замком. // cancellationToken: Токен отмены QR (StartPhone/Logout/остановка). // firstUrlTcs: Завершается первым URL (StartQrAsync ждёт его под замком). private async Task RunQrFlowAsync(CancellationToken cancellationToken, TaskCompletionSource firstUrlTcs) { ISessionClient client = _client!; bool urlAlreadyDelivered; try { await client.StartQrAsync( url => { if (_phase == AuthPhase.Qr) { _qrUrl = url; } firstUrlTcs.TrySetResult(url); }, cancellationToken).ConfigureAwait(false); // Сканирование принято — авторизация завершена: финализировать под замком. await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (!_loggedOut && ReferenceEquals(_client, client) && _phase == AuthPhase.Qr) { await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); } } finally { _gate.Release(); } } catch (OperationCanceledException) { urlAlreadyDelivered = firstUrlTcs.Task.IsCompletedSuccessfully; if (urlAlreadyDelivered) { await ResetQrUnderGateAsync().ConfigureAwait(false); return; } firstUrlTcs.TrySetCanceled(); throw; } catch (SessionException exception) { urlAlreadyDelivered = firstUrlTcs.Task.IsCompletedSuccessfully; if (urlAlreadyDelivered) { await FailQrUnderGateAsync(exception).ConfigureAwait(false); return; } firstUrlTcs.TrySetException(exception); throw; } catch (Exception exception) when (exception is not OperationCanceledException) { SessionException wrapped = new(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); urlAlreadyDelivered = firstUrlTcs.Task.IsCompletedSuccessfully; if (urlAlreadyDelivered) { await FailQrUnderGateAsync(wrapped).ConfigureAwait(false); return; } firstUrlTcs.TrySetException(wrapped); throw wrapped; } } // Ждёт первый URL QR (или завершение/ошибку фоновой задачи). Вызывается из StartQrAsync под замком: // при ошибке/отмене до первого URL задача уже завершена — замок освобождается при unwind. // qrTask: Фоновая задача QR. // firstUrlTcs: TCS первого URL. // cancellationToken: Отмена операции. // Возвращает: Первый URL либо null (задача завершилась без URL). private static async Task WaitForFirstQrUrlAsync(Task qrTask, TaskCompletionSource firstUrlTcs, CancellationToken cancellationToken) { Task urlTask = firstUrlTcs.Task; Task completed = await Task.WhenAny(urlTask, qrTask).WaitAsync(cancellationToken).ConfigureAwait(false); if (completed == urlTask) { return await urlTask.ConfigureAwait(false); } // Задача завершилась без URL (ошибка/отмена/авторизация без URL) — проброс результата задачи. await qrTask.ConfigureAwait(false); return null; } // Сброс QR-состояния после отмены (URL уже был выдан, RPC вернулся): фаза idle, если QR всё ещё // владеет сессией (StartPhone/Logout уже сменили фазу/клиента — не трогаем). private async Task ResetQrUnderGateAsync() { await _gate.WaitAsync().ConfigureAwait(false); try { if (!_loggedOut && _phase == AuthPhase.Qr) { _phase = AuthPhase.Idle; _qrUrl = null; } } finally { _gate.Release(); } } // Обработка ошибки QR после того, как URL уже был выдан (RPC вернулся): фаза idle + текст ошибки // (1:1 _wait_qr L306–309). Ошибку до первого URL сбрасывает StartQrAsync (проброс через задачу). // exception: Ошибка QR-входа. private async Task FailQrUnderGateAsync(SessionException exception) { await _gate.WaitAsync().ConfigureAwait(false); try { if (!_loggedOut && _phase == AuthPhase.Qr) { _phase = AuthPhase.Idle; _qrUrl = null; _error = exception.Message; } } finally { _gate.Release(); } } // Строит снимок текущего состояния (без проверки _registered — вызывается под замком). private TenantSessionSnapshot Snapshot() => new( _phase, connected: _client?.IsConnected ?? false, listener: _listenerActive, account: _phase == AuthPhase.Ready ? _account : null, error: _error, qrUrl: _phase == AuthPhase.Qr ? _qrUrl : null); }