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

299 lines
16 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 System.Collections.Concurrent;
using Grpc.Core;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Пул сессий тенантов «1 аккаунт на тенанта» (план Task 9, Sessions/SessionFarm.cs; Ruling 3,
/// архитектура §7.1). Сессия создаётся на первый вход/возобновление и переиспользуется (Logout
/// сбрасывает её в состояние «отключено» — из карты объект не удаляется, гонок вызова нет).
/// Все сетевые команды исполняются на сессии своего тенанта (Ruling 1); операций по чужим сессиям нет.
/// </summary>
public sealed class SessionFarm
{
private readonly ITelegramClientFactory _clientFactory;
private readonly SessionStore _sessionStore;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<SessionFarm> _logger;
private readonly ConcurrentDictionary<string, TenantSession> _sessions = new(StringComparer.Ordinal);
/// <summary>
/// Создаёт пул сессий.
/// </summary>
/// <param name="clientFactory">Фабрика клиентов Telegram (реальная или фейк в тестах).</param>
/// <param name="sessionStore">Файловое хранилище сессий.</param>
/// <param name="loggerFactory">Фабрика логгеров (логгеры TenantSession).</param>
/// <param name="logger">Логгер.</param>
public SessionFarm(
ITelegramClientFactory clientFactory,
SessionStore sessionStore,
ILoggerFactory loggerFactory,
ILogger<SessionFarm> logger)
{
_clientFactory = clientFactory;
_sessionStore = sessionStore;
_loggerFactory = loggerFactory;
_logger = logger;
}
/// <summary>
/// Возвращает сессию тенанта, если она уже создана (иначе null — «Telegram не подключён»).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
public TenantSession? FindSession(string tenantId)
=> _sessions.TryGetValue(tenantId, out TenantSession? session) ? session : null;
/// <summary>
/// Все сессии пула (снимок; для realtime-циклов каталога — RealtimeMonitorService/Sweep).
/// </summary>
public IReadOnlyCollection<TenantSession> Sessions
=> _sessions.Values.ToArray();
/// <summary>
/// Список диалогов аккаунта тенанта (только ready-сессия; план Task 10).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="limit">Верхняя граница числа диалогов.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<IReadOnlyList<TelegramDialog>> ListDialogsAsync(string tenantId, int limit, CancellationToken cancellationToken)
=> RequireSession(tenantId).ListDialogsAsync(limit, cancellationToken);
/// <summary>
/// Последние сообщения диалога тенанта (только ready-сессия; план Task 10).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="limit">Сколько последних сообщений запросить.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync(string tenantId, string dialogId, int limit, CancellationToken cancellationToken)
=> RequireSession(tenantId).GetMessagesAsync(dialogId, limit, cancellationToken);
/// <summary>
/// Помечает диалог тенанта прочитанным (только ready-сессия; план Task 10).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task MarkReadAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
=> RequireSession(tenantId).MarkReadAsync(dialogId, cancellationToken);
/// <summary>
/// Глобальный поиск каналов/групп по ключу (только ready-сессия; план Task 11).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
/// <param name="limit">Верхняя граница результата.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<IReadOnlyList<TelegramDialog>> SearchAsync(string tenantId, string query, int limit, CancellationToken cancellationToken)
=> RequireSession(tenantId).SearchAsync(query, limit, cancellationToken);
/// <summary>
/// Инфо об источнике для оценки кандидата (только ready-сессия; план Task 11).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TelegramSourceInfo> GetInfoAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
=> RequireSession(tenantId).GetInfoAsync(dialogId, cancellationToken);
/// <summary>
/// Выборка сообщений источника для оценки (только ready-сессия; план Task 11).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="limit">Размер выборки.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<DiscoveryReadResult> ReadForEvalAsync(string tenantId, string dialogId, int limit, CancellationToken cancellationToken)
=> RequireSession(tenantId).ReadForEvalAsync(dialogId, limit, cancellationToken);
/// <summary>
/// Вступить в канал/группу по username (только ready-сессия; план Task 11).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="username">Username (без «@»; нормализует DiscoveryOps).</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task JoinAsync(string tenantId, string username, CancellationToken cancellationToken)
=> RequireSession(tenantId).JoinAsync(username, cancellationToken);
/// <summary>
/// Выйти из канала/группы (только ready-сессия; план Task 11).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task LeaveAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
=> RequireSession(tenantId).LeaveAsync(dialogId, cancellationToken);
/// <summary>
/// Вход по телефону: сессия создаётся при первом обращении.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="apiId">api_id приложения.</param>
/// <param name="apiHash">api_hash приложения.</param>
/// <param name="phone">Номер телефона.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot> StartPhoneAsync(string tenantId, int apiId, string apiHash, string phone, CancellationToken cancellationToken)
=> GetOrCreate(tenantId).StartPhoneAsync(apiId, apiHash, phone, cancellationToken);
/// <summary>
/// QR-вход: сессия создаётся при первом обращении.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="apiId">api_id приложения.</param>
/// <param name="apiHash">api_hash приложения.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot> StartQrAsync(string tenantId, int apiId, string apiHash, CancellationToken cancellationToken)
=> GetOrCreate(tenantId).StartQrAsync(apiId, apiHash, cancellationToken);
/// <summary>
/// Отправка SMS-кода на сессии тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="code">Код.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot> SendCodeAsync(string tenantId, string code, CancellationToken cancellationToken)
=> RequireSession(tenantId).SendCodeAsync(code, cancellationToken);
/// <summary>
/// Отправка облачного пароля 2FA на сессии тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="password">Пароль.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot> SendPasswordAsync(string tenantId, string password, CancellationToken cancellationToken)
=> RequireSession(tenantId).SendPasswordAsync(password, cancellationToken);
/// <summary>
/// Отключение аккаунта: Auth_LogOut + удаление файла сессии тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot?> LogoutAsync(string tenantId, CancellationToken cancellationToken)
{
TenantSession? session = FindSession(tenantId);
if (session is null)
{
// Сессии нет — нечего отключать; файла на диске тоже быть не должно (чистим на всякий случай).
return DeleteStrayFileAsync(tenantId, cancellationToken);
}
return session.LogoutAsync(cancellationToken);
}
/// <summary>
/// Статус сессии тенанта; null — аккаунт не подключён (сессии нет).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot?> GetStatusAsync(string tenantId, CancellationToken cancellationToken)
{
TenantSession? session = FindSession(tenantId);
return session is null ? Task.FromResult<TenantSessionSnapshot?>(null) : session.GetSnapshotAsync(cancellationToken);
}
/// <summary>
/// Авто-возобновление на старте (auto_resume L209222): для каждого файла сессии на диске создаёт
/// клиент и при авторизации переводит тенанта в "ready". Сбои не роняют старт (внутри TryResumeAsync).
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
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);
}
}
/// <summary>
/// Сердцебиение (30 с): повторное подключение оборвавшихся "ready"-сессий (heartbeat L318327).
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task HeartbeatTickAsync(CancellationToken cancellationToken)
{
foreach (TenantSession session in _sessions.Values)
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
await session.TryReconnectAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Остановка хоста: сохраняет живые сессии (перешифровка при остановке, Ruling 3) и освобождает
/// клиенты. Ошибки отдельных сессий не останавливают остальные.
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
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<TenantSession>()));
// Сессия обязана существовать (иначе 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<TenantSessionSnapshot?> 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;
}
}