using System.Collections.Concurrent; using Grpc.Core; using Deal.Telegram.Telegram; namespace Deal.Telegram.Sessions; /// /// Пул сессий тенантов «1 аккаунт на тенанта» (план Task 9, Sessions/SessionFarm.cs; Ruling 3, /// архитектура §7.1). Сессия создаётся на первый вход/возобновление и переиспользуется (Logout /// сбрасывает её в состояние «отключено» — из карты объект не удаляется, гонок вызова нет). /// Все сетевые команды исполняются на сессии своего тенанта (Ruling 1); операций по чужим сессиям нет. /// public sealed class SessionFarm { private readonly ITelegramClientFactory _clientFactory; private readonly SessionStore _sessionStore; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); /// /// Создаёт пул сессий. /// /// Фабрика клиентов Telegram (реальная или фейк в тестах). /// Файловое хранилище сессий. /// Фабрика логгеров (логгеры TenantSession). /// Логгер. public SessionFarm( ITelegramClientFactory clientFactory, SessionStore sessionStore, ILoggerFactory loggerFactory, ILogger logger) { _clientFactory = clientFactory; _sessionStore = sessionStore; _loggerFactory = loggerFactory; _logger = logger; } /// /// Возвращает сессию тенанта, если она уже создана (иначе null — «Telegram не подключён»). /// /// Id тенанта. public TenantSession? FindSession(string tenantId) => _sessions.TryGetValue(tenantId, out TenantSession? session) ? session : null; /// /// Все сессии пула (снимок; для realtime-циклов каталога — RealtimeMonitorService/Sweep). /// public IReadOnlyCollection Sessions => _sessions.Values.ToArray(); /// /// Список диалогов аккаунта тенанта (только ready-сессия; план Task 10). /// /// Id тенанта. /// Верхняя граница числа диалогов. /// Отмена операции. public Task> ListDialogsAsync(string tenantId, int limit, CancellationToken cancellationToken) => RequireSession(tenantId).ListDialogsAsync(limit, cancellationToken); /// /// Последние сообщения диалога тенанта (только ready-сессия; план Task 10). /// /// Id тенанта. /// Подписанный id диалога. /// Сколько последних сообщений запросить. /// Отмена операции. public Task> GetMessagesAsync(string tenantId, string dialogId, int limit, CancellationToken cancellationToken) => RequireSession(tenantId).GetMessagesAsync(dialogId, limit, cancellationToken); /// /// Помечает диалог тенанта прочитанным (только ready-сессия; план Task 10). /// /// Id тенанта. /// Подписанный id диалога. /// Отмена операции. public Task MarkReadAsync(string tenantId, string dialogId, CancellationToken cancellationToken) => RequireSession(tenantId).MarkReadAsync(dialogId, cancellationToken); /// /// Глобальный поиск каналов/групп по ключу (только ready-сессия; план Task 11). /// /// Id тенанта. /// Поисковый запрос (ключ задачи discovery). /// Верхняя граница результата. /// Отмена операции. public Task> SearchAsync(string tenantId, string query, int limit, CancellationToken cancellationToken) => RequireSession(tenantId).SearchAsync(query, limit, cancellationToken); /// /// Инфо об источнике для оценки кандидата (только ready-сессия; план Task 11). /// /// Id тенанта. /// Подписанный id источника. /// Отмена операции. public Task GetInfoAsync(string tenantId, string dialogId, CancellationToken cancellationToken) => RequireSession(tenantId).GetInfoAsync(dialogId, cancellationToken); /// /// Выборка сообщений источника для оценки (только ready-сессия; план Task 11). /// /// Id тенанта. /// Подписанный id источника. /// Размер выборки. /// Отмена операции. public Task ReadForEvalAsync(string tenantId, string dialogId, int limit, CancellationToken cancellationToken) => RequireSession(tenantId).ReadForEvalAsync(dialogId, limit, cancellationToken); /// /// Вступить в канал/группу по username (только ready-сессия; план Task 11). /// /// Id тенанта. /// Username (без «@»; нормализует DiscoveryOps). /// Отмена операции. public Task JoinAsync(string tenantId, string username, CancellationToken cancellationToken) => RequireSession(tenantId).JoinAsync(username, cancellationToken); /// /// Выйти из канала/группы (только ready-сессия; план Task 11). /// /// Id тенанта. /// Подписанный id диалога. /// Отмена операции. public Task LeaveAsync(string tenantId, string dialogId, CancellationToken cancellationToken) => RequireSession(tenantId).LeaveAsync(dialogId, cancellationToken); /// /// Вход по телефону: сессия создаётся при первом обращении. /// /// Id тенанта. /// api_id приложения. /// api_hash приложения. /// Номер телефона. /// Отмена операции. public Task StartPhoneAsync(string tenantId, int apiId, string apiHash, string phone, CancellationToken cancellationToken) => GetOrCreate(tenantId).StartPhoneAsync(apiId, apiHash, phone, cancellationToken); /// /// QR-вход: сессия создаётся при первом обращении. /// /// Id тенанта. /// api_id приложения. /// api_hash приложения. /// Отмена операции. public Task StartQrAsync(string tenantId, int apiId, string apiHash, CancellationToken cancellationToken) => GetOrCreate(tenantId).StartQrAsync(apiId, apiHash, cancellationToken); /// /// Отправка SMS-кода на сессии тенанта. /// /// Id тенанта. /// Код. /// Отмена операции. public Task SendCodeAsync(string tenantId, string code, CancellationToken cancellationToken) => RequireSession(tenantId).SendCodeAsync(code, cancellationToken); /// /// Отправка облачного пароля 2FA на сессии тенанта. /// /// Id тенанта. /// Пароль. /// Отмена операции. public Task SendPasswordAsync(string tenantId, string password, CancellationToken cancellationToken) => RequireSession(tenantId).SendPasswordAsync(password, cancellationToken); /// /// Отключение аккаунта: Auth_LogOut + удаление файла сессии тенанта. /// /// Id тенанта. /// Отмена операции. public Task LogoutAsync(string tenantId, CancellationToken cancellationToken) { TenantSession? session = FindSession(tenantId); if (session is null) { // Сессии нет — нечего отключать; файла на диске тоже быть не должно (чистим на всякий случай). return DeleteStrayFileAsync(tenantId, cancellationToken); } return session.LogoutAsync(cancellationToken); } /// /// Статус сессии тенанта; null — аккаунт не подключён (сессии нет). /// /// Id тенанта. /// Отмена операции. public Task GetStatusAsync(string tenantId, CancellationToken cancellationToken) { TenantSession? session = FindSession(tenantId); return session is null ? Task.FromResult(null) : session.GetSnapshotAsync(cancellationToken); } /// /// Авто-возобновление на старте (auto_resume L209–222): для каждого файла сессии на диске создаёт /// клиент и при авторизации переводит тенанта в "ready". Сбои не роняют старт (внутри TryResumeAsync). /// /// Отмена операции. public async Task ResumeAllAsync(CancellationToken cancellationToken) { foreach (string tenantId in _sessionStore.ListTenantIds()) { if (cancellationToken.IsCancellationRequested) { return; } StoredSession? stored; try { stored = await _sessionStore.LoadAsync(tenantId, cancellationToken).ConfigureAwait(false); } catch (SessionException exception) { _logger.LogWarning(exception, "auto_resume: сессия {TenantId} не прочитана", tenantId); continue; } if (stored is null) { continue; } TenantSession session = GetOrCreate(tenantId); await session.TryResumeAsync(stored, cancellationToken).ConfigureAwait(false); } } /// /// Сердцебиение (30 с): повторное подключение оборвавшихся "ready"-сессий (heartbeat L318–327). /// /// Отмена операции. public async Task HeartbeatTickAsync(CancellationToken cancellationToken) { foreach (TenantSession session in _sessions.Values) { if (cancellationToken.IsCancellationRequested) { return; } await session.TryReconnectAsync(cancellationToken).ConfigureAwait(false); } } /// /// Остановка хоста: сохраняет живые сессии (перешифровка при остановке, Ruling 3) и освобождает /// клиенты. Ошибки отдельных сессий не останавливают остальные. /// /// Отмена операции. public async Task ShutdownAsync(CancellationToken cancellationToken) { foreach (TenantSession session in _sessions.Values) { try { await session.FlushAndDisposeAsync(cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { _logger.LogWarning(exception, "Остановка: сессия {TenantId} не освобождена", session.TenantId); } } } // Создаёт (или возвращает существующую) сессию тенанта. // tenantId: Id тенанта. private TenantSession GetOrCreate(string tenantId) => _sessions.GetOrAdd(tenantId, id => new TenantSession( id, _clientFactory, _sessionStore, _loggerFactory.CreateLogger())); // Сессия обязана существовать (иначе FAILED_PRECONDITION «Telegram не подключён»). // tenantId: Id тенанта. private TenantSession RequireSession(string tenantId) => FindSession(tenantId) ?? throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected); // Logout без сессии в памяти: удаляет возможный осиротевший файл сессии. // tenantId: Id тенанта. // cancellationToken: Отмена операции. private async Task DeleteStrayFileAsync(string tenantId, CancellationToken cancellationToken) { try { await _sessionStore.DeleteAsync(tenantId, cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { _logger.LogWarning(exception, "Logout {TenantId}: осиротевший файл сессии не удалён", tenantId); } return null; } }