Files
Deal/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

1055 lines
47 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Grpc.Core;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Сессия тенанта: id тенанта, клиент Telegram и состояние входа (план Task 9, Sessions/TenantSession.cs).
///
/// Соответствует TelegramManager прототипа (telegram.py L82222) для одного тенанта: 1 аккаунт на
/// тенанта (Ruling 3/архитектура §7.1), фазы idle|phone|code|password|qr|ready, error/qrUrl/account.
/// Все операции сериализованы per-tenant семафором <see cref="_gate"/> (команды исполняются только
/// на сессии своего тенанта; Ruling 1). Клиент создаётся фабрикой под ключи приложения из запроса;
/// авторизованная сессия сохраняется в файл data/sessions/&lt;tenant&gt;.session (AES-GCM-обёртка).
/// QR-вход выполняется фоновой задачей: RPC возвращается после первого URL, сканирование/ошибки
/// обновляют состояние в фоне (как _wait_qr прототипа L302312).
/// </summary>
public sealed class TenantSession : IAsyncDisposable
{
private readonly ITelegramClientFactory _clientFactory;
private readonly SessionStore _sessionStore;
private readonly ILogger<TenantSession> _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;
/// <summary>
/// Realtime-listener сессии жив (включает RealtimeMonitorService при фазе Ready).
/// </summary>
public AuthPhase Phase => _phase;
/// <summary>
/// Событие входящего сообщения аккаунта (план Task 10; поднимается для всех текстовых сообщений
/// клиента). RealtimeListener службы подписывается на сессию и фильтрует по зеркалу мониторинга.
/// </summary>
public event Func<TelegramMessage, Task>? MessageReceived;
/// <summary>
/// Создаёт сессию тенанта (объект переиспользуется между входами/выходами).
/// </summary>
/// <param name="tenantId">Id тенанта (принадлежность сессии).</param>
/// <param name="clientFactory">Фабрика клиентов Telegram (реальная или фейк в тестах).</param>
/// <param name="sessionStore">Файловое хранилище сессий (шифрование at-rest).</param>
/// <param name="logger">Логгер.</param>
/// <param name="reconnectAttemptTimeout">Таймаут попытки переподключения (null — 10 с по умолчанию; тесты).</param>
public TenantSession(
string tenantId,
ITelegramClientFactory clientFactory,
SessionStore sessionStore,
ILogger<TenantSession> logger,
TimeSpan? reconnectAttemptTimeout = null)
{
TenantId = tenantId;
_clientFactory = clientFactory;
_sessionStore = sessionStore;
_logger = logger;
_reconnectAttemptTimeout = reconnectAttemptTimeout ?? DefaultReconnectAttemptTimeout;
}
/// <summary>
/// Id тенанта, которому принадлежит сессия (команды только своей сессии).
/// </summary>
public string TenantId { get; }
/// <summary>
/// Вход по номеру телефона: запросить SMS-код (start_phone L134147).
/// </summary>
/// <param name="apiId">api_id приложения (из тела запроса ядра).</param>
/// <param name="apiHash">api_hash приложения.</param>
/// <param name="phone">Номер телефона (международный формат).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Снимок состояния после операции (фаза "code").</returns>
/// <exception cref="SessionException">Нет ключей (INVALID_ARGUMENT) / ошибки Telegram.</exception>
public async Task<TenantSessionSnapshot> 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 L144147: фаза 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();
}
}
/// <summary>
/// Начать QR-вход (qr_start L286300): фаза "qr" + первый URL либо "ready", если уже вошли.
/// </summary>
/// <param name="apiId">api_id приложения.</param>
/// <param name="apiHash">api_hash приложения.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Снимок состояния: Qr с qrUrl или Ready (авторизация уже была).</returns>
/// <exception cref="SessionException">Нет ключей (INVALID_ARGUMENT) / ошибки Telegram до первого URL.</exception>
public async Task<TenantSessionSnapshot> 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 L291293: уже авторизованы — финализация, url пуст.
await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false);
return Snapshot();
}
if (_phase == AuthPhase.Qr && _qrWaitTask is { IsCompleted: false })
{
// Повторный вызов во время активного QR — вернуть текущий URL (python L294295).
return Snapshot();
}
CancelQrFlow();
_phase = AuthPhase.Qr;
_qrUrl = null;
var qrCts = new CancellationTokenSource();
_qrCts = qrCts;
var firstUrlTcs = new TaskCompletionSource<string?>(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 L306309.
_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();
}
}
/// <summary>
/// Отправить SMS-код (submit_code L149166). Фазы вне "code" — FAILED_PRECONDITION.
/// </summary>
/// <param name="code">Код из SMS/Telegram-сообщения.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Снимок состояния: "password" при 2FA либо "ready" после авторизации.</returns>
public async Task<TenantSessionSnapshot> 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();
}
}
/// <summary>
/// Отправить облачный пароль 2FA (submit_password L168176). Фазы вне "password" — FAILED_PRECONDITION.
/// </summary>
/// <param name="password">Облачный пароль.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Снимок состояния после операции (фаза "ready").</returns>
public async Task<TenantSessionSnapshot> 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();
}
}
/// <summary>
/// Отключить аккаунт и удалить сессию тенанта (disconnect L189207). Возвращает null — сессии больше нет.
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task<TenantSessionSnapshot?> 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();
}
}
/// <summary>
/// Снимок состояния для GetStatus; null — сессии тенанта нет (аккаунт не подключён).
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task<TenantSessionSnapshot?> GetSnapshotAsync(CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return _loggedOut || !_registered ? null : Snapshot();
}
finally
{
_gate.Release();
}
}
// --- Каталог и сообщения (план Task 10; исполняются на ready-сессии своего тенанта) ---
/// <summary>
/// Список диалогов аккаунта (refresh_dialogs L505519); фаза обязана быть ready.
/// </summary>
/// <param name="limit">Верхняя граница числа диалогов (прототип: 500).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Диалоги аккаунта (нейтральный вид).</returns>
public async Task<IReadOnlyList<TelegramDialog>> 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();
}
}
/// <summary>
/// Последние сообщения диалога (get_messages); фаза обязана быть ready.
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="limit">Сколько последних сообщений запросить.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Сообщения диалога (от новых к старым, непустые тексты).</returns>
public async Task<IReadOnlyList<TelegramMessage>> 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();
}
}
/// <summary>
/// Помечает диалог прочитанным (send_read_acknowledge); фаза обязана быть ready.
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
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 L622873) ---
/// <summary>
/// Глобальный поиск каналов/групп по ключу (discovery_search L624664); фаза ready.
/// </summary>
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
/// <param name="limit">Верхняя граница результата.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Найденные источники (нейтральный вид; личные чаты отсеивает ядро, Ruling 10).</returns>
public async Task<IReadOnlyList<TelegramDialog>> 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();
}
}
/// <summary>
/// Инфо об источнике для оценки кандидата (discovery_info L666716); фаза ready.
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Инфо (по умолчанию — только id; сбои определения не бросаются, см. ISessionClient).</returns>
public async Task<TelegramSourceInfo> 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();
}
}
/// <summary>
/// Выборка сообщений источника для оценки (discovery_read L718800); фаза ready.
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="limit">Размер выборки (limit ≤ 0 — пусто без сети).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Результат чтения (ok/messages либо no_history).</returns>
public async Task<DiscoveryReadResult> 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();
}
}
/// <summary>
/// Вступить в канал/группу по username (discovery_join L818839); фаза ready.
/// </summary>
/// <param name="username">Username (без «@»; нормализует DiscoveryOps).</param>
/// <param name="cancellationToken">Отмена операции.</param>
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();
}
}
/// <summary>
/// Выйти из канала/группы (discovery_leave L841848); фаза ready.
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
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();
}
}
/// <summary>
/// Ставит признак живого realtime-listener (для GetStatus.listener, L109).
/// </summary>
/// <param name="active">True — listener сессии подписан на события сообщений.</param>
public void SetListenerActive(bool active)
=> _listenerActive = active;
// Проверяет готовность сессии и соединения (фаза ready + клиент).
// cancellationToken: Отмена операции.
private async Task<ISessionClient> EnsureReadyConnectedAsync(CancellationToken cancellationToken)
{
ISessionClient client = RequireReadyClient();
if (client.IsConnected)
{
return client;
}
// Как refresh_dialogs L507508: разорванное соединение 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<TelegramMessage, Task>? subscribers = MessageReceived;
if (subscribers is null)
{
return;
}
foreach (Delegate subscriber in subscribers.GetInvocationList())
{
try
{
await ((Func<TelegramMessage, Task>)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;
/// <summary>
/// Авто-возобновление на старте (auto_resume L209222)...
/// Авто-возобновление на старте (auto_resume L209222): поднять клиент из сохранённой сессии;
/// авторизованная сессия → фаза "ready". Не бросает — сбои сети/сессии оставляют фазу idle.
/// </summary>
/// <param name="stored">Содержимое файла сессии тенанта.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>True — сессия возобновлена (ready); false — не авторизована/сбой.</returns>
public async Task<bool> 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 L219221: сбой не роняет старт — фаза idle, ошибка для статуса.
_logger.LogWarning(exception, "auto_resume {TenantId} пропущен", TenantId);
_phase = AuthPhase.Idle;
_error = exception is SessionException sessionException ? sessionException.Message : null;
return false;
}
}
finally
{
_gate.Release();
}
}
/// <summary>
/// Сердцебиение (heartbeat L318327): для фазы "ready" при обрыве соединения — повторный connect.
/// Ошибки только логируются; статус-error не меняется (как в прототипе).
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
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();
}
}
/// <summary>
/// Остановка (хост гасится): сохраняет текущие байты сессии (перешифровка при остановке, Ruling 3),
/// отменяет QR и освобождает клиент. Ошибки не бросаются (фоновая остановка).
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
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();
}
}
/// <inheritdoc />
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 L178187): аккаунт в статус, фаза "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<string?> 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<string?> WaitForFirstQrUrlAsync(Task qrTask, TaskCompletionSource<string?> firstUrlTcs, CancellationToken cancellationToken)
{
Task<string?> 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 L306309). Ошибку до первого 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);
}