Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
299 lines
16 KiB
C#
299 lines
16 KiB
C#
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 L209–222): для каждого файла сессии на диске создаёт
|
||
/// клиент и при авторизации переводит тенанта в "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 L318–327).
|
||
/// </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;
|
||
}
|
||
}
|