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