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);
}